diff --git a/WireFoundation/Sources/WireFoundation/Utilities/TimeInterval+Units.swift b/WireFoundation/Sources/WireFoundation/Utilities/TimeInterval+Units.swift index 83c62358200..7462a88b466 100644 --- a/WireFoundation/Sources/WireFoundation/Utilities/TimeInterval+Units.swift +++ b/WireFoundation/Sources/WireFoundation/Utilities/TimeInterval+Units.swift @@ -27,6 +27,7 @@ public extension TimeInterval { static let fiveMinutes = 5 * oneMinute static let oneMinute = 60 * oneSecond static let tenSeconds = 10 * oneSecond + static let thirtySeconds = 30 * oneSecond static let oneSecond = TimeInterval(1) /// Number of seconds for a whole year (accounting for leap years) from now. diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index df1ec28b357..9f770a3cfcc 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -202,6 +202,12 @@ public extension WireMessagingFactory { updateTags: WireCellsUpdateTagsUseCase(nodesAPI: nodesAPI), getTagSuggestions: WireCellsGetTagSuggestionsUseCase(nodesAPI: nodesAPI), createFolder: WireCellsCreateFolderUseCase(nodesRepository: nodesAPI), + fetchNodeVersions: WireCellsFetchNodeVersionsUseCase(repository: nodesAPI), + restoreNodeVersion: WireCellsRestoreNodeVersionUseCase( + repository: nodesAPI, + localAssetsRepository: localAssetRepository, + nodeCache: nodeCache + ), getEditingURL: WireCellsGetEditingURLUseCase(editingURLRepository: nodesAPI), getAssetUseCase: WireCellsGetAssetUseCase( localAssetRepository: localAssetRepository, diff --git a/WireMessaging/Sources/WireMessagingData/WireCells/Model/WireCellsNodeVersionsNetworkModel.swift b/WireMessaging/Sources/WireMessagingData/WireCells/Model/WireCellsNodeVersionsNetworkModel.swift new file mode 100644 index 00000000000..4d442530bde --- /dev/null +++ b/WireMessaging/Sources/WireMessagingData/WireCells/Model/WireCellsNodeVersionsNetworkModel.swift @@ -0,0 +1,84 @@ +// +// Wire +// Copyright (C) 2025 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 CellsSDK +import WireMessagingDomain +package import Foundation + +package struct WireCellsNodeVersionsNetworkModel: Equatable, Hashable, Sendable { + package let versions: [Version] + + package struct Version: Equatable, Hashable, Sendable { + package let contentUrl: URL? + package let contentHash: String? + package let description: String? + package let isDraft: Bool + package let eTag: String? + package let isHead: Bool? + package let mTime: UInt64? + package let ownerName: String? + package let ownerUuid: String? + package let size: UInt64? + package let versionId: UUID + package let downloadUrl: URL? + } +} + +extension WireCellsNodeVersionsNetworkModel { + func toDomainModel() -> [WireCellsNodeVersion] { + versions.map { + WireCellsNodeVersion( + id: $0.versionId, + ownerName: $0.ownerName, + modified: $0.mTime.map { Date(timeIntervalSince1970: Double($0)) }, + eTag: $0.eTag, + size: $0.size, + downloadUrl: $0.downloadUrl + ) + } + } +} + +package extension RestVersionCollection { + func toDTO() -> WireCellsNodeVersionsNetworkModel? { + guard let versions else { return nil } + + let dtoVersions = versions.compactMap { value -> WireCellsNodeVersionsNetworkModel.Version? in + guard let id = UUID(uuidString: value.versionId) else { return nil } + + return WireCellsNodeVersionsNetworkModel.Version( + contentUrl: value.preSignedGET?.url.flatMap(URL.init(string:)), + contentHash: value.contentHash, + description: value.description, + isDraft: value.draft ?? false, + eTag: value.eTag, + isHead: value.isHead, + mTime: value.mTime.flatMap(UInt64.init), + ownerName: value.ownerName, + ownerUuid: value.ownerUuid, + size: value.size.flatMap(UInt64.init), + versionId: id, + downloadUrl: value.preSignedGET?.url.flatMap(URL.init(string:)) + ) + } + + return WireCellsNodeVersionsNetworkModel( + versions: dtoVersions + ) + } +} diff --git a/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/NodesAPI.swift b/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/NodesAPI.swift index 1d83e65eecf..1d34aa75e2d 100644 --- a/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/NodesAPI.swift +++ b/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/NodesAPI.swift @@ -102,6 +102,15 @@ package final actor NodesAPI: NodesAPIProtocol, WireCellsNodesRepositoryProtocol try await restAPI.deleteVersion(uuid: nodeID, versionID: versionID) } + package func getVersions(nodeID: UUID) async throws -> [WireCellsNodeVersion] { + let versionsDTO = try await restAPI.getVersions(uuid: nodeID) + return versionsDTO.toDomainModel() + } + + package func restoreVersion(nodeID: UUID, versionID: UUID) async throws { + try await restAPI.restoreVersion(uuid: nodeID, versionID: versionID) + } + package func downloadFile( out: URL, cellPath: String, diff --git a/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/RestAPI.swift b/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/RestAPI.swift index 853f6818046..0cf16feaeda 100644 --- a/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/RestAPI.swift +++ b/WireMessaging/Sources/WireMessagingData/WireCells/NodesAPI/RestAPI.swift @@ -23,6 +23,7 @@ import WireMessagingDomain enum WireCellsNodesAPIError: Error { case failedToDecodeNode + case failedToDecodeNodeVersions case missingData(String) } @@ -195,6 +196,39 @@ final class RestAPI: Sendable { ) } + func getVersions(uuid: UUID) async throws -> WireCellsNodeVersionsNetworkModel { + let query = RestNodeVersionsFilter( + filterBy: .versionsAll, + flags: [.withPreSignedURLs], + limit: nil, + offset: nil, + sortDirDesc: true, + sortField: nil + ) + + let response = try await NodeServiceAPI.nodeVersions( + uuid: uuid.transportString(), + query: query, + apiConfiguration: makeConfiguration() + ) + + guard let dto = response.toDTO() else { + throw WireCellsNodesAPIError.failedToDecodeNodeVersions + } + + return dto + } + + func restoreVersion(uuid: UUID, versionID: UUID) async throws { + let parameters = RestPromoteParameters(publish: false) + _ = try await NodeServiceAPI.promoteVersion( + uuid: uuid.transportString(), + versionId: versionID.transportString(), + parameters: parameters, + apiConfiguration: makeConfiguration() + ) + } + /// Creates a new folder at the specified path. /// /// - Parameters: @@ -387,7 +421,7 @@ private extension WireCellsGetNodesRequest { var lookupRequest: RestLookupRequest { var request = RestLookupRequest( - flags: [.withPreSignedURLs], + flags: [.withPreSignedURLs, .withEditorURLs], limit: "\(limit)", offset: "\(offset)", ) diff --git a/WireMessaging/Sources/WireMessagingData/WireCells/WireCellsLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireCells/WireCellsLocalAssetRepository.swift index 8b1a35a0288..02d2139bf5c 100644 --- a/WireMessaging/Sources/WireMessagingData/WireCells/WireCellsLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireCells/WireCellsLocalAssetRepository.swift @@ -70,9 +70,7 @@ package final class WireCellsLocalAssetRepository: WireCellsLocalAssetRepository /// metadata from the server, updates local metadata if it has changed and deletes any cached file if it's /// `eTag` has changed. @MainActor - package func refreshAssetMetadata( - nodeID: UUID - ) async throws -> (node: WireCellsNode, asset: WireCellsLocalAsset) { + package func refreshAssetMetadata(nodeID: UUID) async throws -> (node: WireCellsNode, asset: WireCellsLocalAsset) { try await _refreshAssetMetadata(nodeID: nodeID) } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodeVersions.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodeVersions.swift new file mode 100644 index 00000000000..81cd8b90948 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodeVersions.swift @@ -0,0 +1,45 @@ +// +// Wire +// Copyright (C) 2025 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/. +// + +public import Foundation + +public struct WireCellsNodeVersion: Equatable, Identifiable, Sendable { + + public init( + id: UUID, + ownerName: String?, + modified: Date?, + eTag: String?, + size: UInt64?, + downloadUrl: URL? + ) { + self.id = id + self.ownerName = ownerName + self.modified = modified + self.eTag = eTag + self.size = size + self.downloadUrl = downloadUrl + } + + public let id: UUID + public let ownerName: String? + public let modified: Date? + public let size: UInt64? + public let eTag: String? + public let downloadUrl: URL? +} diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodesRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodesRepositoryProtocol.swift index 271a17852de..6a3193d200b 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodesRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/Model/WireCellsNodesRepositoryProtocol.swift @@ -76,6 +76,19 @@ package protocol WireCellsNodesRepositoryProtocol: Sendable { /// - Returns: Whether a file already exists at this path and the next available path if any. func preCheck(nodePath: String, findAvailablePath: Bool) async throws -> WireCellsPreCheckResult + /// Retrieves all available versions for a given node. + /// + /// - Parameter nodeID: The unique identifier of the node whose versions should be fetched. + /// - Returns: An array of `WireCellsNodeVersion` objects representing the node’s versions. + func getVersions(nodeID: UUID) async throws -> [WireCellsNodeVersion] + + /// Restores a previous version of a node. + /// + /// - Parameters: + /// - nodeID: The unique identifier of the file node to restore. + /// - versionID: The unique identifier of the version to restore. + func restoreVersion(nodeID: UUID, versionID: UUID) async throws + } package struct WireCellsGetNodesRequest: Equatable, Sendable { diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/NodesAPIProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/NodesAPIProtocol.swift index 68926a18026..c2c2f7d2525 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/NodesAPIProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/NodesAPIProtocol.swift @@ -49,4 +49,6 @@ package protocol NodesAPIProtocol: Sendable { func updateTags(nodeID: UUID, tags: [String]) async throws func getAllTags() async throws -> [String] + + func getVersions(nodeID: UUID) async throws -> [WireCellsNodeVersion] } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/WireCellsFetchNodeVersionsUseCaseProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/WireCellsFetchNodeVersionsUseCaseProtocol.swift new file mode 100644 index 00000000000..72bdb4aa485 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/WireCellsFetchNodeVersionsUseCaseProtocol.swift @@ -0,0 +1,23 @@ +// +// Wire +// Copyright (C) 2025 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/. +// + +public import Foundation + +public protocol WireCellsFetchNodeVersionsUseCaseProtocol: Sendable { + func invoke(nodeID: UUID) async throws -> [WireCellsNodeVersion] +} diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/WireCellsRestoreNodeVersionUseCaseProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/WireCellsRestoreNodeVersionUseCaseProtocol.swift new file mode 100644 index 00000000000..dd4dd8ab8b5 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/Protocols/WireCellsRestoreNodeVersionUseCaseProtocol.swift @@ -0,0 +1,23 @@ +// +// Wire +// Copyright (C) 2025 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/. +// + +public import Foundation + +public protocol WireCellsRestoreNodeVersionUseCaseProtocol: Sendable { + func invoke(nodeID: UUID, versionID: UUID) async throws +} diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/UseCases/WireCellsFetchNodeVersionsUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/UseCases/WireCellsFetchNodeVersionsUseCase.swift new file mode 100644 index 00000000000..258027d575d --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/UseCases/WireCellsFetchNodeVersionsUseCase.swift @@ -0,0 +1,45 @@ +// +// Wire +// Copyright (C) 2025 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/. +// + +package import Foundation +import WireLogging + +package struct WireCellsFetchNodeVersionsUseCase: WireCellsFetchNodeVersionsUseCaseProtocol { + + enum Failure: Error { + case unableToRetrieveNodeVersions + } + + private let repository: any WireCellsNodesRepositoryProtocol + + package init( + repository: any WireCellsNodesRepositoryProtocol + ) { + self.repository = repository + } + + package func invoke(nodeID: UUID) async throws -> [WireCellsNodeVersion] { + do { + return try await repository.getVersions(nodeID: nodeID) + } catch { + WireLogger.wireCells.error("Unable to retrieve node versions: \(error)") + throw Failure.unableToRetrieveNodeVersions + } + } + +} diff --git a/WireMessaging/Sources/WireMessagingDomain/WireCells/UseCases/WireCellsRestoreNodeVersionUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireCells/UseCases/WireCellsRestoreNodeVersionUseCase.swift new file mode 100644 index 00000000000..504e0f19dd3 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireCells/UseCases/WireCellsRestoreNodeVersionUseCase.swift @@ -0,0 +1,54 @@ +// +// Wire +// Copyright (C) 2025 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/. +// + +package import Foundation +import WireLogging + +package struct WireCellsRestoreNodeVersionUseCase: WireCellsRestoreNodeVersionUseCaseProtocol { + + enum Failure: Error { + case unableToRestoreNodeVersion + } + + private let repository: any WireCellsNodesRepositoryProtocol + private let localAssetsRepository: any WireCellsLocalAssetRepositoryProtocol + private let nodeCache: any WireCellsNodeCacheProtocol + + package init( + repository: any WireCellsNodesRepositoryProtocol, + localAssetsRepository: any WireCellsLocalAssetRepositoryProtocol, + nodeCache: any WireCellsNodeCacheProtocol + ) { + self.repository = repository + self.localAssetsRepository = localAssetsRepository + self.nodeCache = nodeCache + } + + package func invoke(nodeID: UUID, versionID: UUID) async throws { + do { + try await repository.restoreVersion(nodeID: nodeID, versionID: versionID) + let (node, _) = try await localAssetsRepository.refreshAssetMetadata( + nodeID: nodeID + ) + await nodeCache.setItem(.init(node: node), for: nodeID) + } catch { + throw Failure.unableToRestoreNodeVersion + } + } + +} diff --git a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings index 221f0be9aa4..c020cbe4b9b 100644 --- a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings +++ b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings @@ -121,6 +121,10 @@ "conversation.wireCells.files.item.menu.rename" = "Rename"; "conversation.wireCells.files.item.menu.moveToFolder" = "Move to folder"; "conversation.wireCells.files.item.menu.addOrRemoveTags" = "Add or remove tags"; +"conversation.wireCells.files.item.menu.delete" = "Delete Permanently"; +"conversation.wireCells.files.item.menu.versionHistory" = "Version history"; +"conversation.wireCells.files.item.deleteConfirmation.title" = "This will permanently delete the file %@ for all participants. Everyone will lose access and there is no way to recover the file."; +"conversation.wireCells.files.item.deleteConfirmation.deletePermanently" = "Delete Permanently"; "conversation.wireCells.files.item.menu.delete" = "Delete"; "conversation.wireCells.files.item.deleteFileConfirmation.title" = "The file \"%@\" will move to this conversation's recycle bin."; "conversation.wireCells.files.item.deleteFolderConfirmation.title" = "The folder \"%@\" will move to this conversation's recycle bin."; @@ -192,3 +196,16 @@ "conversation.message.attachment.previewNotAvailable" = "Unable to display preview"; "conversation.message.attachment.unableToDownload" = "Unable to download file"; "conversation.message.attachment.loadingContent" = "Loading content"; + +// MARK: - Files versioning +"conversation.wireCells.filesVersioning.navigationTitle" = "Version history"; +"conversation.wireCells.filesVersioning.currentFile" = "Current"; +"conversation.wireCells.filesVersioning.restoreAlertTitle" = "Restore version"; +"conversation.wireCells.filesVersioning.restoreAlertMessage" = "This copies the restored version and sets it as the current one. All previous versions remain available."; +"conversation.wireCells.filesVersioning.restoreAlertAction" = "Restore"; +"conversation.wireCells.filesVersioning.restoreFailureAlertTitle" = "Restoring version failed"; +"conversation.wireCells.filesVersioning.restoreFailureAlertMessage" = "Something went wrong while restoring a version. Please retry."; +"conversation.wireCells.filesVersioning.downloadVersion" = "Download version"; +"conversation.wireCells.filesVersioning.retry" = "Retry"; +"conversation.wireCells.filesVersioning.restoringVersion" = "Restoring version..."; +"conversation.wireCells.filesVersioning.downloadingVersion" = "Downloading file version"; diff --git a/WireMessaging/Sources/WireMessagingUI/Utilities/AlertModel.swift b/WireMessaging/Sources/WireMessagingUI/Utilities/AlertModel.swift index 03a08143a10..bf78160a209 100644 --- a/WireMessaging/Sources/WireMessagingUI/Utilities/AlertModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/Utilities/AlertModel.swift @@ -17,6 +17,7 @@ // import Foundation +import SwiftUI /// Identifies an alert and provides it's title and message. /// @@ -27,6 +28,23 @@ struct AlertModel: Hashable, Identifiable, Sendable { let title: String let message: String + let actionsButtons: [ActionButton] + + struct ActionButton: Hashable, Identifiable, Sendable { + var id: Self { self } + + let title: String + let role: ButtonRole? + let handler: @Sendable () async -> Void + + func hash(into hasher: inout Hasher) { + hasher.combine(title) + } + + static func == (lhs: ActionButton, rhs: ActionButton) -> Bool { + lhs.title == rhs.title + } + } } @@ -38,12 +56,18 @@ extension AlertModel { static let noInternet = AlertModel( title: Error.NoInternet.title, - message: Error.NoInternet.message + message: Error.NoInternet.message, + actionsButtons: [ + ActionButton(title: L10n.Localizable.General.confirm, role: .none, handler: {}) + ] ) static let unknownError = AlertModel( title: Error.Unknown.title, - message: Error.Unknown.message + message: Error.Unknown.message, + actionsButtons: [ + ActionButton(title: L10n.Localizable.General.confirm, role: .none, handler: {}) + ] ) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersionItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersionItemView.swift new file mode 100644 index 00000000000..8b6bc857f4b --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersionItemView.swift @@ -0,0 +1,101 @@ +// +// Wire +// Copyright (C) 2025 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 SwiftUI +import WireDesign +import WireFoundation +import WireMessagingDomain +import WireReusableUIComponents + +private typealias Strings = L10n.Localizable.Conversation.WireCells +private typealias Accessibility = L10n.Accessibility.Conversation.WireCells + +struct FileVersionItemView: View { + @StateObject private var viewModel: FileVersionItemViewModel + @Environment(\.wireAccentColor) private var wireAccentColor + @State private var showRestoreVersionAlert = false + + init( + viewModel: @autoclosure @escaping () -> FileVersionItemViewModel, + ) { + self._viewModel = StateObject(wrappedValue: viewModel()) + } + + var body: some View { + VStack(spacing: 0) { + + HStack { + Image(systemName: "arrow.trianglehead.counterclockwise") + + VStack(alignment: .leading) { + Text(viewModel.item.title) + .font(for: .body1) + .foregroundStyle(ColorTheme.Backgrounds.onSurface.color) + + Text(viewModel.item.subtitle) + .font(for: .h4) + .fontWeight(.regular) + .foregroundStyle(ColorTheme.Base.secondaryText.color) + + }.padding(.leading, 12) + + Spacer() + + Menu { + restoreButton + } label: { + Image(systemName: "ellipsis") + .foregroundStyle(ColorTheme.Base.secondaryText.color) + } + } + } + .alert( + Strings.FilesVersioning.restoreAlertTitle, + isPresented: $showRestoreVersionAlert + ) { + Button(Strings.FilesVersioning.restoreAlertAction, role: .cancel) { + Task { await viewModel.restore() } + } + Button(L10n.Localizable.General.cancel) {} + } message: { + Text(Strings.FilesVersioning.restoreAlertMessage) + } + .contentShape(Rectangle()) + } + + private var restoreButton: some View { + Button( + action: { + showRestoreVersionAlert = true + }, label: { + HStack { + Text(Strings.FilesVersioning.restoreAlertTitle) + + Image(systemName: "arrow.uturn.left") + .foregroundStyle(SemanticColors.Icon.foregroundDefaultBlack.color) + } + + } + ) + } + +} + +#Preview { + FileVersioningView(viewModel: .preview()) +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersionItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersionItemViewModel.swift new file mode 100644 index 00000000000..aaf91feee63 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersionItemViewModel.swift @@ -0,0 +1,50 @@ +// +// Wire +// Copyright (C) 2025 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 Combine +import Foundation +import WireFoundation +import WireMessagingDomain + +@MainActor +final class FileVersionItemViewModel: ObservableObject { + + private let nodeID: UUID + private let versionID: UUID + private let onRestore: (FileVersionItem) async -> Void + + let item: FileVersionItem + let accentColor: WireAccentColor + + init( + nodeID: UUID, + item: FileVersionItem, + accentColor: WireAccentColor, + onRestore: @escaping (FileVersionItem) async -> Void + ) { + self.nodeID = nodeID + self.versionID = item.id + self.item = item + self.accentColor = accentColor + self.onRestore = onRestore + } + + func restore() async { + await onRestore(item) + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersioningView.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersioningView.swift new file mode 100644 index 00000000000..851d17ac6c5 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersioningView.swift @@ -0,0 +1,179 @@ +// +// Wire +// Copyright (C) 2025 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 SwiftUI +import WireDesign +import WireFoundation +import WireLocators +import WireMessagingDomain +import WireReusableUIComponents + +private typealias Strings = L10n.Localizable.Conversation.WireCells +private typealias Accessibility = L10n.Accessibility.Conversation.WireCells + +struct FileVersioningView: View, Identifiable { + @StateObject package var viewModel: FileVersioningViewModel + @Environment(\.dismiss) private var dismiss + + let id = UUID() + + init(viewModel: @autoclosure @escaping () -> FileVersioningViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel()) + } + + var body: some View { + NavigationStack { + ZStack { + ColorTheme.Backgrounds.background.color + .ignoresSafeArea(.all) + + Group { + switch viewModel.state { + case .loading: + ProgressView() + .progressViewStyle(.circular) + case .received: + Form { sections } + .scrollContentBackground(.hidden) + .background(ColorTheme.Backgrounds.background.color) + case .restoringVersion: + VStack { + RestoreVersionProgressView() + .padding(.bottom, 25) + + Text(Strings.FilesVersioning.restoringVersion) + .foregroundStyle(ColorTheme.Backgrounds.onBackground.color) + } + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .toolbarBackground(ColorTheme.Backgrounds.background.color, for: .navigationBar) + .quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation + .refreshable { await viewModel.fetch() } + .alert( + item: $viewModel.alert, + title: { Text($0.title) }, + message: { Text($0.message) }, + actions: { + ForEach($0.actionsButtons, id: \.id) { action in + Button(action.title, role: action.role, action: { Task { await action.handler() } }) + } + } + ) + }.task { viewModel.startPolling() } + } + +} + +private struct RestoreVersionProgressView: View { + @State private var rotate = false + + let dotCount = 20 + let dotSize: CGFloat = 3 + let radius: CGFloat = 20 + let animationDuration: Double = 2.0 + + var body: some View { + ZStack { + ForEach(0 ..< dotCount, id: \.self) { index in + Circle() + .frame(width: dotSize, height: dotSize) + .foregroundColor(ColorTheme.Base.secondaryText.color) + .offset(y: -radius) + .rotationEffect(.degrees(Double(index) / Double(dotCount) * 360)) + } + } + .rotationEffect(.degrees(rotate ? 360 : 0)) + .animation(.linear(duration: animationDuration).repeatForever(autoreverses: false), value: rotate) + .onAppear { + rotate = true + } + } +} + +// MARK: - Toolbar + +private extension FileVersioningView { + + @ToolbarContentBuilder var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .principal) { + VStack { + Text(Strings.FilesVersioning.navigationTitle) + .foregroundStyle(ColorTheme.Backgrounds.onSurface.color) + .font(.headline) + Text(viewModel.name) + .font(.subheadline) + .foregroundStyle(ColorTheme.Base.secondaryText.color) + } + } + + ToolbarItem(placement: .topBarTrailing) { closeButton } + } + + var closeButton: some View { + Button( + action: { dismiss() }, + label: { + Image(.close) + .foregroundStyle(SemanticColors.Icon.foregroundDefaultBlack.color) + .frame(width: 44, height: 44, alignment: .trailing) + } + ) + .accessibilityLabel(Accessibility.Files.close) + .accessibilityIdentifier(Locators.FileVersioningPage.closeButton.rawValue) + } +} + +// MARK: - Sections & Rows + +extension FileVersioningView { + + var sections: some View { + ForEach( + Array(viewModel.state.versions.enumerated()), + id: \.element, + content: section + ) + } + + func section(index: Int, version: FileVersioningViewModel.VersionModel) -> some View { + Section(version.header) { + ForEach(Array(version.items.enumerated()), id: \.element) { itemIndex, _ in + itemRow( + sectionIndex: index, + itemIndex: itemIndex + ) + } + } + } + + func itemRow(sectionIndex: Int, itemIndex: Int) -> some View { + FileVersionItemView( + viewModel: viewModel.itemViewModel( + sectionIndex: sectionIndex, + itemIndex: itemIndex + ) + ) + } +} + +#Preview { + FileVersioningView(viewModel: .preview()) +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersioningViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersioningViewModel.swift new file mode 100644 index 00000000000..8f5ef38ca74 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FileVersioning/FileVersioningViewModel.swift @@ -0,0 +1,277 @@ +// +// Wire +// Copyright (C) 2025 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 Combine +import Foundation +import SwiftUI +import WireFoundation +import WireMessagingDomain + +private typealias Strings = L10n.Localizable.Conversation.WireCells + +struct FileVersionItem: Identifiable, Hashable { + let id: UUID + let title: String + let subtitle: String +} + +/// View model for the `FileVersioningView`. +@MainActor +final class FileVersioningViewModel: ObservableObject { + + struct VersionModel: Identifiable, Hashable { + var id: Self { self } + let header: String + var items: [FileVersionItem] + } + + let name: String + + private let nodeID: UUID + private let fetchNodeVersionsUseCase: any WireCellsFetchNodeVersionsUseCaseProtocol + private let restoreNodeVersionUseCase: any WireCellsRestoreNodeVersionUseCaseProtocol + private let accentColorProvider: () -> WireAccentColor + private var subscriptions = Set() + + var accentColor: WireAccentColor { + accentColorProvider() + } + + enum State { + case loading + case received([VersionModel]) + case restoringVersion + + var versions: [VersionModel] { + switch self { + case let .received(sections): + sections + default: + [] + } + } + } + + @Published var viewingURL: URL? + @Published var alert: AlertModel? + @Published var state: State + + typealias DateFormattingContext = ( + locale: Locale, + calendar: Calendar, + timeZone: TimeZone + ) + + let context: DateFormattingContext + + init( + nodeID: UUID, + name: String, + context: DateFormattingContext = ( + Locale.autoupdatingCurrent, + Calendar.autoupdatingCurrent, + TimeZone.autoupdatingCurrent + ), + fetchNodeVersionsUseCase: any WireCellsFetchNodeVersionsUseCaseProtocol, + restoreNodeVersionUseCase: any WireCellsRestoreNodeVersionUseCaseProtocol, + accentColorProvider: @escaping () -> WireAccentColor + ) { + self.nodeID = nodeID + self.name = name + self.context = context + self.fetchNodeVersionsUseCase = fetchNodeVersionsUseCase + self.restoreNodeVersionUseCase = restoreNodeVersionUseCase + self.accentColorProvider = accentColorProvider + self.state = .loading + } + + func startPolling() { + Timer.publish(every: .thirtySeconds, on: .main, in: .common) + .autoconnect() + .handleEvents(receiveSubscription: { [weak self] _ in + Task { await self?.fetch() } + }) + .sink { [weak self] _ in + Task { await self?.fetch() } + }.store(in: &subscriptions) + } + + func itemViewModel(sectionIndex: Int, itemIndex: Int) -> FileVersionItemViewModel { + FileVersionItemViewModel( + nodeID: nodeID, + item: state.versions[sectionIndex].items[itemIndex], + accentColor: accentColor, + onRestore: { [weak self] item in + Task { await self?.restore(item: item) } + } + ) + } + + func fetch() async { + do { + let response = try await fetchNodeVersionsUseCase.invoke( + nodeID: nodeID + ) + + state = .received(makeVersionModels(from: response)) + + } catch { + state = .received([]) + alert = .unknownError + } + } + + // MARK: - Private + + private func restore(item: FileVersionItem) async { + state = .restoringVersion + + try? await Task.sleep(for: .seconds(2)) + + do { + try await restoreNodeVersionUseCase.invoke( + nodeID: nodeID, + versionID: item.id + ) + + await fetch() + + } catch { + alert = AlertModel( + title: Strings.FilesVersioning.restoreFailureAlertTitle, + message: Strings.FilesVersioning.restoreFailureAlertMessage, + actionsButtons: [ + .init(title: Strings.FilesVersioning.retry, role: .cancel) { [weak self] in + await self?.restore(item: item) + }, + .init(title: L10n.Localizable.General.cancel, role: .none) { [weak self] in await self?.fetch() } + ] + ) + } + } + + private func makeVersionModels(from versions: [WireCellsNodeVersion]) -> [VersionModel] { + let groupedVersions = groupedVersionsByDay(versions) + .sorted(by: { $0.key > $1.key }) + + var sections = groupedVersions.compactMap { date, versionsForDate in + let items = versionsForDate + .sorted(by: sortVersionsByTime) + .map(makeVersionItem) + + return VersionModel( + header: formattedHeaderDate(date), + items: items + ) + + } + + // first item from the first section is the most recent file + guard var firstSection = sections.first, + var firstItem = firstSection.items.first else { return sections } + + firstItem = .init( + id: firstItem.id, + title: firstItem.title + " " + Strings.FilesVersioning.currentFile, + subtitle: firstItem.subtitle + ) + + firstSection.items[0] = firstItem + sections[0] = firstSection + + return sections + } + + // MARK: - Grouping & Sorting + + private func groupedVersionsByDay( + _ versions: [WireCellsNodeVersion] + ) -> [Date: [WireCellsNodeVersion]] { + Dictionary(grouping: versions) { version in + let date = version.modified ?? Date.distantPast + let calendar = context.calendar + return calendar.startOfDay(for: date) + } + } + + private func sortVersionsByTime( + _ arg1: WireCellsNodeVersion, + _ arg2: WireCellsNodeVersion + ) -> Bool { + let calendar = context.calendar + let date1 = arg1.modified ?? Date.distantPast + let components1 = calendar.dateComponents([.hour, .minute, .second], from: date1) + let compared1 = calendar.date(from: components1) + + let date2 = arg2.modified ?? Date.distantPast + let components2 = calendar.dateComponents([.hour, .minute, .second], from: date2) + let compared2 = calendar.date(from: components2) + + guard let compared1, let compared2 else { return false } + + return compared1 > compared2 + } + + private func makeVersionItem(version: WireCellsNodeVersion) -> FileVersionItem { + let title = version.modified.map(formattedItemDate) ?? "" + let subtitle = [version.ownerName, version.size.map(formattedFileSize)] + .compactMap(\.self) + .joined(separator: " · ") + + return FileVersionItem( + id: version.id, + title: title, + subtitle: subtitle, + ) + } + + // MARK: - Formatters + + private func formattedHeaderDate(_ date: Date) -> String { + let style = Date.FormatStyle( + locale: context.locale, + calendar: context.calendar, + timeZone: context.timeZone + ) + .weekday(.wide) + .month(.abbreviated) + .day() + .year() + + return date.formatted(style) + } + + private func formattedItemDate(_ date: Date) -> String { + let style = Date.FormatStyle( + locale: context.locale, + calendar: context.calendar, + timeZone: context.timeZone + ) + .hour(.defaultDigits(amPM: .abbreviated)) + .minute() + + return date.formatted(style) + } + + func formattedFileSize(size: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(size)) + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesItemViewModel.swift index f4c289fa1ef..9e0dc932604 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesItemViewModel.swift @@ -41,6 +41,7 @@ final class FilesItemViewModel: ObservableObject { case rename case editTags case moveToFolder + case onVersionHistory case edit } @@ -204,6 +205,10 @@ final class FilesItemViewModel: ObservableObject { } } + func showVersionHistory() async { + await onItemAction(.onVersionHistory, item) + } + func confirmDelete(permanently: Bool) async { if permanently { await onItemAction(.deletePermanently, item) diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesPreviewHelpers.swift index 7925b430894..85a17174481 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesPreviewHelpers.swift @@ -66,6 +66,14 @@ extension FilesViewModel { createFolder: WireCellsCreateFolderUseCase( nodesRepository: previewNodesRepository() ), + fetchNodeVersions: WireCellsFetchNodeVersionsUseCase( + repository: previewNodesRepository() + ), + restoreNodeVersion: WireCellsRestoreNodeVersionUseCase( + repository: previewNodesRepository(), + localAssetsRepository: localAssetRepository, + nodeCache: MockWireCellsNodeCacheProtocol() + ), getEditingURL: WireCellsGetEditingURLUseCase( editingURLRepository: previewEditingURLRepository() ), @@ -75,7 +83,7 @@ extension FilesViewModel { ), setNavigation: { _ in }, isCellsStatePending: false, - localAssetRepository: PreviewLocalAssetRepository(), + localAssetRepository: localAssetRepository, nodesRepository: previewNodesRepository(), fileCache: cache, cellName: "2b7d1f2c-74bf-4256-a746-8112e006dcd6", @@ -137,6 +145,24 @@ extension FilesItemViewModel { } +extension FileVersionItemViewModel { + /// A stubbed instance of `FileVersionItemViewModel` for SwiftUI previews. + static func preview() -> FileVersionItemViewModel { + let localAssetsRepository = PreviewLocalAssetRepository() + + return FileVersionItemViewModel( + nodeID: UUID(), + item: .init( + id: UUID(), + title: "5:46AM", + subtitle: "Deniz Agha · 13MB" + ), + accentColor: .default, + onRestore: { _ in } + ) + } +} + extension FilesFiltersViewModel { /// A stubbed instance of `FilesFiltersViewModel` for SwiftUI previews. @@ -156,6 +182,31 @@ extension FilesFiltersViewModel { } +extension FileVersioningViewModel { + + /// A stubbed instance of `FileVersioningViewModel` for SwiftUI previews. + static func preview() -> FileVersioningViewModel { + let repository = MockWireCellsNodesRepositoryProtocol() + repository.getVersionsNodeID_MockValue = WireCellsNodeVersion.mock + + let useCase = WireCellsFetchNodeVersionsUseCase(repository: repository) + let localAssetsRepository = PreviewLocalAssetRepository() + repository.restoreVersionNodeIDVersionID_MockMethod = { _, _ in } + + return FileVersioningViewModel( + nodeID: UUID(), + name: "foo.jpg", + fetchNodeVersionsUseCase: useCase, + restoreNodeVersionUseCase: WireCellsRestoreNodeVersionUseCase( + repository: repository, + localAssetsRepository: localAssetsRepository, + nodeCache: MockWireCellsNodeCacheProtocol() + ), + accentColorProvider: { .default } + ) + } +} + // MARK: - Dependencies private func previewNodesRepository() -> any WireCellsNodesRepositoryProtocol { @@ -169,6 +220,7 @@ private func previewNodesRepository() -> any WireCellsNodesRepositoryProtocol { ownerUserName: "Person \(index)", ) } + repository.getVersionsNodeID_MockValue = WireCellsNodeVersion.mock repository.getNodes_MockMethod = { request in try await Task.sleep(nanoseconds: 1_000_000_000) // Simulate network delay @@ -286,6 +338,83 @@ extension CreateFolderViewModel { } } +extension WireCellsNodeVersion { + static let mock: [WireCellsNodeVersion] = [ + .init( + id: UUID(), + ownerName: "foo1", + modified: .init(timeIntervalSince1970: 1_759_311_973), + eTag: "something", + size: 2_158_877, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo2", + modified: .init(timeIntervalSince1970: 1_759_311_973), + eTag: "something", + size: 172_493, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo3", + modified: .init(timeIntervalSince1970: 1_761_663_940), + eTag: "something", + size: 2_216_387, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo4", + modified: .init(timeIntervalSince1970: 1_761_663_393), + eTag: "something", + size: 2_216_387, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo5", + modified: .init(timeIntervalSince1970: 1_759_241_119), + eTag: "something", + size: 27_808, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo6", + modified: .init(timeIntervalSince1970: 1_759_369_815), + eTag: "something", + size: 27_808, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo7", + modified: .init(timeIntervalSince1970: 1_759_401_599), + eTag: "something", + size: 27_808, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo8", + modified: .init(timeIntervalSince1970: 1_761_681_900), + eTag: "something", + size: 27_808, + downloadUrl: URL(string: "https://wire.com") + ), + .init( + id: UUID(), + ownerName: "foo9", + modified: .init(timeIntervalSince1970: 1_761_628_800), + eTag: "something", + size: 27_808, + downloadUrl: URL(string: "https://wire.com") + ) + ] +} + let mockTags = [ "Urgent", "Marketing", diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesView.swift index df7bc5538df..00cd0d7a492 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesView.swift @@ -103,6 +103,8 @@ package struct FilesView: FilesViewProtocol { fileRenameView case let .createFolder(folderView): folderView + case let .versionHistory(versionHistoryView): + versionHistoryView case let .moveToFolder(fileItem): viewModel.moveToFolderView(item: fileItem) default: diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewContainer.swift index 87644096f6a..84fafb34d3d 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewContainer.swift @@ -146,6 +146,12 @@ package struct FilesViewContainer: View { updateTags: WireCellsUpdateTagsUseCase(nodesAPI: nodesAPI), getTagSuggestions: WireCellsGetTagSuggestionsUseCase(nodesAPI: nodesAPI), createFolder: WireCellsCreateFolderUseCase(nodesRepository: nodesAPI), + fetchNodeVersions: WireCellsFetchNodeVersionsUseCase(repository: nodesAPI), + restoreNodeVersion: WireCellsRestoreNodeVersionUseCase( + repository: nodesAPI, + localAssetsRepository: localAssetRepository, + nodeCache: nodeCache + ), getEditingURL: WireCellsGetEditingURLUseCase(editingURLRepository: nodesAPI), getAssetUseCase: WireCellsGetAssetUseCase( localAssetRepository: localAssetRepository, diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewItemView.swift index 3913931b1ac..13dc208028c 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewItemView.swift @@ -37,12 +37,14 @@ struct FilesViewItemView: View { private let canMoveToFolder: Bool private let canEditFile: Bool private var canDeleteFiles: Bool + private let canOpenVersionHistory: Bool init( viewModel: @autoclosure @escaping () -> FilesItemViewModel, canRenameFile: Bool = false, canEditTags: Bool = false, canMoveToFolder: Bool = false, + canOpenVersionHistory: Bool = false, canEditFile: Bool = false, canDeleteFiles: Bool = false ) { @@ -52,6 +54,7 @@ struct FilesViewItemView: View { self.canMoveToFolder = canMoveToFolder self.canEditFile = canEditFile self.canDeleteFiles = canDeleteFiles + self.canOpenVersionHistory = canOpenVersionHistory } var body: some View { @@ -112,7 +115,16 @@ struct FilesViewItemView: View { if viewModel.isDownloadOptionAvailable { Button(action: download) { Label(Strings.Files.Item.Menu.download, systemImage: "square.and.arrow.down") - }.disabled(viewModel.isDownloading) + } + } + + if canOpenVersionHistory, !viewModel.isInRecycleBin { + Button(action: showVersionHistory) { + Label( + Strings.Files.Item.Menu.versionHistory, + systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90" + ) + } } if canEditFile, viewModel.isEditable { @@ -238,6 +250,10 @@ struct FilesViewItemView: View { Task { await viewModel.download() } } + private func showVersionHistory() { + Task { await viewModel.showVersionHistory() } + } + private func rename() { Task { await viewModel.rename() } } @@ -318,7 +334,13 @@ private extension View { #Preview { VStack(spacing: 0) { FilesViewItemView(viewModel: .preview()) - FilesViewItemView(viewModel: .preview(), canRenameFile: true, canEditTags: true, canDeleteFiles: true) + FilesViewItemView( + viewModel: .preview(), + canRenameFile: true, + canEditTags: true, + canOpenVersionHistory: true, + canDeleteFiles: true + ) FilesViewItemView(viewModel: .preview(tags: ["urgent"]), canRenameFile: true, canEditTags: true) FilesViewItemView(viewModel: .preview(tags: ["urgent", "funny", "important"]), canDeleteFiles: true) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewModel.swift index 808b7a17834..1706fb14afd 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewModel.swift @@ -94,6 +94,7 @@ package final class FilesViewModel: ObservableObject { case renameFile(view: FileRenameView) case createFolder(view: CreateFolderView) case filters(view: FilesFiltersView) + case versionHistory(view: FileVersioningView) var id: String { switch self { @@ -107,6 +108,8 @@ package final class FilesViewModel: ObservableObject { "renameFile(\(view.id))" case let .filters(view): "filters(\(view.id))" + case let .versionHistory(view): + "versionHistory(\(view.id))" } } } @@ -152,6 +155,8 @@ package final class FilesViewModel: ObservableObject { updateTags: any WireCellsUpdateTagsUseCaseProtocol, getTagSuggestions: any WireCellsGetTagSuggestionsUseCaseProtocol, createFolder: any WireCellsCreateFolderUseCaseProtocol, + fetchNodeVersions: any WireCellsFetchNodeVersionsUseCaseProtocol, + restoreNodeVersion: any WireCellsRestoreNodeVersionUseCaseProtocol, getEditingURL: WireCellsGetEditingURLUseCase, getAssetUseCase: WireCellsGetAssetUseCase ) { @@ -163,6 +168,8 @@ package final class FilesViewModel: ObservableObject { self.updateTags = updateTags self.getTagSuggestions = getTagSuggestions self.createFolder = createFolder + self.fetchNodeVersions = fetchNodeVersions + self.restoreNodeVersion = restoreNodeVersion self.getEditingURL = getEditingURL self.getAssetUseCase = getAssetUseCase } @@ -174,6 +181,8 @@ package final class FilesViewModel: ObservableObject { let updateTags: any WireCellsUpdateTagsUseCaseProtocol let getTagSuggestions: any WireCellsGetTagSuggestionsUseCaseProtocol let createFolder: any WireCellsCreateFolderUseCaseProtocol + let fetchNodeVersions: any WireCellsFetchNodeVersionsUseCaseProtocol + let restoreNodeVersion: any WireCellsRestoreNodeVersionUseCaseProtocol let getEditingURL: WireCellsGetEditingURLUseCase let getAssetUseCase: WireCellsGetAssetUseCase } @@ -325,6 +334,8 @@ package final class FilesViewModel: ObservableObject { sheetNavigation = .editTags(fileItem: item) case .moveToFolder: sheetNavigation = .moveToFolder(fileItem: item) + case .onVersionHistory: + sheetNavigation = .versionHistory(view: makeFileVersioningView(item: item)) case .edit: isEditing = item } @@ -356,6 +367,7 @@ package final class FilesViewModel: ObservableObject { func moveToFolderView(item: FilesViewItem) -> some View { let containerPath = item.filePath.components(separatedBy: "/").dropLast().joined(separator: "/") let nodesRepository = nodesRepository + let assetRepository = localAssetRepository let useCases = useCases return MoveToFolderView( viewModel: MoveToFolderViewModel( @@ -367,6 +379,7 @@ package final class FilesViewModel: ObservableObject { Task { await self?.reload(refreshing: true) } }, nodesRepository: nodesRepository, + localAssetRepository: assetRepository, moveNodeUseCase: WireCellsMoveNodeUseCase(nodesRepository: nodesRepository), createFolderUseCase: useCases.createFolder ) @@ -654,4 +667,21 @@ package final class FilesViewModel: ObservableObject { return FileRenameView(viewModel: viewModel) } + private func makeFileVersioningView( + item: FilesViewItem + ) -> FileVersioningView { + // always reload this view when file versioning is dismissed + shouldReload = true + + let viewModel = FileVersioningViewModel( + nodeID: item.id, + name: item.name, + fetchNodeVersionsUseCase: useCases.fetchNodeVersions, + restoreNodeVersionUseCase: useCases.restoreNodeVersion, + accentColorProvider: accentColorProvider + ) + + return FileVersioningView(viewModel: viewModel) + } + } diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewProtocol.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewProtocol.swift index cbe4b7974ef..3a7232ab4c3 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewProtocol.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/FilesViewProtocol.swift @@ -82,11 +82,13 @@ extension FilesViewProtocol { func itemRow(index: Int) -> some View { FilesViewItemView( viewModel: viewModel.itemViewModel(index: index), - canRenameFile: !isBrowsing, // action not allowed when browsing files - canEditTags: !isBrowsing, // action not allowed when browsing files - canMoveToFolder: !isBrowsing && viewModel.isFoldersEnabled, // action not allowed when browsing files - canEditFile: !isBrowsing, // action not allowed when browsing files - canDeleteFiles: !isBrowsing, // action not allowed when browsing files + // some actions are not allowed when browsing files + canRenameFile: !isBrowsing, + canEditTags: !isBrowsing, + canMoveToFolder: !isBrowsing && viewModel.isFoldersEnabled, + canOpenVersionHistory: !isBrowsing && viewModel.isCollaboraEnabled, + canEditFile: !isBrowsing, + canDeleteFiles: !isBrowsing ) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/MoveToFolderViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/MoveToFolderViewModel.swift index 755c836292f..8f99aefe87f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/MoveToFolderViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/MoveToFolderViewModel.swift @@ -80,6 +80,7 @@ final class MoveToFolderViewModel: MoveToFolderViewModelProtocol { private let nodeName: String private let onFinish: () -> Void private let nodesRepository: any WireCellsNodesRepositoryProtocol + private let localAssetRepository: any WireCellsLocalAssetRepositoryProtocol private let moveNodeUseCase: WireCellsMoveNodeUseCase private let createFolderUseCase: any WireCellsCreateFolderUseCaseProtocol @@ -91,6 +92,7 @@ final class MoveToFolderViewModel: MoveToFolderViewModelProtocol { nodeName: String, onFinish: @escaping () -> Void, nodesRepository: any WireCellsNodesRepositoryProtocol, + localAssetRepository: any WireCellsLocalAssetRepositoryProtocol, moveNodeUseCase: WireCellsMoveNodeUseCase, createFolderUseCase: any WireCellsCreateFolderUseCaseProtocol ) { @@ -103,6 +105,7 @@ final class MoveToFolderViewModel: MoveToFolderViewModelProtocol { self.nodesRepository = nodesRepository self.moveNodeUseCase = moveNodeUseCase self.createFolderUseCase = createFolderUseCase + self.localAssetRepository = localAssetRepository } func makeView(path: String) -> some View { diff --git a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/RecycleBinContainer.swift index 25c44d3a8b5..98bdc347731 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireCells/Components/Files/RecycleBinContainer.swift @@ -102,6 +102,12 @@ package struct RecycleBinContainer: View { updateTags: WireCellsUpdateTagsUseCase(nodesAPI: nodesAPI), getTagSuggestions: WireCellsGetTagSuggestionsUseCase(nodesAPI: nodesAPI), createFolder: WireCellsCreateFolderUseCase(nodesRepository: nodesAPI), + fetchNodeVersions: WireCellsFetchNodeVersionsUseCase(repository: nodesRepository), + restoreNodeVersion: WireCellsRestoreNodeVersionUseCase( + repository: nodesRepository, + localAssetsRepository: localAssetRepository, + nodeCache: nodeCache + ), getEditingURL: WireCellsGetEditingURLUseCase(editingURLRepository: nodesAPI), getAssetUseCase: WireCellsGetAssetUseCase( localAssetRepository: localAssetRepository, diff --git a/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningRestoringVersion.dark.png b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningRestoringVersion.dark.png new file mode 100644 index 00000000000..082f58d4fd3 --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningRestoringVersion.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80309cac3be17ebbaacf643094d40f8318e09911b23cd665bdc64702be014c81 +size 75245 diff --git a/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningRestoringVersion.light.png b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningRestoringVersion.light.png new file mode 100644 index 00000000000..316805e82e0 --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningRestoringVersion.light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:934c462983a583bb9ded1ac7c7b17b3f37f6e64d7642be86962a2f97329b74c8 +size 75370 diff --git a/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningSuccess.dark.png b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningSuccess.dark.png new file mode 100644 index 00000000000..39bb89d27ce --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningSuccess.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44fea207b05d921e266b0c63964013e3a30e3b671173848aa369b019d115bc3a +size 172311 diff --git a/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningSuccess.light.png b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningSuccess.light.png new file mode 100644 index 00000000000..940a551677e --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/Resources/ReferenceImages/FileVersioningViewTests/testFileVersioningSuccess.light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ae9a08f71392e5800cc3adf99401aa7d6c9525a6a253c72ca88c1ec43da34b0 +size 171229 diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodeVersionsUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodeVersionsUseCaseTests.swift new file mode 100644 index 00000000000..5abfde0180a --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodeVersionsUseCaseTests.swift @@ -0,0 +1,61 @@ +// +// Wire +// Copyright (C) 2025 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 Foundation +import Testing + +import WireMessagingDomainSupport +@testable import WireMessagingDomain +@testable import WireMessagingUI + +final class WireCellsFetchNodeVersionsUseCaseTests { + + private let repository = MockWireCellsNodesRepositoryProtocol() + private let sut: WireCellsFetchNodeVersionsUseCase + + init() { + self.sut = WireCellsFetchNodeVersionsUseCase( + repository: repository + ) + } + + @Test + func `It returns a collection of WireCellsNodeVersion`() async throws { + // given + repository.getVersionsNodeID_MockValue = WireCellsNodeVersion.mock + + // when + let result = try await sut.invoke(nodeID: .mockID1) + + // then + #expect(result == WireCellsNodeVersion.mock) + } + + @Test + func `It fails retrieving node versions`() async throws { + // given + repository.getVersionsNodeID_MockError = NSError(domain: "any", code: 0) + + // when + await #expect(throws: WireCellsFetchNodeVersionsUseCase.Failure.unableToRetrieveNodeVersions) { + // then + try await sut.invoke(nodeID: .mockID1) + } + } + +} diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodesPageUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodesPageUseCaseTests.swift index 36fdf2dc9ce..d9ede556fc2 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodesPageUseCaseTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsFetchNodesPageUseCaseTests.swift @@ -21,9 +21,11 @@ import Testing @testable import WireMessagingDomain @testable import WireMessagingDomainSupport +@MainActor struct WireCellsFetchNodesPageUseCaseTests { private let repository = MockWireCellsNodesRepositoryProtocol() + private let localAssetRepository = MockWireCellsLocalAssetRepositoryProtocol() private let sut: WireCellsFetchNodesPageUseCase init() { @@ -32,6 +34,7 @@ struct WireCellsFetchNodesPageUseCaseTests { repository: repository ) repository.getNodes_MockValue = (nodes: [WireCellsNode.fixture()], nextOffset: 30) + localAssetRepository.assetNodeID_MockValue = WireCellsLocalAsset.fixture() } @Test diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsRestoreNodeUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsRestoreNodeUseCaseTests.swift new file mode 100644 index 00000000000..19043d3b25d --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/ImplementationTests/UseCases/WireCellsRestoreNodeUseCaseTests.swift @@ -0,0 +1,69 @@ +// +// Wire +// Copyright (C) 2025 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 Foundation +import Testing + +import WireMessagingDomainSupport +@testable import WireMessagingDomain + +final class WireCellsRestoreNodeUseCaseTests { + + private let repository = MockWireCellsNodesRepositoryProtocol() + private let localAssetRepository = MockWireCellsLocalAssetRepositoryProtocol() + private let nodeCache = MockWireCellsNodeCacheProtocol() + private let sut: WireCellsRestoreNodeVersionUseCase + + init() { + self.sut = WireCellsRestoreNodeVersionUseCase( + repository: repository, + localAssetsRepository: localAssetRepository, + nodeCache: nodeCache + ) + } + + func `It invokes methods to restore version and updates asset locally`() async throws { + // given + nodeCache.setItemFor_MockMethod = { _, _ in } + repository.restoreVersionNodeIDVersionID_MockMethod = { _, _ in () } + localAssetRepository.refreshAssetMetadataNodeID_MockValue = ( + WireCellsNode.fixture(), + WireCellsLocalAsset.fixture() + ) + + // when + try await sut.invoke(nodeID: .mockID1, versionID: .mockID10) + + // then + #expect(nodeCache.setItemFor_Invocations.count == 1) + #expect(repository.restoreVersionNodeIDVersionID_Invocations.count == 1) + #expect(localAssetRepository.refreshAssetMetadataNodeID_Invocations.count == 1) + } + + func `It fails restoring a version`() async throws { + // given + repository.restoreVersionNodeIDVersionID_MockError = NSError(domain: "any", code: 0) + + // when + await #expect(throws: WireCellsRestoreNodeVersionUseCase.Failure.unableToRestoreNodeVersion) { + // then + try await sut.invoke(nodeID: .mockID1, versionID: .mockID10) + } + } + +} diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FileVersioningViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FileVersioningViewTests.swift new file mode 100644 index 00000000000..d0aec97031f --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FileVersioningViewTests.swift @@ -0,0 +1,122 @@ +// +// Wire +// Copyright (C) 2025 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 Combine +import SwiftUI +import WireDesign +import WireFoundation +import WireMessagingDomain +import WireMessagingDomainSupport +import WireTestingPackage +import XCTest + +@testable import WireMessagingUI + +final class FileVersioningViewTests: XCTestCase { + + private var snapshotHelper: SnapshotHelper! + private var nodesAPI: MockNodesAPIProtocol! + private var repository: MockWireCellsNodesRepositoryProtocol! + + @MainActor + override func setUp() async throws { + snapshotHelper = .init() + .withSnapshotDirectory(SnapshotTestReferenceImageDirectory) + nodesAPI = MockNodesAPIProtocol() + repository = MockWireCellsNodesRepositoryProtocol() + } + + @MainActor + override func tearDown() async throws { + snapshotHelper = nil + nodesAPI = nil + repository = nil + } + + // TODO: [WPB-21903] - fix snapshot test currently failing on the CI +// @MainActor +// func testFileVersioningSuccess() async { +// let viewModel = await makeViewModel(testCase: .success) +// let view = FileVersioningView(viewModel: viewModel) +// .frame(width: 375, height: 667) +// +// snapshotHelper +// .withUserInterfaceStyle(.light) +// .verify(matching: view, named: "light") +// snapshotHelper +// .withUserInterfaceStyle(.dark) +// .verify(matching: view, named: "dark") +// } + + @MainActor + func testFileVersioningRestoringVersion() async { + let viewModel = await makeViewModel(testCase: .restore) + let view = FileVersioningView(viewModel: viewModel) + .frame(width: 375, height: 667) + + snapshotHelper + .withUserInterfaceStyle(.light) + .verify(matching: view, named: "light") + snapshotHelper + .withUserInterfaceStyle(.dark) + .verify(matching: view, named: "dark") + } + + enum TestCase { + case success + case restore + } + + @MainActor + private func makeViewModel( + testCase: TestCase + ) async -> FileVersioningViewModel { + + let fetchNodeVersionUseCase = WireCellsFetchNodeVersionsUseCase( + repository: repository + ) + + let localAssetRepository = MockWireCellsLocalAssetRepositoryProtocol() + + let restoreNodeVersionUseCase = WireCellsRestoreNodeVersionUseCase( + repository: repository, + localAssetsRepository: localAssetRepository, + nodeCache: MockWireCellsNodeCacheProtocol() + ) + + let viewModel = FileVersioningViewModel( + nodeID: .mockID1, + name: "foo.jpg", + context: (Locale(identifier: "en_US_POSIX"), Calendar(identifier: .gregorian), TimeZone.gmt), + fetchNodeVersionsUseCase: fetchNodeVersionUseCase, + restoreNodeVersionUseCase: restoreNodeVersionUseCase, + accentColorProvider: { .default } + ) + + switch testCase { + case .success: + repository.getVersionsNodeID_MockValue = WireCellsNodeVersion.mock + await viewModel.fetch() + case .restore: + viewModel.state = .restoringVersion + } + + return viewModel + } + +} diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesBrowserViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesBrowserViewTests.swift index 63f46a6d9e4..b22be63ce23 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesBrowserViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesBrowserViewTests.swift @@ -39,6 +39,8 @@ final class FilesBrowserViewTests: XCTestCase { private var updateTagsUseCase: (any WireCellsUpdateTagsUseCaseProtocol)! private var getTagSuggestionsUseCase: (any WireCellsGetTagSuggestionsUseCaseProtocol)! private var createFolderUseCase: (any WireCellsCreateFolderUseCaseProtocol)! + private var fetchNodeVersionsUseCase: WireCellsFetchNodeVersionsUseCase! + private var restoreNodeVersionUseCase: WireCellsRestoreNodeVersionUseCase! private var getEditingURLUseCase: WireCellsGetEditingURLUseCase! private var getAssetUseCase: WireCellsGetAssetUseCase! private var localAssetsRepository: MockWireCellsLocalAssetRepositoryProtocol! @@ -73,7 +75,7 @@ final class FilesBrowserViewTests: XCTestCase { ) renameNodeUseCase = WireCellsRenameNodeUseCase( nodesRepository: MockWireCellsNodesRepositoryProtocol(), - localAssetsRepository: MockWireCellsLocalAssetRepositoryProtocol(), + localAssetsRepository: localAssetsRepository, nodeCache: MockWireCellsNodeCacheProtocol(), nodeRenameNotifier: WireCellsNodeRenameNotifier() ) @@ -88,6 +90,13 @@ final class FilesBrowserViewTests: XCTestCase { nodesRepository: nodesRepository ) + fetchNodeVersionsUseCase = WireCellsFetchNodeVersionsUseCase(repository: nodesRepository) + restoreNodeVersionUseCase = WireCellsRestoreNodeVersionUseCase( + repository: nodesRepository, + localAssetsRepository: localAssetsRepository, + nodeCache: MockWireCellsNodeCacheProtocol() + ) + let editingURLRepository = MockWireCellsEditingURLRepositoryProtocol() editingURLRepository.getEditorURLId_MockValue = nil getEditingURLUseCase = WireCellsGetEditingURLUseCase( @@ -101,6 +110,13 @@ final class FilesBrowserViewTests: XCTestCase { nodesRepository = nil fetchNodesUseCase = nil localAssetsRepository = nil + fetchNodeVersionsUseCase = nil + createFolderUseCase = nil + getTagSuggestionsUseCase = nil + updateTagsUseCase = nil + renameNodeUseCase = nil + deleteNodeUseCase = nil + restoreNodeVersionUseCase = nil } @MainActor @@ -185,6 +201,8 @@ final class FilesBrowserViewTests: XCTestCase { updateTags: updateTagsUseCase, getTagSuggestions: getTagSuggestionsUseCase, createFolder: createFolderUseCase, + fetchNodeVersions: fetchNodeVersionsUseCase, + restoreNodeVersion: restoreNodeVersionUseCase, getEditingURL: getEditingURLUseCase, getAssetUseCase: getAssetUseCase ), diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewModelTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewModelTests.swift index a537652fb2f..3e9014f0244 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewModelTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewModelTests.swift @@ -68,6 +68,12 @@ final class FilesViewModelTests { updateTags: WireCellsUpdateTagsUseCase(nodesAPI: nodesApi), getTagSuggestions: WireCellsGetTagSuggestionsUseCase(nodesAPI: nodesApi), createFolder: WireCellsCreateFolderUseCase(nodesRepository: nodesRepository), + fetchNodeVersions: WireCellsFetchNodeVersionsUseCase(repository: nodesRepository), + restoreNodeVersion: WireCellsRestoreNodeVersionUseCase( + repository: nodesRepository, + localAssetsRepository: localAssetRepository, + nodeCache: MockWireCellsNodeCacheProtocol() + ), getEditingURL: WireCellsGetEditingURLUseCase(editingURLRepository: editingURLRepository), getAssetUseCase: WireCellsGetAssetUseCase( localAssetRepository: localAssetRepository, @@ -83,6 +89,7 @@ final class FilesViewModelTests { accentColorProvider: { .default } ) + localAssetRepository.assetNodeID_MockValue = .fixture() localAssetRepository .refreshAssetMetadataNodeID_MockValue = (WireCellsNode.fixture(), WireCellsLocalAsset.fixture()) localAssetRepository.downloadAssetNodeID_MockMethod = { _ in } diff --git a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewTests.swift index d19a07df719..6416292e2d7 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireCells/UITests/Components/Files/FilesViewTests.swift @@ -53,6 +53,8 @@ final class FilesViewTests: XCTestCase { nodesApi.updateTagsNodeIDTags_MockMethod = { _, _ in } nodesApi.getAllTags_MockMethod = { ["tag1", "tag2", "abcdef"] } + let localAssetsRepository = MockWireCellsLocalAssetRepositoryProtocol() + fetchNodesUseCase = WireCellsFetchNodesPageUseCase( configuration: .conversationFileView(root: .id(.mockID1), isFoldersEnabled: false), repository: nodesRepository @@ -69,7 +71,7 @@ final class FilesViewTests: XCTestCase { ) renameNodeUseCase = WireCellsRenameNodeUseCase( nodesRepository: nodesRepository, - localAssetsRepository: MockWireCellsLocalAssetRepositoryProtocol(), + localAssetsRepository: localAssetsRepository, nodeCache: MockWireCellsNodeCacheProtocol(), nodeRenameNotifier: WireCellsNodeRenameNotifier() ) @@ -356,6 +358,12 @@ final class FilesViewTests: XCTestCase { createFolder: WireCellsCreateFolderUseCase( nodesRepository: nodesRepository ), + fetchNodeVersions: WireCellsFetchNodeVersionsUseCase(repository: nodesRepository), + restoreNodeVersion: WireCellsRestoreNodeVersionUseCase( + repository: nodesRepository, + localAssetsRepository: MockWireCellsLocalAssetRepositoryProtocol(), + nodeCache: MockWireCellsNodeCacheProtocol() + ), getEditingURL: getEditingURLUseCase, getAssetUseCase: WireCellsGetAssetUseCase( localAssetRepository: MockWireCellsLocalAssetRepositoryProtocol(), diff --git a/WireUI/Sources/WireLocators/Locators.swift b/WireUI/Sources/WireLocators/Locators.swift index a7a623c2280..03640135a01 100644 --- a/WireUI/Sources/WireLocators/Locators.swift +++ b/WireUI/Sources/WireLocators/Locators.swift @@ -261,4 +261,9 @@ public enum Locators { case resetPassword = "Reset password" } + public enum FileVersioningPage: String { + + case closeButton + } + } diff --git a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/WireMessagingFactoryProtocol.swift b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/WireMessagingFactoryProtocol.swift index eb5e15bd755..de910502525 100644 --- a/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/WireMessagingFactoryProtocol.swift +++ b/wire-ios/Wire-iOS/Sources/UserInterface/Conversation/WireMessagingFactoryProtocol.swift @@ -66,8 +66,9 @@ protocol ConversationCellProviderProtocol { } -extension WireMessagingFactory: WireMessagingFactoryProtocol { +extension WireMessagingFactory: @preconcurrency WireMessagingFactoryProtocol { + @MainActor func makeConversationCellProvider( insetsProvider: @escaping () -> ConversationCellInsets ) -> ConversationCellProviderProtocol {