diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/AWSClient.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/AWSClient.swift index e639c5f12cd..c1df3c890bf 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/AWSClient.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/AWSClient.swift @@ -31,6 +31,7 @@ package protocol S3ClientProtocol: Sendable { func getObject(input: GetObjectInput) async throws -> GetObjectOutput func putObject(input: PutObjectInput) async throws -> PutObjectOutput + func deleteObject(input: DeleteObjectInput) async throws -> DeleteObjectOutput func uploadPart(input: UploadPartInput) async throws -> UploadPartOutput func createMultipartUpload(input: CreateMultipartUploadInput) async throws -> CreateMultipartUploadOutput func completeMultipartUpload(input: CompleteMultipartUploadInput) async throws -> CompleteMultipartUploadOutput @@ -157,6 +158,15 @@ final class AWSClient: Sendable { } } + func delete(node: WireDriveNodeNetworkModel) async throws { + let deleteObjectInput = DeleteObjectInput( + bucket: Constants.bucket, + key: node.path + ) + + try await s3.deleteObject(input: deleteObjectInput) + } + private func upload( path: URL, node: WireDriveNodeNetworkModel, @@ -198,6 +208,7 @@ final class AWSClient: Sendable { try await withTaskCancellationHandler { do { + _ = try await s3.putObject(input: input) } catch { if Task.isCancelled { diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/NodesAPI.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/NodesAPI.swift index eda361d9081..7abe8f13796 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/NodesAPI.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/NodesAPI/NodesAPI.swift @@ -73,6 +73,11 @@ package final actor NodesAPI: NodesAPIProtocol, WireDriveNodesRepositoryProtocol : .success } + package func deleteFile(nodeID: UUID) async throws { + let node = try await getNode(nodeID: nodeID) + try await awsClient.delete(node: node.toDTO()) + } + package func uploadFile( path: URL, node: WireDriveNode, diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveDraft.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveDraft.swift index f54abcdc9a5..550ec115ef4 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveDraft.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveDraft.swift @@ -56,7 +56,7 @@ public struct WireDriveDraft: Hashable, Sendable { /// The URL of the asset that contains the file data. - package let assetURL: URL + public let assetURL: URL /// The type of the file, represented as a Uniform Type Identifier (UTType). This value is determined locally. @@ -87,6 +87,14 @@ public struct WireDriveDraft: Hashable, Sendable { public let metadata: Metadata? + /// Optional data associated to an image. + public let data: Data? + + /// Optional unique identifier from the device’s Photos library, matching `PHAsset.localIdentifier` or + /// `PHPickerResult.assetIdentifier`. + /// Used to manage drafts in conversation previews. + public let localIdentifier: String? + package init( nodeID: UUID, versionID: UUID, @@ -97,7 +105,9 @@ public struct WireDriveDraft: Hashable, Sendable { bytes: Int, mimeType: String?, requiresCleanup: Bool, - metadata: Metadata? + metadata: Metadata?, + data: Data?, + localIdentifier: String? ) { self.nodeID = nodeID self.versionID = versionID @@ -109,5 +119,17 @@ public struct WireDriveDraft: Hashable, Sendable { self.mimeType = mimeType self.requiresCleanup = requiresCleanup self.metadata = metadata + self.data = data + self.localIdentifier = localIdentifier + } +} + +public extension WireDriveDraft { + var isImage: Bool { + fileType?.conforms(to: .image) ?? (data != nil) + } + + var isVideo: Bool { + fileType?.conforms(to: .movie) ?? false } } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/NodesAPIProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/NodesAPIProtocol.swift index 7abcb3bc253..a419a3ea0a5 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/NodesAPIProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/NodesAPIProtocol.swift @@ -30,6 +30,8 @@ package protocol NodesAPIProtocol: Sendable { func uploadFile(path: URL, node: WireDriveNode, versionID: UUID) async throws -> AsyncThrowingStream + func deleteFile(nodeID: UUID) async throws + func deleteVersion(nodeID: UUID, versionID: UUID) async throws func publishDraft(nodeID: UUID, versionID: UUID) async throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveUploadDraftUseCaseProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveUploadDraftUseCaseProtocol.swift index 2ab0ddc449a..a5d488b3e07 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveUploadDraftUseCaseProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveUploadDraftUseCaseProtocol.swift @@ -25,11 +25,12 @@ public protocol WireDriveUploadDraftUseCaseProtocol: Sendable { /// Uploads the file at `fileURL` to the drive server. - func invoke(fileURL: URL) async throws + func invoke(fileURL: URL, localIdentifier: String?) async throws /// Creates a file using `imageData` and uploads it to the drive server. + /// When an `existingNodeID` is provided, an existing asset is being updated. - func invoke(data: Data, type: UTType) async throws + func invoke(data: Data, type: UTType, localIdentifier: String?, existingNodeID: UUID?) async throws var charactersToReplace: [Character] { get } } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/UploadDraftUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/UploadDraftUseCase.swift index c6efbd2e8cb..4e0be2dc1e6 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/UploadDraftUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/UploadDraftUseCase.swift @@ -58,8 +58,8 @@ package struct UploadDraftUseCase: WireDriveUploadDraftUseCaseProtocol, WireDriv self.filenameGenerator = filenameGenerator } - package func invoke(fileURL: URL) async throws { - try await invoke(fileURL: fileURL, requiresCleanup: false) + package func invoke(fileURL: URL, localIdentifier: String?) async throws { + try await invoke(fileURL: fileURL, localIdentifier: localIdentifier, requiresCleanup: false) } /// Uploads a file using an existing draft's nodeID. @@ -109,7 +109,7 @@ package struct UploadDraftUseCase: WireDriveUploadDraftUseCaseProtocol, WireDriv } } - package func invoke(data: Data, type: UTType) async throws { + package func invoke(data: Data, type: UTType, localIdentifier: String?, existingNodeID: UUID?) async throws { let filename = await filenameGenerator.generateFilename(type: type) let container = intermediaryFilesDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -118,12 +118,24 @@ package struct UploadDraftUseCase: WireDriveUploadDraftUseCaseProtocol, WireDriv try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) try data.write(to: url) - try await invoke(fileURL: url, requiresCleanup: true) + try await invoke( + fileURL: url, + data: data, + localIdentifier: localIdentifier, + existingNodeID: existingNodeID, + requiresCleanup: true + ) } // MARK: - Private Methods - private func invoke(fileURL: URL, requiresCleanup: Bool) async throws { + private func invoke( + fileURL: URL, + data: Data? = nil, + localIdentifier: String? = nil, + existingNodeID: UUID? = nil, + requiresCleanup: Bool + ) async throws { let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey]) guard let fileSize = resourceValues.fileSize, fileSize > 0 else { throw WireDriveUploadDraftUseCaseError.missingFileSize @@ -136,7 +148,7 @@ package struct UploadDraftUseCase: WireDriveUploadDraftUseCaseProtocol, WireDriv } let draft = WireDriveDraft( - nodeID: UUID(), + nodeID: existingNodeID ?? UUID(), versionID: UUID(), assetURL: fileURL, fileType: resourceValues.contentType, @@ -145,10 +157,18 @@ package struct UploadDraftUseCase: WireDriveUploadDraftUseCaseProtocol, WireDriv bytes: fileSize, mimeType: nil, requiresCleanup: requiresCleanup, - metadata: try? await metadata(for: fileURL, fileType: resourceValues.contentType) + metadata: try? await metadata(for: fileURL, fileType: resourceValues.contentType), + data: data, + localIdentifier: localIdentifier ) - await draftRepository.addDraft(draft, for: cellName) + if let existingNodeID { + await draftRepository.updateDraft(draft, for: cellName) + try await nodesAPI.deleteFile(nodeID: existingNodeID) + } else { + await draftRepository.addDraft(draft, for: cellName) + } + try await invoke(nodeID: draft.nodeID) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarousel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarousel.swift index f8cb1c2ee3d..dcf63fb029e 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarousel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarousel.swift @@ -79,6 +79,7 @@ private struct AttachmentsCarouselItemView: View { ZStack { ZStack { content + .contentShape(Rectangle()) // Constrains the tappable content area of the view. .onTapGesture(perform: onTap) } .aspectRatio(item.aspectRatio, contentMode: .fill) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarouselViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarouselViewModel.swift index ec52e09203f..39929d7a900 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarouselViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsCarousel/AttachmentsCarouselViewModel.swift @@ -36,6 +36,10 @@ public final class AttachmentsCarouselViewModel: ObservableObject { private var thumbnails: [UUID: UIImage] = [:] private var generatingThumbnailIDs: Set = [] + public var draftsLocalIdentifiers: [String] { + drafts.compactMap(\.localIdentifier) + } + @Published private(set) var items: [AttachmentsCarouselItem] public convenience init() { @@ -63,6 +67,19 @@ public final class AttachmentsCarouselViewModel: ObservableObject { } } + public func draft(for item: AttachmentsCarouselItem) -> WireDriveDraft? { + guard let index = items.firstIndex(of: item), + drafts.indices.contains(index) else { + return nil + } + + return drafts[index] + } + + public func draft(withLocalIdentifier localIdentifier: String) -> WireDriveDraft? { + drafts.first(where: { $0.localIdentifier == localIdentifier }) + } + private func refreshItems() { items = drafts.compactMap { AttachmentsCarouselItem(draft: $0, thumbnail: thumbnails[$0.versionID]) } } diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveDraft+Fixture.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveDraft+Fixture.swift index 4df97afc71c..5e17d2c73fa 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveDraft+Fixture.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveDraft+Fixture.swift @@ -43,7 +43,9 @@ extension WireDriveDraft { bytes: bytes, mimeType: mimeType, requiresCleanup: requiresCleanup, - metadata: metadata + metadata: metadata, + data: nil, + localIdentifier: nil ) } } diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/UploadDraftUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/UploadDraftUseCaseTests.swift index 8fd4a997846..0f3fa1d4fcc 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/UploadDraftUseCaseTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/UploadDraftUseCaseTests.swift @@ -211,7 +211,7 @@ final class UploadDraftUseCaseTests { // When, Then let sut = sut await #expect(throws: (any Error).self) { - try await sut.invoke(fileURL: url) + try await sut.invoke(fileURL: url, localIdentifier: nil) } } @@ -223,7 +223,7 @@ final class UploadDraftUseCaseTests { try data.write(to: fileURL) // When - try await sut.invoke(fileURL: fileURL) + try await sut.invoke(fileURL: fileURL, localIdentifier: nil) // Then let arguments = try #require(draftsRepository.addDraftFor_Invocations.first) @@ -254,7 +254,7 @@ final class UploadDraftUseCaseTests { metadataRepository.videoMetadataFileURL_MockValue = .video(width: 10, height: 10, duration: 10) // When - try await sut.invoke(fileURL: fileURL) + try await sut.invoke(fileURL: fileURL, localIdentifier: nil) // Then let arguments = try #require(draftsRepository.addDraftFor_Invocations.first) @@ -268,7 +268,7 @@ final class UploadDraftUseCaseTests { metadataRepository.imageMetadataFileURL_MockError = NSError(domain: "something", code: 10) // When - try await sut.invoke(fileURL: fileURL) + try await sut.invoke(fileURL: fileURL, localIdentifier: nil) // Then #expect(draftsRepository.addDraftFor_Invocations.count == 1) @@ -285,7 +285,7 @@ final class UploadDraftUseCaseTests { let data = Data("This is a test file content.".utf8) // When - try await sut.invoke(data: data, type: type) + try await sut.invoke(data: data, type: type, localIdentifier: nil, existingNodeID: nil) // Then let arguments = try #require(draftsRepository.addDraftFor_Invocations.first) @@ -305,7 +305,7 @@ final class UploadDraftUseCaseTests { let data = Data("This is a test file content.".utf8) // When - try await sut.invoke(data: data, type: .plainText) + try await sut.invoke(data: data, type: .plainText, localIdentifier: nil, existingNodeID: nil) // Then let arguments = try #require(draftsRepository.addDraftFor_Invocations.first) diff --git a/WireUI/Sources/WireLocators/Locators.swift b/WireUI/Sources/WireLocators/Locators.swift index deb307d3d07..11a3e092c60 100644 --- a/WireUI/Sources/WireLocators/Locators.swift +++ b/WireUI/Sources/WireLocators/Locators.swift @@ -132,6 +132,7 @@ public enum Locators { case sketchButton case canvas case canvasSendButton + case canvasConfirmButton case attachmentImagePreview case attachmentVideoPreview case classifiedBanner = "ClassificationBannerClassified" diff --git a/wire-ios-data-model/Source/Model/Conversation/SendableImage.swift b/wire-ios-data-model/Source/Model/Conversation/SendableImage.swift index 3298c35e78e..563a9c4ce38 100644 --- a/wire-ios-data-model/Source/Model/Conversation/SendableImage.swift +++ b/wire-ios-data-model/Source/Model/Conversation/SendableImage.swift @@ -20,11 +20,15 @@ import UniformTypeIdentifiers public struct SendableImage { + public let id: UUID + public let localIdentifier: String? // Optional unique identifier from the device’s Photos library public let name: String public let utType: UTType? public let data: Data public init( + id: UUID = UUID(), + localIdentifier: String? = nil, name: String?, utType: UTType?, data: Data @@ -43,7 +47,9 @@ public struct SendableImage { self.name = "picture" } + self.localIdentifier = localIdentifier self.data = data + self.id = id } private static func determineUTType(from data: Data) -> UTType? { diff --git a/wire-ios/Wire-iOS Tests/CameraKeyboardViewControllerTests.swift b/wire-ios/Wire-iOS Tests/CameraKeyboardViewControllerTests.swift index d23278ea44c..aa1d1158bac 100644 --- a/wire-ios/Wire-iOS Tests/CameraKeyboardViewControllerTests.swift +++ b/wire-ios/Wire-iOS Tests/CameraKeyboardViewControllerTests.swift @@ -19,6 +19,7 @@ import AVFoundation import Photos import WireDesign +import WireMessagingUI import WireTestingPackage import XCTest @@ -45,6 +46,7 @@ final class CameraKeyboardViewControllerDelegateMock: CameraKeyboardViewControll func cameraKeyboardViewController( _ controller: CameraKeyboardViewController, didSelectVideo: URL, + withLocalIdentifier id: String?, duration: TimeInterval ) { cameraKeyboardDidSelectVideoHitCount += 1 @@ -58,6 +60,15 @@ final class CameraKeyboardViewControllerDelegateMock: CameraKeyboardViewControll ) { cameraKeyboardViewControllerDidSelectImageDataHitCount += 1 } + + var cameraKeyboardViewControllerDidDeselectImageDataHitCount: UInt = 0 + func cameraKeyboardViewController( + _ controller: Wire.CameraKeyboardViewController, + didDeselectImage image: PHAsset + ) { + cameraKeyboardViewControllerDidDeselectImageDataHitCount += 1 + } + } // MARK: - SplitLayoutObservableMock @@ -188,6 +199,7 @@ final class CameraKeyboardViewControllerTests: XCTestCase { sut = CameraKeyboardViewController( splitLayoutObservable: splitView, permissions: permissions, + attachmentsCarouselViewModel: AttachmentsCarouselViewModel(), userSession: UserSessionMock() ) } @@ -199,6 +211,7 @@ final class CameraKeyboardViewControllerTests: XCTestCase { sut = CallingMockCameraKeyboardViewController( splitLayoutObservable: splitView, permissions: permissions, + attachmentsCarouselViewModel: AttachmentsCarouselViewModel(), userSession: UserSessionMock() ) @@ -291,7 +304,7 @@ final class CameraKeyboardViewControllerTests: XCTestCase { initialStateLayoutSizeCompact(with: permissions) } - // MARK: - Tests for InitialStateLayoutSizeRegularPortrait + // MARK: - Tests for InitialStateLayoutSizeRegularPortrait#imageLiteral(resourceName: "testInitialStateLayoutSizeCompact_LibraryAccessGranted.1.png") private func initialStateLayoutSizeRegularPortrait( with permissions: PhotoPermissionsController, diff --git a/wire-ios/Wire-iOS Tests/Mocks/WireCells/WireDriveUploadDraftUseCaseProtocolMock.swift b/wire-ios/Wire-iOS Tests/Mocks/WireCells/WireDriveUploadDraftUseCaseProtocolMock.swift index 924738a4c6e..e08b37c2921 100644 --- a/wire-ios/Wire-iOS Tests/Mocks/WireCells/WireDriveUploadDraftUseCaseProtocolMock.swift +++ b/wire-ios/Wire-iOS Tests/Mocks/WireCells/WireDriveUploadDraftUseCaseProtocolMock.swift @@ -21,12 +21,11 @@ import UniformTypeIdentifiers import WireMessagingDomain final class WireDriveUploadDraftUseCaseProtocolMock: WireDriveUploadDraftUseCaseProtocol { - - func invoke(fileURL: URL) async throws { + func invoke(fileURL: URL, localIdentifier: String?) async throws { fatalError("Implement") } - func invoke(data: Data, type: UTType) async throws { + func invoke(data: Data, type: UTType, localIdentifier: String?, nodeID: UUID?) async throws { fatalError("Implement") } diff --git a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift index 6d3562dc302..256b03d9da1 100644 --- a/wire-ios/Wire-iOS/Generated/Strings+Generated.swift +++ b/wire-ios/Wire-iOS/Generated/Strings+Generated.swift @@ -1090,6 +1090,10 @@ internal enum L10n { /// Close sketch internal static let description = L10n.tr("Accessibility", "sketch.closeButton.description", fallback: "Close sketch") } + internal enum ConfirmButton { + /// Confirm + internal static let description = L10n.tr("Accessibility", "sketch.confirmButton.description", fallback: "Confirm") + } internal enum DrawButton { /// Draw or write internal static let description = L10n.tr("Accessibility", "sketch.drawButton.description", fallback: "Draw or write") diff --git a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Accessibility.strings b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Accessibility.strings index e8d5fdaad64..748c1a3ae14 100644 --- a/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Accessibility.strings +++ b/wire-ios/Wire-iOS/Resources/Localization/Base.lproj/Accessibility.strings @@ -130,6 +130,7 @@ "sketch.drawButton.hint" = "Double tap to enable or disable"; "sketch.selectEmojiButton.description" = "Select emoji"; "sketch.sendButton.description" = "Send"; +"sketch.confirmButton.description" = "Confirm"; "sketch.undoButton.description" = "Undo last step"; "sketch.closeButton.description" = "Close sketch"; diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Components/Controllers/ConfirmAssetViewController/ConfirmAssetViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Components/Controllers/ConfirmAssetViewController/ConfirmAssetViewController.swift index df5d2f9f014..72d0d0c2c8f 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Components/Controllers/ConfirmAssetViewController/ConfirmAssetViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Components/Controllers/ConfirmAssetViewController/ConfirmAssetViewController.swift @@ -49,6 +49,7 @@ final class ConfirmAssetViewController: UIViewController { let context: Context private let userSession: UserSession + private let isWireDriveEnabled: Bool var previewTitle: String? { didSet { @@ -87,9 +88,10 @@ final class ConfirmAssetViewController: UIViewController { wr_supportedInterfaceOrientations } - init(context: Context, userSession: UserSession) { + init(context: Context, userSession: UserSession, isWireDriveEnabled: Bool = false) { self.context = context self.userSession = userSession + self.isWireDriveEnabled = isWireDriveEnabled super.init(nibName: nil, bundle: nil) } @@ -185,7 +187,10 @@ final class ConfirmAssetViewController: UIViewController { return } - let canvasViewController = CanvasViewController(userSession: userSession) + let canvasViewController = CanvasViewController( + userSession: userSession, + isWireDriveEnabled: isWireDriveEnabled + ) canvasViewController.sketchImage = image canvasViewController.delegate = self canvasViewController.title = previewTitle diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/AssetCell.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/AssetCell.swift index 5005acc89a0..203124dddbd 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/AssetCell.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/AssetCell.swift @@ -25,10 +25,13 @@ final class AssetCell: UICollectionViewCell { let imageView = UIImageView() let durationView = UILabel() + var checkMarkView = UIImageView() var imageRequestTag: PHImageRequestID = PHInvalidImageRequestID var representedAssetIdentifier: String! var manager: ImageManagerProtocol! + var isWireDriveEnabled: Bool = false + var accentColor: UIColor! override init(frame: CGRect) { super.init(frame: frame) @@ -45,7 +48,10 @@ final class AssetCell: UICollectionViewCell { durationView.font = FontSpec(.small, .light).font! contentView.addSubview(durationView) - [imageView, durationView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + checkMarkView.isHidden = true + contentView.addSubview(checkMarkView) + + [imageView, durationView, checkMarkView].forEach { $0.translatesAutoresizingMaskIntoConstraints = false } NSLayoutConstraint.activate([ imageView.topAnchor.constraint(equalTo: contentView.topAnchor), imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), @@ -54,10 +60,20 @@ final class AssetCell: UICollectionViewCell { durationView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), durationView.leftAnchor.constraint(equalTo: contentView.leftAnchor), durationView.rightAnchor.constraint(equalTo: contentView.rightAnchor), - durationView.heightAnchor.constraint(equalToConstant: 20) + durationView.heightAnchor.constraint(equalToConstant: 20), + checkMarkView.heightAnchor.constraint(equalToConstant: 20), + checkMarkView.widthAnchor.constraint(equalTo: checkMarkView.heightAnchor), + checkMarkView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -5), + checkMarkView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -5) ]) } + override var isSelected: Bool { + didSet { + updateCheckmarkButton() + } + } + @available(*, unavailable) required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -71,6 +87,14 @@ final class AssetCell: UICollectionViewCell { return options }() + private func updateCheckmarkButton() { + guard isWireDriveEnabled else { return } + let config = UIImage.SymbolConfiguration(paletteColors: [.white, accentColor]) + let image = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config) + checkMarkView.image = image + checkMarkView.isHidden = !isSelected + } + var asset: PHAsset? { didSet { imageView.image = nil diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/CameraKeyboardViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/CameraKeyboardViewController.swift index 87067d82090..6eed6ec7961 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/CameraKeyboardViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/CameraKeyboard/CameraKeyboardViewController.swift @@ -21,6 +21,8 @@ import Photos import UIKit import WireCommonComponents import WireDesign +import WireFoundation +import WireMessagingUI import WireReusableUIComponents import WireSyncEngine @@ -32,6 +34,7 @@ protocol CameraKeyboardViewControllerDelegate: AnyObject { func cameraKeyboardViewController( _ controller: CameraKeyboardViewController, didSelectVideo: URL, + withLocalIdentifier id: String?, duration: TimeInterval ) func cameraKeyboardViewController( @@ -39,6 +42,12 @@ protocol CameraKeyboardViewControllerDelegate: AnyObject { didSelectImage image: SendableImage, isFromCamera: Bool ) + + func cameraKeyboardViewController( + _ controller: CameraKeyboardViewController, + didDeselectImage image: PHAsset + ) + func cameraKeyboardViewControllerWantsToOpenFullScreenCamera(_ controller: CameraKeyboardViewController) func cameraKeyboardViewControllerWantsToOpenCameraRoll(_ controller: CameraKeyboardViewController) } @@ -75,6 +84,9 @@ class CameraKeyboardViewController: UIViewController { private let mediaSharingRestrictionsMananger: MediaShareRestrictionManager private let userSession: UserSession + private let isWireDriveEnabled: Bool + private let attachmentsCarouselViewModel: AttachmentsCarouselViewModel + let assetLibrary: AssetLibrary? let imageManagerType: ImageManagerProtocol.Type @@ -93,9 +105,13 @@ class CameraKeyboardViewController: UIViewController { splitLayoutObservable: SplitLayoutObservable, imageManagerType: ImageManagerProtocol.Type = PHImageManager.self, permissions: PhotoPermissionsController = PhotoPermissionsControllerStrategy(), + attachmentsCarouselViewModel: AttachmentsCarouselViewModel, + isWireDriveEnabled: Bool = false, userSession: UserSession ) { self.userSession = userSession + self.isWireDriveEnabled = isWireDriveEnabled + self.attachmentsCarouselViewModel = attachmentsCarouselViewModel self.mediaSharingRestrictionsMananger = MediaShareRestrictionManager( sessionRestriction: userSession as? ZMUserSession ) @@ -249,7 +265,7 @@ class CameraKeyboardViewController: UIViewController { collectionView.delegate = self collectionView.dataSource = self collectionView.translatesAutoresizingMaskIntoConstraints = false - collectionView.allowsMultipleSelection = false + collectionView.allowsMultipleSelection = isWireDriveEnabled collectionView.allowsSelection = true collectionView.backgroundColor = UIColor.clear collectionView.bounces = false @@ -322,6 +338,7 @@ class CameraKeyboardViewController: UIViewController { let name = PHAssetResource.assetResources(for: asset).first?.originalFilename let image = SendableImage( + localIdentifier: asset.localIdentifier, name: name, utType: utType, data: returnData @@ -420,6 +437,7 @@ class CameraKeyboardViewController: UIViewController { private func forwardSelectedVideoAsset(_ asset: PHAsset) { activityIndicator.start() let fileLengthLimit: UInt64 = userSession.maxUploadFileSize + let localIdentifier = asset.localIdentifier asset.getVideoURL { url in DispatchQueue.main.async { @@ -448,6 +466,7 @@ class CameraKeyboardViewController: UIViewController { self.delegate?.cameraKeyboardViewController( self, didSelectVideo: resultURL, + withLocalIdentifier: localIdentifier, duration: CMTimeGetSeconds(asset.duration) ) } @@ -520,15 +539,22 @@ extension CameraKeyboardViewController: UICollectionViewDelegateFlowLayout, UICo return deniedAuthorizationCell(for: .photos, collectionView: collectionView, indexPath: indexPath) } + let accentColor = WireAccentColor(rawValue: userSession.selfUser.accentColorValue) ?? .default + let cell = collectionView.dequeueReusableCell( withReuseIdentifier: AssetCell.reuseIdentifier, for: indexPath ) as! AssetCell cell.manager = imageManagerType.defaultInstance + cell.isWireDriveEnabled = isWireDriveEnabled + cell.accentColor = accentColor.uiColor if let asset = try? assetLibrary?.asset(atIndex: UInt((indexPath as NSIndexPath).row)) { cell.asset = asset + if attachmentsCarouselViewModel.draftsLocalIdentifiers.contains(asset.localIdentifier) { + collectionView.selectItem(at: indexPath, animated: true, scrollPosition: []) + } } return cell @@ -612,6 +638,27 @@ extension CameraKeyboardViewController: UICollectionViewDelegateFlowLayout, UICo } } + func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { + guard let asset = try? assetLibrary?.asset(atIndex: UInt((indexPath as NSIndexPath).row)) else { + return + } + + delegate?.cameraKeyboardViewController(self, didDeselectImage: asset) + } + + func deselectItem(withLocalIdentifier localIdentifier: String) { + let indexPath = collectionView + .indexPathsForSelectedItems? + .first(where: { + let assetCell = collectionView.cellForItem(at: $0) as? AssetCell + return assetCell?.representedAssetIdentifier == localIdentifier + }) + + if let indexPath { + collectionView.deselectItem(at: indexPath, animated: true) + } + } + func collectionView( _ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Camera.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Camera.swift index cedfe9757d3..911eb200131 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Camera.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Camera.swift @@ -39,6 +39,8 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega let splitLayoutObserver = SplitLayoutObserver(zClientViewController: zClientViewController) let cameraKeyboardViewController = CameraKeyboardViewController( splitLayoutObservable: splitLayoutObserver, + attachmentsCarouselViewModel: attachmentsCarouselViewModel, + isWireDriveEnabled: useWireDrive(), userSession: userSession ) cameraKeyboardViewController.delegate = self @@ -49,6 +51,7 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega func cameraKeyboardViewController( _ controller: CameraKeyboardViewController, didSelectVideo videoURL: URL, + withLocalIdentifier id: String?, duration: TimeInterval ) { // Video can be longer than allowed to be uploaded. Then we need to add user the possibility to trim it. @@ -82,24 +85,11 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega present(videoEditor, animated: true) {} } } else { - let context = ConfirmAssetViewController.Context( - asset: .video(url: videoURL), - onConfirm: { [unowned self] _ in - dismiss(animated: true) - uploadFiles(at: [videoURL]) - }, - onCancel: { [unowned self] in - dismiss(animated: true) { - self.mode = .camera - self.inputBar.textView.becomeFirstResponder() - } - } - ) - let confirmVideoViewController = ConfirmAssetViewController(context: context, userSession: userSession) - confirmVideoViewController.previewTitle = conversation.displayNameWithFallback - - view.window?.endEditing(true) - present(confirmVideoViewController, animated: true) + if useWireDrive() { + uploadVideoFile(.init(url: videoURL, localIdentifier: id)) + } else { + showConfirmationForVideo(url: videoURL) + } } } @@ -108,10 +98,28 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega didSelectImage image: SendableImage, isFromCamera: Bool ) { - showConfirmationForImage( - image, - isFromCamera: isFromCamera - ) + if useWireDrive(), !isFromCamera { + let dataToSend = image.data + let utType: UTType = image.utType ?? .image + uploadDraft(data: dataToSend, type: utType, localIdentifier: image.localIdentifier) + } else { + showConfirmationForImage( + image, + isFromCamera: isFromCamera + ) + } + } + + func cameraKeyboardViewController(_ controller: CameraKeyboardViewController, didDeselectImage image: PHAsset) { + let draft = attachmentsCarouselViewModel.draft(withLocalIdentifier: image.localIdentifier) + + guard let draft else { + return WireLogger.wireDrive.error("Draft with localIdentifier: \(image.localIdentifier) not found") + } + + Task.detached { [deleteDraftUseCase] in + try? await deleteDraftUseCase.invoke(nodeID: draft.nodeID) + } } @objc @@ -154,9 +162,33 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega } } + func showConfirmationForVideo(url: URL) { + let context = ConfirmAssetViewController.Context( + asset: .video(url: url), + onConfirm: { [unowned self] _ in + dismiss(animated: true) + if !useWireDrive() { + uploadFiles(at: [url]) + } + }, + onCancel: { [unowned self] in + dismiss(animated: true) { + self.mode = .camera + self.inputBar.textView.becomeFirstResponder() + } + } + ) + let confirmVideoViewController = ConfirmAssetViewController(context: context, userSession: userSession) + confirmVideoViewController.previewTitle = conversation.displayNameWithFallback + + view.window?.endEditing(true) + present(confirmVideoViewController, animated: true) + } + func showConfirmationForImage( _ image: SendableImage, - isFromCamera: Bool + isFromCamera: Bool, + nodeID: UUID? = nil ) { let mediaAsset: MediaAsset = if image.utType == .gif, @@ -184,9 +216,14 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega image.utType ?? .image } - if self.userSession.isWireDriveEnabled, - self.conversation.isWireDriveEnabled { - self.uploadDraft(data: dataToSend, type: utType) + if self.useWireDrive() { + if isFromCamera || editedImage != nil { + self.uploadDraft( + data: dataToSend, + type: utType, + existingNodeID: isFromCamera ? nil : image.id + ) + } } else { let image = SendableImage( name: nil, @@ -209,7 +246,11 @@ extension ConversationInputBarViewController: CameraKeyboardViewControllerDelega } ) - let confirmImageViewController = ConfirmAssetViewController(context: context, userSession: userSession) + let confirmImageViewController = ConfirmAssetViewController( + context: context, + userSession: userSession, + isWireDriveEnabled: useWireDrive() + ) confirmImageViewController.previewTitle = conversation.displayNameWithFallback view.window?.endEditing(true) @@ -310,8 +351,7 @@ extension ConversationInputBarViewController: CanvasViewControllerDelegate { dismiss(animated: true) { if let imageData = image.pngData() { - if self.userSession.isWireDriveEnabled, - self.conversation.isWireDriveEnabled { + if self.useWireDrive() { self.uploadDraft(data: imageData, type: .png) } else { let image = SendableImage( @@ -329,11 +369,16 @@ extension ConversationInputBarViewController: CanvasViewControllerDelegate { } } - private func uploadDraft(data: Data, type: UTType) { + func uploadDraft(data: Data, type: UTType, localIdentifier: String? = nil, existingNodeID: UUID? = nil) { Task.detached { [uploadDraftUseCase] in // We don't care about the result of the operation here as we will be observing changes. do { - try await uploadDraftUseCase.invoke(data: data, type: type) + try await uploadDraftUseCase.invoke( + data: data, + type: type, + localIdentifier: localIdentifier, + existingNodeID: existingNodeID + ) } catch { WireLogger.conversation.error("Failed to upload file: \(error)") } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Files.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Files.swift index 6959adcd028..e5bce2f74c8 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Files.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+Files.swift @@ -51,37 +51,67 @@ extension ConversationInputBarViewController { func uploadFiles(at urls: [URL]) { guard !urls.isEmpty else { return } + let files: [FileMetadata] = urls.map(FileMetadata.init) let charactersToReplace = uploadDraftUseCase.charactersToReplace if urls.contains(where: { $0.lastPathComponent.contains(where: { charactersToReplace.contains($0) }) }) { - showAlertForFileNeedsRename(urls: urls) + showAlertForFileNeedsRename(files) } else { - continueUploadFiles(at: urls) + continueUploadFiles(files) } } - private func continueUploadFiles(at urls: [URL]) { - if userSession.isWireDriveEnabled, conversation.isWireDriveEnabled { - for url in urls { + /// Metadata describing a selected file and its optional link to a Photos asset. + struct FileMetadata { + let url: URL + + /// Optional device Photos asset identifier used for draft handling in conversation previews + /// (`PHAsset.localIdentifier` / `PHPickerResult.assetIdentifier`). + let localIdentifier: String? + + init(url: URL, localIdentifier: String?) { + self.url = url + self.localIdentifier = localIdentifier + } + + init(url: URL) { + self.url = url + self.localIdentifier = nil + } + } + + func uploadVideoFile(_ file: FileMetadata) { + let charactersToReplace = uploadDraftUseCase.charactersToReplace + + if file.url.lastPathComponent.contains(where: { charactersToReplace.contains($0) }) { + showAlertForFileNeedsRename([file]) + } else { + continueUploadFiles([file]) + } + } + + private func continueUploadFiles(_ files: [FileMetadata]) { + if useWireDrive() { + for file in files { Task.detached { [uploadDraftUseCase] in // We don't care about the result of the operation here as we will be observing changes. do { - try await uploadDraftUseCase.invoke(fileURL: url) + try await uploadDraftUseCase.invoke(fileURL: file.url, localIdentifier: file.localIdentifier) } catch { WireLogger.conversation.error("Failed to upload file: \(error)") } } } - } else if urls.count == 1 { - uploadFile(at: urls[0]) + } else if files.count == 1 { + uploadFile(at: files[0].url) } else { do { let temporaryDirectory = URL(fileURLWithPath: NSTemporaryDirectory()) let archiveURL = temporaryDirectory.appending(path: "archive.zip", directoryHint: .notDirectory) - try ZIPFoundationFileArchiver().zipResources(at: urls, into: archiveURL) + try ZIPFoundationFileArchiver().zipResources(at: files.map(\.url), into: archiveURL) uploadFile(at: archiveURL) } catch { - zmLog.error("Cannot archive files at URLs: \(urls)") + zmLog.error("Cannot archive files at URLs: \(files.map(\.url))") } } } @@ -166,7 +196,7 @@ extension ConversationInputBarViewController { present(alert, animated: true) } - private func showAlertForFileNeedsRename(urls: [URL]) { + private func showAlertForFileNeedsRename(_ files: [FileMetadata]) { let characters = uploadDraftUseCase.charactersToReplace.map(String.init) let formattedCharacters = characters.dropLast().joined(separator: " ") let lastCharacter = characters.last ?? "" @@ -187,7 +217,7 @@ extension ConversationInputBarViewController { title: L10n.Localizable.Content.UploadedFileNeedsRename.confirmButton, style: .default, handler: { [weak self] _ in - self?.continueUploadFiles(at: urls) + self?.continueUploadFiles(files) } ) ) diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePicker.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePicker.swift index 807f5d0c31b..0ff4a3bd909 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePicker.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePicker.swift @@ -17,6 +17,7 @@ // import AVFoundation +import PhotosUI import UIKit import WireSyncEngine @@ -43,29 +44,41 @@ extension ConversationInputBarViewController { } let presentController = { [self] in + // Allows multiple media selection on Wire drive conversations. + if useWireDrive(), sourceType != .camera { + // As per Apple's doc, we shouldn't use the empty initializer if we need the asset identifiers to be + // non-nil. + var config = PHPickerConfiguration(photoLibrary: PHPhotoLibrary.shared()) + config.selectionLimit = 0 + config.filter = .any(of: [.images, .videos]) + + let picker = PHPickerViewController(configuration: config) + picker.delegate = self + present(picker, animated: true) + } else { + let pickerController = UIImagePickerController() + pickerController.sourceType = sourceType + pickerController.preferredContentSize = .IPadPopover.preferredContentSize + pickerController.delegate = self + pickerController.allowsEditing = allowsEditing + pickerController.mediaTypes = mediaTypes + pickerController.videoMaximumDuration = userSession.maxVideoLength + pickerController.videoExportPreset = AVURLAsset.defaultVideoQuality + if sourceType == .camera { + let settingsCamera: SettingsCamera? = Settings.shared[.preferredCamera] + pickerController.cameraDevice = settingsCamera == .back ? .rear : .front + } - let pickerController = UIImagePickerController() - pickerController.sourceType = sourceType - pickerController.preferredContentSize = .IPadPopover.preferredContentSize - pickerController.delegate = self - pickerController.allowsEditing = allowsEditing - pickerController.mediaTypes = mediaTypes - pickerController.videoMaximumDuration = userSession.maxVideoLength - pickerController.videoExportPreset = AVURLAsset.defaultVideoQuality - if sourceType == .camera { - let settingsCamera: SettingsCamera? = Settings.shared[.preferredCamera] - pickerController.cameraDevice = settingsCamera == .back ? .rear : .front - } + if sourceType != .camera, + let popoverPresentationController = pickerController.popoverPresentationController { + popoverPresentationController.sourceView = pointToView.superview + popoverPresentationController.sourceRect = pointToView.frame + popoverPresentationController.backgroundColor = .white + popoverPresentationController.permittedArrowDirections = .down + } - if sourceType != .camera, - let popoverPresentationController = pickerController.popoverPresentationController { - popoverPresentationController.sourceView = pointToView.superview - popoverPresentationController.sourceRect = pointToView.frame - popoverPresentationController.backgroundColor = .white - popoverPresentationController.permittedArrowDirections = .down + present(pickerController, animated: true) } - - present(pickerController, animated: true) } if sourceType == .camera { diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePickerDriveConversation.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePickerDriveConversation.swift new file mode 100644 index 00000000000..d2ba1e257e3 --- /dev/null +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController+ImagePickerDriveConversation.swift @@ -0,0 +1,93 @@ +// +// Wire +// Copyright (C) 2026 Wire Swiss GmbH +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see http://www.gnu.org/licenses/. +// + +import PhotosUI +import WireLogging + +// MARK: - PHPicker delegate methods used only for Wire drive conversation (allowing multiple media selection). + +extension ConversationInputBarViewController: PHPickerViewControllerDelegate { + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + + for result in results { + let localIdentifier = result.assetIdentifier + let provider = result.itemProvider + let isImage = provider.canLoadObject(ofClass: UIImage.self) + let isVideo = provider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) + + // filters out already previously selected assets. + if let localIdentifier, attachmentsCarouselViewModel.draftsLocalIdentifiers.contains(localIdentifier) { + continue + } + + if isImage { + processWireDriveImage(provider: provider, localIdentifier: localIdentifier) + } else if isVideo { + processWireDriveVideo(provider: provider, localIdentifier: localIdentifier) + } + } + } + + private func processWireDriveImage(provider: NSItemProvider, localIdentifier: String?) { + provider.loadObject(ofClass: UIImage.self) { image, _ in + guard let image = image as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + + DispatchQueue.main.async { + let checker = PrivacyWarningChecker(conversation: self.conversation) { + self.uploadDraft( + data: data, + type: .jpeg, + localIdentifier: localIdentifier + ) + } + + checker.performAction() + } + } + } + + private func processWireDriveVideo(provider: NSItemProvider, localIdentifier: String?) { + let filename = String.filename(for: userSession.selfUser) + let fileLengthLimit = Int64(userSession.maxUploadFileSize) + + provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in + guard let url, error == nil else { + return WireLogger.wireDrive.error("Could not load video: \(String(describing: error))") + } + + let videoTempURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent(filename) + .appendingPathExtension(url.pathExtension) + + do { + try FileManager.default.removeTmpIfNeededAndCopy(fileURL: url, tmpURL: videoTempURL) + } catch { + return WireLogger.wireDrive.error("Cannot copy video from \(url) to \(videoTempURL): \(error)") + } + + AVURLAsset.convertVideoToUploadFormat(at: videoTempURL, fileLengthLimit: fileLengthLimit) { url, _, error in + guard error == nil, let url else { return } + self.uploadVideoFile(.init(url: url, localIdentifier: localIdentifier)) + } + + } + + } +} diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift index 13fdde85d8c..f9b55dde4d4 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/InputBar/ConversationInputBarViewController/ConversationInputBarViewController.swift @@ -237,9 +237,9 @@ final class ConversationInputBarViewController: UIViewController, let publishDraftsUseCase: WireDrivePublishDraftsUseCaseProtocol let clearPublishedDraftsUseCase: WireDriveClearPublishedDraftsUseCaseProtocol private let observeDraftsUseCase: WireDriveObserveDraftsUseCaseProtocol - private let deleteDraftUseCase: WireDriveDeleteDraftUseCaseProtocol + let deleteDraftUseCase: WireDriveDeleteDraftUseCaseProtocol private let retryUploadDraftUseCase: WireDriveRetryUploadDraftUseCaseProtocol - private let attachmentsCarouselViewModel = AttachmentsCarouselViewModel() + let attachmentsCarouselViewModel = AttachmentsCarouselViewModel() private var inputBarButtons: [IconButton] { var buttonsArray: [IconButton] = [] @@ -1133,7 +1133,22 @@ extension ConversationInputBarViewController: UIGestureRecognizerDelegate { let carouselViewController = UIHostingController( rootView: AttachmentsCarousel( viewModel: attachmentsCarouselViewModel, - onTap: { WireLogger.conversation.debug("Did tap draft attachment: \($0)") }, + onTap: { [weak self] item in + guard let self, let draft = attachmentsCarouselViewModel.draft(for: item) else { return } + + if draft.isImage, let data = draft.data { + let sendableImage = SendableImage( + id: draft.nodeID, + name: nil, + utType: nil, + data: data + ) + + showConfirmationForImage(sendableImage, isFromCamera: false) + } else if draft.isVideo { + showConfirmationForVideo(url: draft.assetURL) + } + }, onRemove: { [deleteDraftUseCase] item in Task.detached { try? await deleteDraftUseCase.invoke(nodeID: item.id) @@ -1249,7 +1264,7 @@ extension ConversationInputBarViewController: UIGestureRecognizerDelegate { ]) } - private func useWireDrive() -> Bool { + func useWireDrive() -> Bool { userSession.isWireDriveEnabled && conversation.isWireDriveEnabled } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/IconButton+Factory.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/IconButton+Factory.swift index 37c1e245070..fd6c0f10579 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/IconButton+Factory.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Helpers/IconButton+Factory.swift @@ -67,6 +67,26 @@ extension IconButton { ) } + static func checkmarkButton() -> IconButton { + + let sendButtonIconColor = SemanticColors.Icon.foregroundDefaultWhite + + return .init( + icon: .checkmark, + accessibilityId: Locators.ActiveConversationPage.sendButton.rawValue, + backgroundColor: [ + UIControl.State.normal.rawValue: UIColor.accent(), + UIControl.State.highlighted.rawValue: UIColor.accentDarken, + UIControl.State.disabled.rawValue: SemanticColors.Button.backgroundSendDisabled + ], + iconColor: [ + UIControl.State.normal.rawValue: sendButtonIconColor, + UIControl.State.highlighted.rawValue: sendButtonIconColor, + UIControl.State.disabled.rawValue: sendButtonIconColor + ] + ) + } + private convenience init( icon: StyleKitIcon, size: StyleKitIcon.Size = .tiny, diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Sketchpad/CanvasViewController.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Sketchpad/CanvasViewController.swift index e7cf8058b4b..e3b2c96a9e7 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Sketchpad/CanvasViewController.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Sketchpad/CanvasViewController.swift @@ -44,10 +44,16 @@ final class CanvasViewController: UIViewController, UINavigationControllerDelega weak var delegate: CanvasViewControllerDelegate? var canvas = Canvas() - private lazy var toolbar: SketchToolbar = .init(buttons: [photoButton, drawButton, emojiButton, sendButton]) + private lazy var toolbar: SketchToolbar = .init(buttons: [ + photoButton, + drawButton, + emojiButton, + isWireDriveEnabled ? checkmarkButton : sendButton + ]) let drawButton = NonLegacyIconButton() let emojiButton = NonLegacyIconButton() let sendButton = IconButton.sendButton() + let checkmarkButton = IconButton.checkmarkButton() let photoButton = NonLegacyIconButton() let separatorLine = UIView() let hintLabel = UILabel() @@ -66,11 +72,13 @@ final class CanvasViewController: UIViewController, UINavigationControllerDelega let colorPickerController = SketchColorPickerController() private let userSession: UserSession + private let isWireDriveEnabled: Bool // MARK: - Init - init(userSession: UserSession) { + init(userSession: UserSession, isWireDriveEnabled: Bool = false) { self.userSession = userSession + self.isWireDriveEnabled = isWireDriveEnabled super.init(nibName: nil, bundle: nil) } @@ -152,11 +160,19 @@ final class CanvasViewController: UIViewController, UINavigationControllerDelega typealias Sketch = L10n.Accessibility.Sketch let hitAreaPadding = CGSize(width: 16, height: 16) - sendButton.addTarget(self, action: #selector(exportImage), for: .touchUpInside) - sendButton.isEnabled = false - sendButton.hitAreaPadding = hitAreaPadding - sendButton.accessibilityLabel = Sketch.SendButton.description - sendButton.accessibilityIdentifier = Locators.ActiveConversationPage.canvasSendButton.rawValue + if isWireDriveEnabled { + checkmarkButton.addTarget(self, action: #selector(exportImage), for: .touchUpInside) + checkmarkButton.isEnabled = false + checkmarkButton.hitAreaPadding = hitAreaPadding + sendButton.accessibilityLabel = Sketch.ConfirmButton.description + sendButton.accessibilityIdentifier = Locators.ActiveConversationPage.canvasConfirmButton.rawValue + } else { + sendButton.addTarget(self, action: #selector(exportImage), for: .touchUpInside) + sendButton.isEnabled = false + sendButton.hitAreaPadding = hitAreaPadding + sendButton.accessibilityLabel = Sketch.SendButton.description + sendButton.accessibilityIdentifier = Locators.ActiveConversationPage.canvasSendButton.rawValue + } drawButton.setIcon(.brush, size: .tiny, for: .normal) drawButton.addTarget(self, action: #selector(toggleDrawTool), for: .touchUpInside) @@ -302,6 +318,7 @@ extension CanvasViewController: CanvasDelegate { func canvasDidChange(_ canvas: Canvas) { sendButton.isEnabled = canvas.hasChanges + checkmarkButton.isEnabled = canvas.hasChanges navigationItem.leftBarButtonItem?.isEnabled = canvas.hasChanges hideHint() }