From 4596b7a30fa7757c07a00158506ec4ff752b100e Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:33:44 +0100 Subject: [PATCH 01/62] chore: update DB version with new attributes on `WireCellsLocalAsset` entity - WPB-24208 (#4478) --- .../WireData/Models/WireCellsLocalAsset.swift | 16 + .../WireData/Schema/Databases Changelog.md | 11 + .../zmessaging.xcdatamodeld/.xccurrentversion | 2 +- .../zmessaging2.135.0.xcdatamodel/contents | 459 ++++++++++++++++++ .../Models/WireCellsLocalAssetTests.swift | 9 + .../WireDriveLocalAssetRepository.swift | 10 + .../WireDrive/WireDriveLocalAssetStore.swift | 8 + .../WireDrive/Model/WireDriveLocalAsset.swift | 24 + .../Files/FilesPreviewHelpers.swift | 8 + .../Helpers/WireDriveLocalAsset+Fixture.swift | 4 + .../WireDriveLocalAssetRepositoryTests.swift | 104 ++++ .../Components/Files/FilesViewTests.swift | 8 + .../CoreDataMessagingMigrationVersion.swift | 5 +- .../Tests/Resources/store2-135-0.wiredatabase | Bin 0 -> 716800 bytes .../WireDataModel.xcodeproj/project.pbxproj | 4 + 15 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/zmessaging2.135.0.xcdatamodel/contents create mode 100644 wire-ios-data-model/Tests/Resources/store2-135-0.wiredatabase diff --git a/WireData/Sources/WireData/Models/WireCellsLocalAsset.swift b/WireData/Sources/WireData/Models/WireCellsLocalAsset.swift index 32fc9f76c02..6278ce6fcfc 100644 --- a/WireData/Sources/WireData/Models/WireCellsLocalAsset.swift +++ b/WireData/Sources/WireData/Models/WireCellsLocalAsset.swift @@ -55,6 +55,22 @@ public final class WireCellsLocalAsset: NSManagedObject { @NSManaged public var size: Int64 + /// The conversation where this asset is shared. + + @NSManaged public var conversationName: String? + + /// The name of the user who shared the asset. + + @NSManaged public var ownerName: String? + + /// The date the asset was created / last modified. + + @NSManaged public var modified: Date? + + /// Whether the asset is available offline for the user (defaults to false). + + @NSManaged public var isAvailableOffline: Bool + /// Whether the asset is downloaded or not. @NSManaged public var isDownloaded: Bool diff --git a/WireData/Sources/WireData/Schema/Databases Changelog.md b/WireData/Sources/WireData/Schema/Databases Changelog.md index 064eade598e..7f762c079f5 100644 --- a/WireData/Sources/WireData/Schema/Databases Changelog.md +++ b/WireData/Sources/WireData/Schema/Databases Changelog.md @@ -10,6 +10,17 @@ As it is hard to spot changes from version to version of database models (.xcdat ## zmessaging +### 2.135.0 + +* added `conversationName` attribute on the WireCellsLocalAsset entity +* added `ownerName` attribute on the WireCellsLocalAsset entity +* added `modified` attribute on the WireCellsLocalAsset entity +* added `isAvailableOffline` attribute on the WireCellsLocalAsset entity + +### 2.134.0 + +* added `AppInfo` entity + ### 2.133.0 * removed `senderID` attribute from the Message entity diff --git a/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/.xccurrentversion b/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/.xccurrentversion index b0c4c0ec95a..fc0fb90592e 100644 --- a/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/.xccurrentversion +++ b/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - zmessaging2.134.0.xcdatamodel + zmessaging2.135.0.xcdatamodel diff --git a/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/zmessaging2.135.0.xcdatamodel/contents b/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/zmessaging2.135.0.xcdatamodel/contents new file mode 100644 index 00000000000..fa07136661b --- /dev/null +++ b/WireData/Sources/WireData/Schema/zmessaging.xcdatamodeld/zmessaging2.135.0.xcdatamodel/contents @@ -0,0 +1,459 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/WireData/Tests/WireDataTests/Models/WireCellsLocalAssetTests.swift b/WireData/Tests/WireDataTests/Models/WireCellsLocalAssetTests.swift index 69a1e8436bb..c2a79b367f9 100644 --- a/WireData/Tests/WireDataTests/Models/WireCellsLocalAssetTests.swift +++ b/WireData/Tests/WireDataTests/Models/WireCellsLocalAssetTests.swift @@ -35,6 +35,7 @@ struct WireCellsLocalAssetTests { // given let context = container.viewContext let nodeID = UUID() + let date = try Date.ISO8601FormatStyle().parse("2026-03-24T12:34:56Z") let asset = WireCellsLocalAsset(context: context) asset.nodeID = nodeID @@ -42,6 +43,10 @@ struct WireCellsLocalAssetTests { asset.path = "asset/path" asset.contentType = "image/png" asset.size = 1024 + asset.conversationName = "Conversation 1" + asset.ownerName = "User 1" + asset.isAvailableOffline = true + asset.modified = date asset.isDownloaded = true // when @@ -57,6 +62,10 @@ struct WireCellsLocalAssetTests { #expect(persisted.contentType == "image/png") #expect(persisted.size == 1024) #expect(persisted.isDownloaded == true) + #expect(persisted.conversationName == "Conversation 1") + #expect(persisted.ownerName == "User 1") + #expect(persisted.modified == date) + #expect(persisted.isAvailableOffline == true) } } diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index 32f7f90f703..69c976c546a 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -105,6 +105,7 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository // MARK: - Private + // TODO: [WPB-23967] - Pass flag whether the asset is available offline or not.. @MainActor private func _downloadAsset(nodeID: UUID) async throws { do { @@ -118,6 +119,11 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository path: node.path, contentType: node.mimeType, size: node.size, + conversationName: node.conversation?.name, + ownerName: node.ownerUserName, + modified: node.modified, + isAvailableOffline: false, + // TODO: [WPB-23967] - Once asset is downloaded, this will need to be set to true if the asset is available offline for the user. downloadState: .pending ) ) @@ -170,6 +176,10 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository path: node.path, contentType: node.mimeType, size: node.size, + conversationName: node.conversation?.name, + ownerName: node.ownerUserName, + modified: node.modified, + isAvailableOffline: false, downloadState: .pending ) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index f34a89d2424..c18ad4be383 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -64,6 +64,10 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { stored.path = asset.path stored.contentType = asset.contentType stored.size = asset.size.map { Int64($0) } ?? -1 + stored.conversationName = asset.conversationName + stored.ownerName = asset.ownerName + stored.modified = asset.modified + stored.isAvailableOffline = asset.isAvailableOffline stored.isDownloaded = asset.isDownloaded try context.save() @@ -115,6 +119,10 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { path: managed.path, contentType: managed.contentType, size: managed.size >= 0 ? UInt64(managed.size) : nil, + conversationName: managed.conversationName, + ownerName: managed.ownerName, + modified: managed.modified, + isAvailableOffline: managed.isAvailableOffline, downloadState: managed.isDownloaded ? .downloaded(cacheKey: cacheKey) : .pending ) } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveLocalAsset.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveLocalAsset.swift index b9f8b00e6f7..e1df6655e34 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveLocalAsset.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Model/WireDriveLocalAsset.swift @@ -84,6 +84,22 @@ public struct WireDriveLocalAsset: Equatable, Sendable { public var size: UInt64? + /// The conversation where this asset is shared. + + public var conversationName: String? + + /// The name of the user who shared the asset. + + public var ownerName: String? + + /// The date the asset was created / last modified. + + public var modified: Date? + + /// Whether the asset is available offline for the user. + + public var isAvailableOffline: Bool + /// The download state of the asset. public var downloadState: DownloadState @@ -94,6 +110,10 @@ public struct WireDriveLocalAsset: Equatable, Sendable { path: String, contentType: String?, size: UInt64?, + conversationName: String?, + ownerName: String?, + modified: Date?, + isAvailableOffline: Bool, downloadState: DownloadState ) { self.nodeID = nodeID @@ -102,6 +122,10 @@ public struct WireDriveLocalAsset: Equatable, Sendable { self.contentType = contentType self.size = size self.downloadState = downloadState + self.conversationName = conversationName + self.ownerName = ownerName + self.modified = modified + self.isAvailableOffline = isAvailableOffline } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index f591fbdbaec..9a58f701757 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -336,6 +336,10 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr path: "some/path.jpg", contentType: nil, size: nil, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending ) @@ -363,6 +367,10 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr path: "some/path.jpg", contentType: nil, size: nil, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: downloadState ) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift index e0c65ed72f3..6bca35605cf 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift @@ -35,6 +35,10 @@ extension WireDriveLocalAsset { path: path, contentType: contentType, size: size, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: downloadState ) } diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift index d05f1346d2d..5d453acdd0e 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift @@ -87,10 +87,16 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, + conversation: WireDriveConversation( + id: UUID().uuidString, + name: "Conversation 1", + participants: [] + ), path: "path/file.png", size: 1234, eTag: "abc", mimeType: "image/png", + ownerUserName: "User 1", downloadURL: URL(string: "https://example.com/file.png")! ) nodesAPI.getNodeNodeID_MockValue = node @@ -106,6 +112,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending ) ) @@ -122,6 +132,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending, ) ) @@ -136,6 +150,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "some-cache-key") ) @@ -159,6 +177,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending ) ) @@ -175,6 +197,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending, ) ) @@ -187,10 +213,16 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, + conversation: WireDriveConversation( + id: UUID().uuidString, + name: "Conversation 1", + participants: [] + ), path: "path/file.png", size: 1234, eTag: "abc", mimeType: "image/png", + ownerUserName: "User 1", downloadURL: URL(string: "https://example.com/file.png")! ) nodesAPI.getNodeNodeID_MockValue = node @@ -215,6 +247,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png") ) ) @@ -227,6 +263,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending, ), WireDriveLocalAsset( @@ -235,6 +275,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 0.5) ), WireDriveLocalAsset( @@ -243,6 +287,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 1.0) ), WireDriveLocalAsset( @@ -251,6 +299,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png") ) ] @@ -264,10 +316,16 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, + conversation: WireDriveConversation( + id: UUID().uuidString, + name: "Conversation 1", + participants: [] + ), path: "path/fileWithoutExtension", size: 1234, eTag: "abc", mimeType: "image/png", + ownerUserName: "User 1", downloadURL: URL(string: "https://example.com/fileWithoutExtension")! ) nodesAPI.getNodeNodeID_MockValue = node @@ -292,6 +350,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/fileWithoutExtension", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/fileWithoutExtension") ) ) @@ -304,6 +366,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/fileWithoutExtension", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending, ), WireDriveLocalAsset( @@ -312,6 +378,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/fileWithoutExtension", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 0.5) ), WireDriveLocalAsset( @@ -320,6 +390,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/fileWithoutExtension", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 1.0) ), WireDriveLocalAsset( @@ -328,6 +402,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/fileWithoutExtension", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/fileWithoutExtension") ) ] @@ -341,10 +419,16 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, + conversation: WireDriveConversation( + id: UUID().uuidString, + name: "Conversation 1", + participants: [] + ), path: "path/file.png", size: 1234, eTag: "abc", mimeType: "image/png", + ownerUserName: "User 1", downloadURL: URL(string: "https://example.com/file.png")! ) nodesAPI.getNodeNodeID_MockValue = node @@ -391,6 +475,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png") ) } @@ -404,6 +492,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .pending, ), WireDriveLocalAsset( @@ -412,6 +504,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 0.5) ), WireDriveLocalAsset( @@ -420,6 +516,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 1.0) ), WireDriveLocalAsset( @@ -428,6 +528,10 @@ final class WireDriveLocalAssetRepositoryTests { path: "path/file.png", contentType: "image/png", size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png") ) ] diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift index a916cd1ee67..6ab150ca786 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift @@ -291,6 +291,10 @@ final class FilesViewTests: XCTestCase { path: "some/path", contentType: "some/content/type", size: nil, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .downloading(progress: 0.5) ) @@ -328,6 +332,10 @@ final class FilesViewTests: XCTestCase { path: "some/path", contentType: "some/content/type", size: nil, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, downloadState: .failed(error: URLError(.notConnectedToInternet)) ) diff --git a/wire-ios-data-model/Source/ManagedObjectContext/Migration/CoreDataMessagingMigrationVersion.swift b/wire-ios-data-model/Source/ManagedObjectContext/Migration/CoreDataMessagingMigrationVersion.swift index 15a829faff0..b9a71837586 100644 --- a/wire-ios-data-model/Source/ManagedObjectContext/Migration/CoreDataMessagingMigrationVersion.swift +++ b/wire-ios-data-model/Source/ManagedObjectContext/Migration/CoreDataMessagingMigrationVersion.swift @@ -30,6 +30,7 @@ enum CoreDataMessagingMigrationVersion: String, CoreDataMigrationVersion { // MARK: - // Note: add new versions here in first position! + case v135 = "zmessaging2.135.0" case v134 = "zmessaging2.134.0" case v133 = "zmessaging2.133.0" case v132 = "zmessaging2.132.0" @@ -88,8 +89,10 @@ enum CoreDataMessagingMigrationVersion: String, CoreDataMigrationVersion { var nextVersion: Self? { switch self { - case .v134: + case .v135: nil + case .v134: + .v135 case .v133: .v134 case .v131, .v132: diff --git a/wire-ios-data-model/Tests/Resources/store2-135-0.wiredatabase b/wire-ios-data-model/Tests/Resources/store2-135-0.wiredatabase new file mode 100644 index 0000000000000000000000000000000000000000..e2907ab65d7ade917d3841cb3adb87ec0830ec60 GIT binary patch literal 716800 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

_(we#tX-j$HN5brb?#XOoflj7OVQ` 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 Date: Mon, 30 Mar 2026 14:38:19 +0200 Subject: [PATCH 02/62] feat: add button action for file offline availability (UI only) - WPB-23967 (#4500) --- .../Localization/en.lproj/Localizable.strings | 2 ++ .../Components/Files/FilesViewModel.swift | 3 +++ .../Files/Item/FilesItemViewModel.swift | 11 ++++++++++ .../Files/Item/FilesViewItemView.swift | 22 +++++++++++++++++++ 4 files changed, 38 insertions(+) diff --git a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings index c757503df1a..de50d0c8b40 100644 --- a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings +++ b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings @@ -130,6 +130,8 @@ "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.menu.makeAvailableOffline" = "Make available offline"; +"conversation.wireCells.files.item.menu.removeAvailableOffline" = "Remove available offline"; "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"; diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index d6bfebb5afe..d2e41e5fd44 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -449,6 +449,9 @@ package final class FilesViewModel: ObservableObject { sheetNavigation = .versionHistory(view: makeFileVersioningView(item: item)) case .edit: isEditing = item + case .makeAvailableOffline, .removeAvailableOffline: + // TODO: [WPB-23967] - Call use cases + break } }, isBrowsing: isBrowsing, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index e44270d533e..18197aff4a6 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -46,6 +46,8 @@ final class FilesItemViewModel: ObservableObject { case restore case deleteToRecycleBin case deletePermanently + case makeAvailableOffline + case removeAvailableOffline } let onItemAction: (ItemAction, FilesViewItem) async -> Void @@ -366,6 +368,10 @@ final class FilesItemViewModel: ObservableObject { actions.insert(.shareLink) } + if !isEditable { + actions.insert(isAvailableOffline ? .removeAvailableOffline : .makeAvailableOffline) + } + if !isBrowsing { if isInRecycleBin { actions.insert(.restore) @@ -387,6 +393,11 @@ final class FilesItemViewModel: ObservableObject { return actions } + + var isAvailableOffline: Bool { + // TODO: [WPB-24208] When PR merged, uncomment code + Bool.random() // localAssetRepository.asset(nodeID: nodeID).isAvailableOffline + } } private extension [String] { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 7ab231ad896..b7760c7e9eb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -196,6 +196,28 @@ struct FilesItemView: View { } } + menuItem(.makeAvailableOffline) { item in + Button { + viewModel.performMenuAction(item) + } label: { + Label( + Strings.Files.Item.Menu.makeAvailableOffline, + systemImage: "arrow.down.circle" + ) + } + } + + menuItem(.removeAvailableOffline) { item in + Button { + viewModel.performMenuAction(item) + } label: { + Label( + Strings.Files.Item.Menu.removeAvailableOffline, + systemImage: "xmark.circle" + ) + } + } + menuItem(.showVersionHistory) { item in Button { viewModel.performMenuAction(item) From 7cf32de89f97944fc0b2b14d8afec5d9aa46a2af Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 31 Mar 2026 12:13:21 +0200 Subject: [PATCH 03/62] removing UI elements for offline mode, changing No-Internet bar --- .../Components/Files/FilesContentView.swift | 42 +++++++++++-------- .../Files/FilesOfflineBarView.swift | 33 ++++++++++----- .../Components/Files/FilesView.swift | 2 +- .../Components/Files/FilesViewModel.swift | 12 +++++- 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 72c09f5aab3..644daebb4c0 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -49,24 +49,26 @@ package struct FilesContentView: View { .ignoresSafeArea(.all) VStack { - VStack(alignment: .leading, spacing: 0) { - FilesFilteringView( - useCases: .init(fetchTagsUseCase: viewModel.useCases.getTagSuggestions), - filtersSelection: viewModel.filtersSelection, - isBrowsing: isBrowsing, - conversations: Set(viewModel.conversations), - onUpdate: viewModel.onUpdate(of:), - onSearchFocused: { isSearchFocused = $0 } - ) - .opacity(isFilterBarPresented ? 1 : 0) - .frame(height: isFilterBarPresented ? nil : 0) - .padding(.bottom, isFilterBarPresented ? 15 : 0) - - FilesSortingView(viewModel: viewModel.makeFilesSortingViewModel()) + if !viewModel.isOffline { + VStack(alignment: .leading, spacing: 0) { + FilesFilteringView( + useCases: .init(fetchTagsUseCase: viewModel.useCases.getTagSuggestions), + filtersSelection: viewModel.filtersSelection, + isBrowsing: isBrowsing, + conversations: Set(viewModel.conversations), + onUpdate: viewModel.onUpdate(of:), + onSearchFocused: { isSearchFocused = $0 } + ) + .opacity(isFilterBarPresented ? 1 : 0) + .frame(height: isFilterBarPresented ? nil : 0) + .padding(.bottom, isFilterBarPresented ? 15 : 0) + + FilesSortingView(viewModel: viewModel.makeFilesSortingViewModel()) + } + .padding(.top, 4) + + Spacer() } - .padding(.top, 4) - - Spacer() switch viewModel.state { case .loading: @@ -132,7 +134,9 @@ private extension FilesContentView { List { Group { itemsSection - if viewModel.hasMore { loadMoreRow } + if viewModel.hasMore && !viewModel.isOffline { + loadMoreRow + } } .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) @@ -218,6 +222,8 @@ private extension FilesContentView { var offlineBar: some View { FilesOfflineBarView() + .padding(.bottom, 4) + .background(backgroundColor) .transition( .move(edge: .top) .combined(with: .opacity) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift index c51a2277483..b3b48d27758 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift @@ -20,22 +20,33 @@ import SwiftUI struct FilesOfflineBarView: View { var body: some View { - Group { + VStack { Text( L10n.Localizable.General.NoInternet.title.uppercased() ) .font(for: .subline2) - .foregroundColor(.white) + .foregroundColor(.white) // TODO: [WPB-24475] use proper color + .frame(maxWidth: .infinity) + .frame(height: 25) + .background { + // TODO: [WPB-24475] use proper color + Color( + red: 254.0 / 255.0, + green: 191.0 / 255.0, + blue: 2.0 / 255.0, + opacity: 1 + ) + } + .cornerRadius(6) + + //TODO: Olga said this design will probably change. don't localise yet. + Label { + Text("You can only see downloaded files in the offline mode.") //TODO: localize + //.multilineTextAlignment(.center) + } icon: { + Image(systemName: "wifi.slash") + } } - .frame(maxWidth: .infinity) - .frame(height: 25) - .background(Color( - red: 254.0 / 255.0, - green: 191.0 / 255.0, - blue: 2.0 / 255.0, - opacity: 1 - )) - .cornerRadius(6) .padding(.horizontal, 16) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift index 3e38d2f1b7e..fb3ce36e25f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift @@ -85,7 +85,7 @@ private extension FilesView { } } - if !viewModel.isRecycleBin { + if !viewModel.isRecycleBin && !viewModel.isOffline { ToolbarItem(placement: .navigationBarTrailing) { moreActionsButton } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index d2e41e5fd44..d74182d311f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -232,7 +232,11 @@ package final class FilesViewModel: ObservableObject { var shouldReload: Bool = false let title: String? var showSearchBar: Bool { - switch state { + guard !isOffline else { + return false + } + + return switch state { case .loading, .received: true case .pending, .error: @@ -256,9 +260,13 @@ package final class FilesViewModel: ObservableObject { var isLoading: Bool { loadMoreTask != nil } + + var isOffline: Bool { + connectionState == .offline + } var shouldShowOfflineBar: Bool { - connectionState == .offline && !state.items.isEmpty + isOffline && !state.items.isEmpty } enum ConnectionState { From 6a02c8474ecbc532d5fcea9a9003b1381bdf8b06 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 31 Mar 2026 14:25:48 +0200 Subject: [PATCH 04/62] file item menu actions checking for offline mode --- .../Files/Item/FilesItemViewModel.swift | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 18197aff4a6..7f5621cdc81 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -51,6 +51,11 @@ final class FilesItemViewModel: ObservableObject { } let onItemAction: (ItemAction, FilesViewItem) async -> Void + + enum ConnectionState { + case offline + case online + } @Published private var asset: WireDriveLocalAsset? @@ -61,7 +66,7 @@ final class FilesItemViewModel: ObservableObject { @Published var isPresentingRestoreFileConfirmation = false @Published var isPresentingRestoreFolderConfirmation = false @Published var isPresentingRestoreParentConfirmation = false - @Published var menuActions: Set = [] + @Published var connectionState: ConnectionState = .online let fileName: String let subtitle: String? @@ -114,11 +119,11 @@ final class FilesItemViewModel: ObservableObject { self.isBrowsing = isBrowsing self.isInRecycleBin = isInRecycleBin - self.menuActions = makeMenuActions() - localAssetRepository.observeAsset(nodeID: nodeID).sink { [weak self] asset in self?.asset = asset }.store(in: &cancellables) + + bindNetworkConnection() } var nameOfTopmostFolderInRecycleBin: String { @@ -359,20 +364,33 @@ final class FilesItemViewModel: ObservableObject { additionalTagsIndicator: formattedNumber ) } + + var isOffline: Bool { + connectionState == .offline + } - private func makeMenuActions() -> Set { + var menuActions: Set { var actions: Set = [] if !isInRecycleBin { actions.insert(.open) - actions.insert(.shareLink) + + if !isOffline { + actions.insert(.shareLink) + } } if !isEditable { - actions.insert(isAvailableOffline ? .removeAvailableOffline : .makeAvailableOffline) + if isAvailableOffline { + actions.insert(.removeAvailableOffline) + } else { + if !isOffline { + actions.insert(.makeAvailableOffline) + } + } } - if !isBrowsing { + if !isBrowsing && !isOffline { if isInRecycleBin { actions.insert(.restore) actions.insert(.deletePermanently) @@ -393,10 +411,29 @@ final class FilesItemViewModel: ObservableObject { return actions } + + //TODO: refactor NetworkMonitor to be `@Observable` for more frictionless usage in ViewModels and add debounce. + private func bindNetworkConnection() { + NetworkMonitor.shared.statusPublisher + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + guard let self else { return } + + switch status { + case .connected: + guard connectionState == .offline else { return } + connectionState = .online + case .disconnected: + connectionState = .offline + } + } + .store(in: &cancellables) + } var isAvailableOffline: Bool { // TODO: [WPB-24208] When PR merged, uncomment code - Bool.random() // localAssetRepository.asset(nodeID: nodeID).isAvailableOffline + self.item.hashValue.isMultiple(of: 2) // localAssetRepository.asset(nodeID: nodeID).isAvailableOffline } } From 0a9897d4841e01c7482b12ed51cffacff9fe122e Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Tue, 31 Mar 2026 15:26:18 +0200 Subject: [PATCH 05/62] offline mode: create use cases, adjust repository logic and views accordingly (wip) --- .../WireMessagingFactory.swift | 4 +- .../WireDriveLocalAssetRepository.swift | 26 ++++--- .../WireDrive/WireDriveLocalAssetStore.swift | 4 + ...ireDriveLocalAssetRepositoryProtocol.swift | 5 +- .../UseCases/WireDriveGetAssetUseCase.swift | 2 +- ...riveMakeAssetAvailableOfflineUseCase.swift | 38 +++++++++ ...veRemoveAssetAvailableOfflineUseCase.swift | 42 ++++++++++ .../Files/FilesPreviewHelpers.swift | 10 ++- .../Components/Files/FilesViewContainer.swift | 4 +- .../Components/Files/FilesViewModel.swift | 37 ++++++++- .../Files/Item/FilesItemViewModel.swift | 29 ++++++- .../Files/Item/FilesViewItemView.swift | 78 ++++++++++++------- .../Files/RecycleBinContainer.swift | 4 +- 13 files changed, 230 insertions(+), 53 deletions(-) create mode 100644 WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift create mode 100644 WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index 8768acb44cc..5b2a29a5620 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -215,7 +215,9 @@ public extension WireMessagingFactory { deletePublicLink: WireDriveDeletePublicLinkUseCase(nodesAPI: nodesAPI), updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), - getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI) + getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository) ), isCellsStatePending: false, localAssetRepository: localAssetRepository, diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index 69c976c546a..3ae605ba455 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -79,13 +79,13 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository /// This method first refreshes the assets metadata - see `refreshMetadata(nodeID:)`. /// The download can be observed via the `observeAsset(nodeID:)` method. @MainActor - package func downloadAsset(nodeID: UUID) async throws { + package func downloadAsset(nodeID: UUID, isAvailableOffline: Bool) async throws { if let existingTask = downloadTasks[nodeID] { try await existingTask.value } else { defer { downloadTasks[nodeID] = nil } - let task = Task { try await _downloadAsset(nodeID: nodeID) } + let task = Task { try await _downloadAsset(nodeID: nodeID, isAvailableOffline: isAvailableOffline) } downloadTasks[nodeID] = task try await task.value } @@ -102,17 +102,21 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository package func cancelDownload(nodeID: UUID) { downloadTasks[nodeID]?.cancel() } + + @MainActor + package func updateAsset(_ asset: WireDriveLocalAsset) throws { + try store.upsertAsset(asset) + } // MARK: - Private - // TODO: [WPB-23967] - Pass flag whether the asset is available offline or not.. @MainActor - private func _downloadAsset(nodeID: UUID) async throws { + private func _downloadAsset(nodeID: UUID, isAvailableOffline: Bool) async throws { do { let node = try await getNode(nodeID: nodeID) let (downloadURL, eTag) = try node.downloadInfo - try store.upsertAsset( + try updateAsset( WireDriveLocalAsset( nodeID: nodeID, eTag: eTag, @@ -122,8 +126,7 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository conversationName: node.conversation?.name, ownerName: node.ownerUserName, modified: node.modified, - isAvailableOffline: false, - // TODO: [WPB-23967] - Once asset is downloaded, this will need to be set to true if the asset is available offline for the user. + isAvailableOffline: isAvailableOffline, downloadState: .pending ) ) @@ -132,7 +135,7 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository for await progress in progress { var asset = try verifyAsset(nodeID: nodeID, eTag: eTag) asset.downloadState = .downloading(progress: progress) - try store.upsertAsset(asset) + try updateAsset(asset) } let (tempURL, _) = try await download.value @@ -142,12 +145,13 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository var asset = try verifyAsset(nodeID: nodeID, eTag: eTag) asset.downloadState = .downloaded(cacheKey: key) - try store.upsertAsset(asset) + try updateAsset(asset) } catch { // We don't care about the eTag when setting download state to failed. if var asset = try store.asset(nodeID: nodeID) { asset.downloadState = .failed(error: error) - try store.upsertAsset(asset) + asset.isAvailableOffline = false + try updateAsset(asset) } throw error @@ -198,7 +202,7 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository asset.size = node.size asset.downloadState = downloadState - try store.upsertAsset(asset) + try updateAsset(asset) return (node, asset) } diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index c18ad4be383..53530fe1e95 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -150,6 +150,10 @@ private extension WireMessagingDomain.WireDriveLocalAsset { && contentType == other.contentType && size == other.size && isDownloaded == other.isDownloaded + && conversationName == other.conversationName + && modified == other.modified + && ownerName == other.ownerName + && isAvailableOffline == other.isAvailableOffline } } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index dc62eb9f824..57591c7b27e 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -41,11 +41,14 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { /// This method first refreshes the assets metadata - see `refreshMetadata(nodeID:)`. /// The download can be observed via the `observeAsset(nodeID:)` method. @MainActor - func downloadAsset(nodeID: UUID) async throws + func downloadAsset(nodeID: UUID, isAvailableOffline: Bool) async throws /// Observes the asset for the given `nodeID`. A value of `nil` is emitted if the asset has never been fetched. @MainActor func observeAsset(nodeID: UUID) -> AnyPublisher + + @MainActor + func updateAsset(_ asset: WireDriveLocalAsset) throws /// Cancels the asset download for a given `nodeID`. @MainActor diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveGetAssetUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveGetAssetUseCase.swift index 177bc303d9b..918b75c9d31 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveGetAssetUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveGetAssetUseCase.swift @@ -43,7 +43,7 @@ package struct WireDriveGetAssetUseCase { return fileURL } - try await localAssetRepository.downloadAsset(nodeID: nodeID) + try await localAssetRepository.downloadAsset(nodeID: nodeID, isAvailableOffline: false) guard let cacheKey = try await localAssetRepository.asset(nodeID: nodeID)?.downloadState.cacheKey else { throw Failure.invalidDownloadState } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift new file mode 100644 index 00000000000..9224566b2f9 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift @@ -0,0 +1,38 @@ +// +// 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/. +// + +package import Foundation + +@MainActor +package struct WireDriveMakeAssetAvailableOfflineUseCase { + + private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol + + package init(localAssetRepository: any WireDriveLocalAssetRepositoryProtocol) { + self.localAssetRepository = localAssetRepository + } + + package func invoke(nodeID: UUID) async throws { + if var asset = try localAssetRepository.asset(nodeID: nodeID) { + asset.isAvailableOffline = true + try localAssetRepository.updateAsset(asset) + } else { + try await localAssetRepository.downloadAsset(nodeID: nodeID, isAvailableOffline: true) + } + } +} diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift new file mode 100644 index 00000000000..4d45c0c8d2e --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift @@ -0,0 +1,42 @@ +// +// 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/. +// + +package import Foundation + +@MainActor +package struct WireDriveRemoveAssetAvailableOfflineUseCase { + + enum Failure: Error { + case assetNotFound + } + + private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol + + package init(localAssetRepository: any WireDriveLocalAssetRepositoryProtocol) { + self.localAssetRepository = localAssetRepository + } + + package func invoke(nodeID: UUID) throws { + guard var asset = try localAssetRepository.asset(nodeID: nodeID) else { + throw Failure.assetNotFound + } + + asset.isAvailableOffline = false + try localAssetRepository.updateAsset(asset) + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 9a58f701757..808ab0b026e 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -97,6 +97,12 @@ extension FilesViewModel { ), getDriveConversations: WireDriveGetConversationsUseCase( nodesAPI: previewConversationsApi() + ), + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository ) ), setNavigation: { _ in }, @@ -345,8 +351,10 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr return (node, localAsset) } + + func updateAsset(_ asset: WireDriveLocalAsset) throws {} - func downloadAsset(nodeID: UUID) async throws { + func downloadAsset(nodeID: UUID, isAvailableOffline: Bool) async throws { failIndex += 1 // Fail every 3rd download let shouldFail = failIndex % 3 == 0 diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index 0ab0792c5b2..7f4ffd4d7d1 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -154,7 +154,9 @@ package struct FilesViewContainer: View { deletePublicLink: WireDriveDeletePublicLinkUseCase(nodesAPI: nodesAPI), updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), - getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI) + getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository) ), title: path.last?.name, navigationPath: path, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index d2e41e5fd44..c9f8289d10b 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -173,7 +173,9 @@ package final class FilesViewModel: ObservableObject { deletePublicLink: WireDriveDeletePublicLinkUseCase, updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase, updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase, - getDriveConversations: any WireDriveGetConversationsUseCaseProtocol + getDriveConversations: any WireDriveGetConversationsUseCaseProtocol, + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase, + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase ) { self.fetchNodes = fetchNodes @@ -193,6 +195,8 @@ package final class FilesViewModel: ObservableObject { self.updatePublicLinkExpiration = updatePublicLinkExpiration self.updatePublicLinkPassword = updatePublicLinkPassword self.getDriveConversations = getDriveConversations + self.makeAssetAvailableOfflineUseCase = makeAssetAvailableOfflineUseCase + self.removeAssetAvailableOfflineUseCase = removeAssetAvailableOfflineUseCase } let fetchNodes: WireDriveFetchNodesPageUseCase @@ -212,6 +216,8 @@ package final class FilesViewModel: ObservableObject { let updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase let updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase let getDriveConversations: any WireDriveGetConversationsUseCaseProtocol + let makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase + let removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase } private let setNavigation: ([FilesViewItem]) -> Void @@ -449,9 +455,10 @@ package final class FilesViewModel: ObservableObject { sheetNavigation = .versionHistory(view: makeFileVersioningView(item: item)) case .edit: isEditing = item - case .makeAvailableOffline, .removeAvailableOffline: - // TODO: [WPB-23967] - Call use cases - break + case .makeAvailableOffline: + makeAssetAvailableOffline(item: item) + case .removeAvailableOffline: + removeAssetAvailableOffline(item: item) } }, isBrowsing: isBrowsing, @@ -853,6 +860,28 @@ package final class FilesViewModel: ObservableObject { filtersSelection = filters Task { await reload() } } + + // MARK: - Offline mode + + private func makeAssetAvailableOffline(item: FilesViewItem) { + Task { + do { + try await useCases.makeAssetAvailableOfflineUseCase.invoke(nodeID: item.id) + } catch { + WireLogger.wireDrive.error("Failed to make asset available offline: \(String(describing: error))") + } + } + } + + private func removeAssetAvailableOffline(item: FilesViewItem) { + Task { + do { + try useCases.removeAssetAvailableOfflineUseCase.invoke(nodeID: item.id) + } catch { + WireLogger.wireDrive.error("Failed to remove asset from available offline: \(String(describing: error))") + } + } + } } private extension FilesSortingViewModel.SortingKey { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 18197aff4a6..cde01fb0a69 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -61,6 +61,7 @@ final class FilesItemViewModel: ObservableObject { @Published var isPresentingRestoreFileConfirmation = false @Published var isPresentingRestoreFolderConfirmation = false @Published var isPresentingRestoreParentConfirmation = false + @Published var showOfflineDownload = false @Published var menuActions: Set = [] let fileName: String @@ -117,7 +118,9 @@ final class FilesItemViewModel: ObservableObject { self.menuActions = makeMenuActions() localAssetRepository.observeAsset(nodeID: nodeID).sink { [weak self] asset in - self?.asset = asset + guard let self else { return } + self.asset = asset + self.menuActions = makeMenuActions() }.store(in: &cancellables) } @@ -144,6 +147,15 @@ final class FilesItemViewModel: ObservableObject { false } } + + var isDownloadingForOfflineUse: Bool { + switch asset?.downloadState { + case .downloading where asset?.isAvailableOffline == true: + true + default: + false + } + } var progress: Double? { switch asset?.downloadState { @@ -177,6 +189,8 @@ final class FilesItemViewModel: ObservableObject { showDeleteConfirmation(deletePermanently: true) case .deleteToRecycleBin: showDeleteConfirmation(deletePermanently: false) + case .makeAvailableOffline: + Task { await onItemAction(action, item) } default: Task { await onItemAction(action, item) } } @@ -186,7 +200,7 @@ final class FilesItemViewModel: ObservableObject { precondition(item.kind == .file) // Ignore errors as these will be reported via the `asset` publisher. - try? await localAssetRepository.downloadAsset(nodeID: nodeID) + try? await localAssetRepository.downloadAsset(nodeID: nodeID, isAvailableOffline: false) } func showDeleteConfirmation(deletePermanently: Bool) { @@ -395,8 +409,15 @@ final class FilesItemViewModel: ObservableObject { } var isAvailableOffline: Bool { - // TODO: [WPB-24208] When PR merged, uncomment code - Bool.random() // localAssetRepository.asset(nodeID: nodeID).isAvailableOffline + let isAvailableOffline = (try? localAssetRepository.asset(nodeID: nodeID)?.isAvailableOffline) ?? false + let isDownloaded = switch asset?.downloadState { + case .downloaded: + true + default: + false + } + + return isAvailableOffline && isDownloaded } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index b7760c7e9eb..cab756ba3f8 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -51,35 +51,55 @@ struct FilesItemView: View { .foregroundStyle(ColorTheme.Backgrounds.onSurface.color) HStack(spacing: 5) { - let tagsInfo = viewModel.tagsInfo - - if let firstTag = tagsInfo.firstTag { - Text(firstTag) + if viewModel.isDownloadingForOfflineUse { + Image(systemName: "arrow.down.circle") + .resizable() + .frame(width: 10, height: 10) + .foregroundStyle(wireAccentColor) + + Text("Downloading file..") .font(for: .subline1) - .fontWeight(.medium) .lineLimit(1) - .foregroundStyle(ColorTheme.Base.primary(wireAccentColor).color) - .padding(.vertical, 2) - .padding(.horizontal, 5) - .background { - RoundedRectangle(cornerRadius: 4) - .fill(ColorTheme.Base.primaryVariant(wireAccentColor).color) - } - } - - if let additionalTagsIndicator = tagsInfo.additionalTagsIndicator { - Text(additionalTagsIndicator) + .foregroundStyle(wireAccentColor) + } else { + + if viewModel.isAvailableOffline { + Image(systemName: "arrow.down.circle.fill") + .resizable() + .frame(width: 10, height: 10) + .foregroundStyle(ColorTheme.Base.secondaryText.color) + } + + let tagsInfo = viewModel.tagsInfo + + if let firstTag = tagsInfo.firstTag { + Text(firstTag) + .font(for: .subline1) + .fontWeight(.medium) + .lineLimit(1) + .foregroundStyle(ColorTheme.Base.primary(wireAccentColor).color) + .padding(.vertical, 2) + .padding(.horizontal, 5) + .background { + RoundedRectangle(cornerRadius: 4) + .fill(ColorTheme.Base.primaryVariant(wireAccentColor).color) + } + } + + if let additionalTagsIndicator = tagsInfo.additionalTagsIndicator { + Text(additionalTagsIndicator) + .font(for: .subline1) + .fontWeight(.medium) + .lineLimit(1) + .foregroundStyle(ColorTheme.Base.primary(wireAccentColor).color) + .padding(.trailing, 2) + } + + Text(viewModel.subtitle ?? "") .font(for: .subline1) - .fontWeight(.medium) .lineLimit(1) - .foregroundStyle(ColorTheme.Base.primary(wireAccentColor).color) - .padding(.trailing, 2) + .foregroundStyle(ColorTheme.Base.secondaryText.color) } - - Text(viewModel.subtitle ?? "") - .font(for: .subline1) - .lineLimit(1) - .foregroundStyle(ColorTheme.Base.secondaryText.color) } } @@ -142,11 +162,13 @@ struct FilesItemView: View { .padding(.top, 8) .padding(.bottom, 5) // Less padding to accommodate progress bar - ProgressView(value: viewModel.progress, total: 1) - .opacity(viewModel.progress == nil ? 0 : 1) - .progressViewStyle(AssetProgressStyle(fillColor: progressColor)) + if !viewModel.isDownloadingForOfflineUse { + ProgressView(value: viewModel.progress, total: 1) + .opacity(viewModel.progress == nil ? 0 : 1) + .progressViewStyle(AssetProgressStyle(fillColor: progressColor)) - Divider() + Divider() + } } .contentShape(Rectangle()) // Tap area } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index 526e3d22e9f..0db74b520aa 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift @@ -114,7 +114,9 @@ package struct RecycleBinContainer: View { deletePublicLink: WireDriveDeletePublicLinkUseCase(nodesAPI: nodesAPI), updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), - getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI) + getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository) ), title: path.last?.name, navigationPath: path, From 2a75ef03caf9f01a5b88aa086dede2ef7377d497 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 31 Mar 2026 18:04:44 +0200 Subject: [PATCH 06/62] refactored NetworkMonitor to be Observable instead of just exposing a Publisher with the need for manually setting up a sink --- .../Components/Common/NetworkMonitor.swift | 33 ++++++++++++------- .../Components/Files/FilesContentView.swift | 2 +- .../Components/Files/FilesViewModel.swift | 29 ++-------------- .../Files/Item/FilesItemViewModel.swift | 31 ++--------------- 4 files changed, 28 insertions(+), 67 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift index 9cb09657815..2c70886dee9 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift @@ -18,8 +18,12 @@ @preconcurrency import Combine import Network +import Observation -final class NetworkMonitor: Sendable { +/// Provides observable changes in internet connection. +/// Conforms to both, `Observable` and `ObservableObject` to support ViewModels with the old and the new system. +@MainActor +final class NetworkMonitor: Sendable, Observable, ObservableObject { enum NetworkStatus { case connected @@ -31,21 +35,26 @@ final class NetworkMonitor: Sendable { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitorQueue") private let subject = CurrentValueSubject(.disconnected) + private var cancellables = Set() - var statusPublisher: AnyPublisher { - subject.eraseToAnyPublisher() - } - - var currentStatus: NetworkStatus { - subject.value - } + @Published var currentStatus: NetworkStatus? private init() { + subject + .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + guard let self else { return } + currentStatus = status + } + .store(in: &cancellables) + monitor.pathUpdateHandler = { [weak self] path in - let status: NetworkStatus = - path.status == .satisfied ? .connected : .disconnected - - self?.subject.send(status) + guard let self else { return } + Task { @MainActor in + let status: NetworkStatus = path.status == .satisfied ? .connected : .disconnected + subject.send(status) + } } monitor.start(queue: queue) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 644daebb4c0..b8c449f397d 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -89,7 +89,7 @@ package struct FilesContentView: View { } Spacer() } - .animation(.easeInOut(duration: 0.25), value: viewModel.connectionState) + .animation(.easeInOut(duration: 0.25), value: viewModel.isOffline) .animation(.easeOut(duration: 0.25), value: isSearchFocused) .quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation .navigationTitle(navigationTitle) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index d74182d311f..7dbbb0410c7 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -262,18 +262,13 @@ package final class FilesViewModel: ObservableObject { } var isOffline: Bool { - connectionState == .offline + networkMonitor.currentStatus == .disconnected } var shouldShowOfflineBar: Bool { isOffline && !state.items.isEmpty } - enum ConnectionState { - case offline - case online - } - @Published var hasMore = true @Published private var loadMoreTask: LoadItemsTask? @Published var searchText = "" @@ -286,8 +281,9 @@ package final class FilesViewModel: ObservableObject { @Published var isEditing: FilesViewItem? @Published var templates: [WireDriveFileTemplate] = [] @Published var conversations: [WireDriveConversation] = [] - @Published var connectionState: ConnectionState = .online @Published var filtersSelection: FilesFilteringViewModel.FiltersSelection = .empty + + @Published private var networkMonitor = NetworkMonitor.shared private var selfUserID: String? { conversations @@ -325,7 +321,6 @@ package final class FilesViewModel: ObservableObject { self.accentColorProvider = accentColorProvider bindSearch() - bindNetworkConnection() fetchTemplates() fetchConversations() } @@ -369,24 +364,6 @@ package final class FilesViewModel: ObservableObject { } } - private func bindNetworkConnection() { - NetworkMonitor.shared.statusPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] status in - guard let self else { return } - - switch status { - case .connected: - guard connectionState == .offline else { return } - connectionState = .online - case .disconnected: - connectionState = .offline - } - } - .store(in: &subscriptions) - } - private func bindSearch() { $searchText .removeDuplicates() diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 7f5621cdc81..9a6f340e9ae 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -52,11 +52,6 @@ final class FilesItemViewModel: ObservableObject { let onItemAction: (ItemAction, FilesViewItem) async -> Void - enum ConnectionState { - case offline - case online - } - @Published private var asset: WireDriveLocalAsset? @Published var isPresentingDeleteFilePermanentlyConfirmation = false @@ -66,7 +61,8 @@ final class FilesItemViewModel: ObservableObject { @Published var isPresentingRestoreFileConfirmation = false @Published var isPresentingRestoreFolderConfirmation = false @Published var isPresentingRestoreParentConfirmation = false - @Published var connectionState: ConnectionState = .online + + @Published private var networkMonitor = NetworkMonitor.shared let fileName: String let subtitle: String? @@ -122,8 +118,6 @@ final class FilesItemViewModel: ObservableObject { localAssetRepository.observeAsset(nodeID: nodeID).sink { [weak self] asset in self?.asset = asset }.store(in: &cancellables) - - bindNetworkConnection() } var nameOfTopmostFolderInRecycleBin: String { @@ -366,7 +360,7 @@ final class FilesItemViewModel: ObservableObject { } var isOffline: Bool { - connectionState == .offline + networkMonitor.currentStatus == .disconnected } var menuActions: Set { @@ -411,25 +405,6 @@ final class FilesItemViewModel: ObservableObject { return actions } - - //TODO: refactor NetworkMonitor to be `@Observable` for more frictionless usage in ViewModels and add debounce. - private func bindNetworkConnection() { - NetworkMonitor.shared.statusPublisher - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] status in - guard let self else { return } - - switch status { - case .connected: - guard connectionState == .offline else { return } - connectionState = .online - case .disconnected: - connectionState = .offline - } - } - .store(in: &cancellables) - } var isAvailableOffline: Bool { // TODO: [WPB-24208] When PR merged, uncomment code From 4d31b4c9189f26f581f345c910638c94194834ea Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 31 Mar 2026 18:22:07 +0200 Subject: [PATCH 07/62] removed @preconcurrency from import Combine --- .../WireDrive/Components/Common/NetworkMonitor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift index 2c70886dee9..135b2073c29 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift @@ -16,7 +16,7 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -@preconcurrency import Combine +import Combine import Network import Observation From 01ccc4cacdc75027907f1df54ac3b6d69e706602 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Wed, 1 Apr 2026 13:37:31 +0200 Subject: [PATCH 08/62] removed unused property: showOfflineDownload --- .../WireDrive/Components/Files/Item/FilesItemViewModel.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 970bbbdac4e..4ec02690ad4 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -64,8 +64,6 @@ final class FilesItemViewModel: ObservableObject { @Published private var networkMonitor = NetworkMonitor.shared - @Published var showOfflineDownload = false - let fileName: String let subtitle: String? let icon: WireDriveFileType From 40f38d37be68a73387972ef969fae4174fe83073 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 15:44:03 +0200 Subject: [PATCH 09/62] fix remaining merge conflicts, adjust offline mode UI code following merge --- .../WireDriveLocalAssetRepository.swift | 1 + .../Files/Item/FilesItemViewModel.swift | 8 ++--- .../Files/Item/FilesViewItemView.swift | 28 +++++++++++++-- .../Source/Model/ReactionData.swift | 34 +++++++++++++++++++ .../WireDataModel.xcodeproj/project.pbxproj | 12 ++++--- 5 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 wire-ios-data-model/Source/Model/ReactionData.swift diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index 92f9fbc8d77..e830ba55e63 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -143,6 +143,7 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository for try await progress in progress { var asset = try verifyAsset(nodeID: nodeID, eTag: eTag) asset.downloadState = .downloading(progress: progress) + asset.fileSize = fileSize try updateAsset(asset) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index b2fae4d34c4..135da7b2b77 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -149,8 +149,8 @@ final class FilesItemViewModel: ObservableObject { } var isDownloadingForOfflineUse: Bool { - switch asset?.downloadState { - case .downloading where asset?.isAvailableOffline == true: + switch fileTracker.state { + case .loading where asset?.isAvailableOffline == true: true default: false @@ -392,8 +392,8 @@ final class FilesItemViewModel: ObservableObject { var isAvailableOffline: Bool { let isAvailableOffline = (try? localAssetRepository.asset(nodeID: nodeID)?.isAvailableOffline) ?? false - let isDownloaded = switch asset?.downloadState { - case .downloaded: + let isDownloaded = switch fileTracker.state { + case .loaded: true default: false diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 82295f0106a..13963a925ad 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -115,6 +115,7 @@ struct FilesItemView: View { .padding(.vertical, 8) Divider() + }.contentShape(Rectangle()) // Tap area } @@ -154,13 +155,24 @@ struct FilesItemView: View { .frame(minWidth: iconSpaceWidth) .frame(height: iconSpaceHeight + 3) .overlay { - if readyToOpen { + if viewModel.isDownloadingForOfflineUse { + Image(systemName: "arrow.down") + .foregroundStyle(wireAccentColor) + } else if readyToOpen { Image(systemName: "checkmark") .fontWeight(.medium) } } .foregroundStyle(wireAccentColor) } + + @ViewBuilder + private func availableOfflineIcon() -> some View { + Image(systemName: "arrow.down.circle.fill") + .resizable() + .frame(width: 10, height: 10) + .foregroundStyle(ColorTheme.Base.secondaryText.color) + } @ViewBuilder private func tagsInfo() -> some View { @@ -206,17 +218,29 @@ struct FilesItemView: View { switch viewModel.fileTracker.state { case .notLoaded, .loaded(showReadyToOpen: false): HStack(spacing: 5) { + if viewModel.isAvailableOffline { availableOfflineIcon() } tagsInfo() infoRowTextLine(viewModel.subtitle ?? "") } case .loaded(showReadyToOpen: true): infoRowTextLine(Strings.Files.readyToOpenAfterDownload) case .loading: - infoRowTextLine(Strings.Files.tapToCancelDownload) + if viewModel.isDownloadingForOfflineUse { + downloadingInfoRowTexLine() + } else { + infoRowTextLine(Strings.Files.tapToCancelDownload) + } case .failed: infoRowTextLine(Strings.Files.downloadFailed, error: true) } } + + @ViewBuilder private func downloadingInfoRowTexLine() -> some View { + Text("Downloading file...") + .font(for: .subline1) + .lineLimit(1) + .foregroundStyle(wireAccentColor) + } @ViewBuilder private func menuContent() -> some View { diff --git a/wire-ios-data-model/Source/Model/ReactionData.swift b/wire-ios-data-model/Source/Model/ReactionData.swift new file mode 100644 index 00000000000..b2a1a7f5bbb --- /dev/null +++ b/wire-ios-data-model/Source/Model/ReactionData.swift @@ -0,0 +1,34 @@ +// +// 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/. +// + +@objc +public class ReactionData: NSObject { + public let reactionString: String + public let users: [UserType] + public let creationDate: Date + + public init(reactionString: String, users: [UserType], creationDate: Date) { + self.reactionString = reactionString + self.users = users + self.creationDate = creationDate + } + + public override var hash: Int { + reactionString.hash + } +} diff --git a/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj b/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj index fdc17d222be..e73ba2c4d6c 100644 --- a/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj +++ b/wire-ios-data-model/WireDataModel.xcodeproj/project.pbxproj @@ -197,7 +197,6 @@ 34937A702EBA4E97001A0061 /* MLSTransportError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34937A6F2EBA4E97001A0061 /* MLSTransportError.swift */; }; 34BD6E032DB26FA200DFA11A /* StoredUpdateEventEnvelope+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BD6E022DB26FA200DFA11A /* StoredUpdateEventEnvelope+Helpers.swift */; }; 34DC44B52E01C210004D5DD5 /* WireNetwork in Frameworks */ = {isa = PBXBuildFile; productRef = 34DC44B42E01C210004D5DD5 /* WireNetwork */; }; - 4058AAA22AA76BFA0013DE71 /* ReactionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4058AAA12AA76BFA0013DE71 /* ReactionData.swift */; }; 4058AAA62AAA017F0013DE71 /* store2-109-0.wiredatabase in Resources */ = {isa = PBXBuildFile; fileRef = 4058AAA52AAA017F0013DE71 /* store2-109-0.wiredatabase */; }; 4058AAA82AAB65530013DE71 /* ReactionsSortingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4058AAA72AAB65530013DE71 /* ReactionsSortingTests.swift */; }; 541E4F951CBD182100D82D69 /* FileAssetCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541E4F941CBD182100D82D69 /* FileAssetCache.swift */; }; @@ -437,6 +436,8 @@ BFE764431ED5AAE500C65C3E /* ZMConversation+TeamsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFE764421ED5AAE400C65C3E /* ZMConversation+TeamsTests.swift */; }; BFFBFD931D59E3F00079773E /* ConversationMessage+Deletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFBFD921D59E3F00079773E /* ConversationMessage+Deletion.swift */; }; BFFBFD951D59E49D0079773E /* ZMClientMessageTests+Deletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFFBFD941D59E49D0079773E /* ZMClientMessageTests+Deletion.swift */; }; + C92F45D32F7D53C400B0E5F3 /* EARServiceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92F45D22F7D53C400B0E5F3 /* EARServiceFactory.swift */; }; + C92F45D52F7D542200B0E5F3 /* ReactionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C92F45D42F7D542200B0E5F3 /* ReactionData.swift */; }; C93082672F72D4C000C02AD9 /* store2-135-0.wiredatabase in Resources */ = {isa = PBXBuildFile; fileRef = C93082662F72D4C000C02AD9 /* store2-135-0.wiredatabase */; }; C9B82AAF2E60640000183723 /* store2-129-0.wiredatabase in Resources */ = {isa = PBXBuildFile; fileRef = C9B82AAE2E60640000183723 /* store2-129-0.wiredatabase */; }; CB181C7E2D3F8D8400A80AB4 /* OneOnOneSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB181C7D2D3F8D7F00A80AB4 /* OneOnOneSource.swift */; }; @@ -946,7 +947,6 @@ 347FA5FF2E70131700F34C6A /* NSManagedObjectContext+BackendMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+BackendMetadata.swift"; sourceTree = ""; }; 34937A6F2EBA4E97001A0061 /* MLSTransportError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLSTransportError.swift; sourceTree = ""; }; 34BD6E022DB26FA200DFA11A /* StoredUpdateEventEnvelope+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoredUpdateEventEnvelope+Helpers.swift"; sourceTree = ""; }; - 4058AAA12AA76BFA0013DE71 /* ReactionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionData.swift; sourceTree = ""; }; 4058AAA52AAA017F0013DE71 /* store2-109-0.wiredatabase */ = {isa = PBXFileReference; lastKnownFileType = file; path = "store2-109-0.wiredatabase"; sourceTree = ""; }; 4058AAA72AAB65530013DE71 /* ReactionsSortingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsSortingTests.swift; sourceTree = ""; }; 541E4F941CBD182100D82D69 /* FileAssetCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileAssetCache.swift; sourceTree = ""; }; @@ -1175,6 +1175,8 @@ BFE764421ED5AAE400C65C3E /* ZMConversation+TeamsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ZMConversation+TeamsTests.swift"; sourceTree = ""; }; BFFBFD921D59E3F00079773E /* ConversationMessage+Deletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationMessage+Deletion.swift"; sourceTree = ""; }; BFFBFD941D59E49D0079773E /* ZMClientMessageTests+Deletion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ZMClientMessageTests+Deletion.swift"; sourceTree = ""; }; + C92F45D22F7D53C400B0E5F3 /* EARServiceFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EARServiceFactory.swift; sourceTree = ""; }; + C92F45D42F7D542200B0E5F3 /* ReactionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionData.swift; sourceTree = ""; }; C93082662F72D4C000C02AD9 /* store2-135-0.wiredatabase */ = {isa = PBXFileReference; lastKnownFileType = file; path = "store2-135-0.wiredatabase"; sourceTree = ""; }; C9B82AAE2E60640000183723 /* store2-129-0.wiredatabase */ = {isa = PBXFileReference; lastKnownFileType = file; path = "store2-129-0.wiredatabase"; sourceTree = ""; }; CB181C7D2D3F8D7F00A80AB4 /* OneOnOneSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneOnOneSource.swift; sourceTree = ""; }; @@ -2033,7 +2035,7 @@ isa = PBXGroup; children = ( CE4EDC081D6D9A3D002A20AA /* Reaction.swift */, - 4058AAA12AA76BFA0013DE71 /* ReactionData.swift */, + C92F45D42F7D542200B0E5F3 /* ReactionData.swift */, ); name = Reaction; sourceTree = ""; @@ -2093,6 +2095,7 @@ E6E504292BC542C5004948E7 /* EARPublicKeys.swift */, E6E5042A2BC542C5004948E7 /* EARService.swift */, E6E5042B2BC542C5004948E7 /* EARServiceDelegate.swift */, + C92F45D22F7D53C400B0E5F3 /* EARServiceFactory.swift */, E6E5042C2BC542C5004948E7 /* EARServiceFailure.swift */, E6E5042D2BC542C5004948E7 /* EARStorage.swift */, E6E5042E2BC542C5004948E7 /* PrivateEARKeyDescription.swift */, @@ -3618,6 +3621,7 @@ 34BD6E032DB26FA200DFA11A /* StoredUpdateEventEnvelope+Helpers.swift in Sources */, F9A706831CAEE01D00C2F5FE /* ZMOTRMessage.m in Sources */, 165DC51F21491C0400090B7B /* Mention.swift in Sources */, + C92F45D52F7D542200B0E5F3 /* ReactionData.swift in Sources */, F9A706531CAEE01D00C2F5FE /* NSManagedObjectContext+zmessaging.m in Sources */, 16B5B33126FDC5D2001A3216 /* ZMConnection+Actions.swift in Sources */, F9A706571CAEE01D00C2F5FE /* NSNotification+ManagedObjectContextSave.m in Sources */, @@ -3659,7 +3663,6 @@ 013887AC2B9A5C8B00323DD0 /* CoreDataMigrationAction.swift in Sources */, 068DCC5729BB816300F7E4F1 /* ZMOTRMessage+FailedToSendReason.swift in Sources */, F963E9811D9C09E700098AD3 /* ZMMessageTimer.m in Sources */, - 4058AAA22AA76BFA0013DE71 /* ReactionData.swift in Sources */, 6326E4762AEBB946006EEA28 /* ProteusToMLSMigrationStorage.swift in Sources */, 0129E7FB29A520EB0065E6DB /* SafeFileContext.swift in Sources */, 63370CC4242CFA860072C37F /* ZMAssetClientMessage+UpdateEvent.swift in Sources */, @@ -3729,6 +3732,7 @@ 63B1335729A503D100009D84 /* MLSActionsProvider.swift in Sources */, EEC794F42A384421008E1A3B /* MLSDecryptionService.swift in Sources */, 5966D8362BD6AF1700305BBC /* UserPropertyNormalizationResult.swift in Sources */, + C92F45D32F7D53C400B0E5F3 /* EARServiceFactory.swift in Sources */, 1672A6282344F10700380537 /* FolderList.swift in Sources */, 54E3EE3F1F6169A800A261E3 /* ZMAssetClientMessage+FileMessageData.swift in Sources */, 599EA3D02C246886009319D4 /* FilterConversationsUseCase.swift in Sources */, From 61b4a329445b896cb6e124ebda53fc51249149ac Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 16:00:56 +0200 Subject: [PATCH 10/62] add conditional check on downloaded state --- .../UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift | 2 +- .../WireDrive/Components/Files/Item/FilesItemViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift index 9224566b2f9..6b7ba01f145 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift @@ -28,7 +28,7 @@ package struct WireDriveMakeAssetAvailableOfflineUseCase { } package func invoke(nodeID: UUID) async throws { - if var asset = try localAssetRepository.asset(nodeID: nodeID) { + if var asset = try localAssetRepository.asset(nodeID: nodeID), asset.downloadState.cacheKey != nil { asset.isAvailableOffline = true try localAssetRepository.updateAsset(asset) } else { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 135da7b2b77..e12cd90c758 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -178,7 +178,7 @@ final class FilesItemViewModel: ObservableObject { showDeleteConfirmation(deletePermanently: true) case .deleteToRecycleBin: showDeleteConfirmation(deletePermanently: false) - case .makeAvailableOffline: + case .makeAvailableOffline, .removeAvailableOffline: Task { await onItemAction(action, item) } default: Task { await onItemAction(action, item) } From aba852173dce2c6d23af052e38ba5b882dcb7cc8 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Wed, 1 Apr 2026 16:05:54 +0200 Subject: [PATCH 11/62] loading list of offline files (one dummy file for now) when offline mode changes. --- .../Components/Files/FilesContentView.swift | 5 +++ .../Components/Files/FilesViewModel.swift | 32 +++++++++++++++++++ .../Files/Item/FilesItemViewModel.swift | 2 +- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index b8c449f397d..48bad5c823e 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -119,6 +119,11 @@ package struct FilesContentView: View { } ) } + .onChange(of: viewModel.isOffline) { + Task { + await viewModel.reload(refreshing: true) + } + } } private var isFilterBarPresented: Bool { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 62d2bfc44ac..e84cb430588 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -608,6 +608,14 @@ package final class FilesViewModel: ObservableObject { } private func loadMore(refreshing: Bool = false) async { + if isOffline { + await loadOfflineFiles() + } else { + await loadOnlineFiles(refreshing: refreshing) + } + } + + private func loadOnlineFiles(refreshing: Bool) async { guard loadMoreTask == nil else { return } let offset = refreshing ? 0 : state.items.count @@ -639,6 +647,30 @@ package final class FilesViewModel: ObservableObject { loadMoreTask = nil } + private func loadOfflineFiles() async { + //TODO: fetch the offline files + state = .received( + items: [ + .init( + id: UUID(), + eTag: "eTag", + kind: .file, + name: "offline file dummy", + filePath: "filePath", + ownedBy: nil, + modifiedAt: nil, + icon: .code, + tags: ["offline file"], + isEditable: false, + publicLinkID: nil, + conversationName: nil, + size: nil + ) + ] + ) + hasMore = false + } + private nonisolated func fetchItems( offset: Int ) async throws -> (items: [FilesViewItem], isLastPage: Bool) { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 4ec02690ad4..dc8533b0ee0 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -126,7 +126,7 @@ final class FilesItemViewModel: ObservableObject { } var isDownloadOptionAvailable: Bool { - guard item.kind == .file else { return false } + guard item.kind == .file && !isOffline else { return false } return switch asset?.downloadState { case .downloaded: From 00bf131ed51ef6d3d3c6d8e82a942d16598d4b3e Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 16:06:48 +0200 Subject: [PATCH 12/62] add localized string --- .../Resources/Localization/en.lproj/Localizable.strings | 1 + .../WireDrive/Components/Files/Item/FilesViewItemView.swift | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings index 2bab8ece136..b467bd0b706 100644 --- a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings +++ b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings @@ -182,6 +182,7 @@ "conversation.wireCells.files.readyToOpenAfterDownload" = "Ready to open"; "conversation.wireCells.files.tapToCancelDownload" = "Tap to cancel loading"; "conversation.wireCells.files.downloadFailed" = "Unable to load, retry"; +"conversation.wireCells.files.downloadingFile" = "Downloading file..."; // MARK: - Recycle Bin "conversation.wireCells.recycleBin.navigationTitle" = "Recycle Bin"; diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 13963a925ad..21b47dfa6eb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -235,8 +235,8 @@ struct FilesItemView: View { } } - @ViewBuilder private func downloadingInfoRowTexLine() -> some View { - Text("Downloading file...") + @ViewBuilder private func downloadingInfoRowTextLine() -> some View { + Text(Strings.Files.downloadingFile) .font(for: .subline1) .lineLimit(1) .foregroundStyle(wireAccentColor) From 8a3438c3e2f987fd20f4ddf62b393d9ef72ed049 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 16:12:14 +0200 Subject: [PATCH 13/62] fix compilation issue --- .../WireDrive/Components/Files/Item/FilesViewItemView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 21b47dfa6eb..aae5f43f711 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -226,7 +226,7 @@ struct FilesItemView: View { infoRowTextLine(Strings.Files.readyToOpenAfterDownload) case .loading: if viewModel.isDownloadingForOfflineUse { - downloadingInfoRowTexLine() + downloadingInfoRowTextLine() } else { infoRowTextLine(Strings.Files.tapToCancelDownload) } From 586b87e86abef7b507cd869ba83d227771ffbaea Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 17:45:03 +0200 Subject: [PATCH 14/62] add DEBUG action to delete asset from db and cache --- .../WireDrive/WireDriveLocalAssetRepository.swift | 11 +++++++++++ .../WireDriveLocalAssetRepositoryProtocol.swift | 4 ++++ .../Components/Files/FilesPreviewHelpers.swift | 8 +++++++- .../Components/Files/Item/FilesItemViewModel.swift | 8 ++++++++ .../Components/Files/Item/FilesViewItemView.swift | 8 ++++++++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index e830ba55e63..66b9ac64925 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -107,6 +107,17 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository package func updateAsset(_ asset: WireDriveLocalAsset) throws { try store.upsertAsset(asset) } + + @MainActor + package func deleteAsset(nodeID: UUID) async throws { + guard let asset = try store.asset(nodeID: nodeID), + let cacheKey = asset.downloadState.cacheKey else { + return + } + + try await store.deleteAssets(nodeIDs: [nodeID]) + try await fileCache.deleteFile(forKey: cacheKey) + } // MARK: - Private diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index 57591c7b27e..e7d116ce392 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -49,6 +49,10 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { @MainActor func updateAsset(_ asset: WireDriveLocalAsset) throws + + /// Deletes an asset from both the database and file cache. + @MainActor + func deleteAsset(nodeID: UUID) async throws /// Cancels the asset download for a given `nodeID`. @MainActor diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 808ab0b026e..e51e3d4ccb4 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -352,7 +352,13 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr return (node, localAsset) } - func updateAsset(_ asset: WireDriveLocalAsset) throws {} + func updateAsset(_ asset: WireDriveLocalAsset) throws { + publishers[asset.nodeID]?.send(asset) + } + + func deleteAsset(nodeID: UUID) async throws { + publishers[nodeID]?.send(nil) + } func downloadAsset(nodeID: UUID, isAvailableOffline: Bool) async throws { failIndex += 1 diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index e12cd90c758..1fc8ad43529 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -223,6 +223,14 @@ final class FilesItemViewModel: ObservableObject { func confirmRestore() async { await onItemAction(.restore, item) } + + #if DEBUG + func deleteAsset() { + Task { + try await localAssetRepository.deleteAsset(nodeID: nodeID) + } + } + #endif private static func subtitle( selectedSortingKey: FilesSortingViewModel.SortingKey?, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index aae5f43f711..0e443fd0922 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -244,6 +244,14 @@ struct FilesItemView: View { @ViewBuilder private func menuContent() -> some View { + #if DEBUG + Button { + viewModel.deleteAsset() + } label: { + Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") + } + #endif + menuItem(.primaryAction) { item in Button { viewModel.performAction(item) From 089c93e0c210bc779326d87aad591e20150c2df5 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 18:12:34 +0200 Subject: [PATCH 15/62] lint and format --- .../WireMessagingFactory.swift | 8 +++- .../WireDriveLocalAssetRepository.swift | 6 +-- ...ireDriveLocalAssetRepositoryProtocol.swift | 4 +- ...riveMakeAssetAvailableOfflineUseCase.swift | 6 +-- ...veRemoveAssetAvailableOfflineUseCase.swift | 10 ++-- .../Files/FilesPreviewHelpers.swift | 4 +- .../Components/Files/FilesViewContainer.swift | 8 +++- .../Components/Files/FilesViewModel.swift | 9 ++-- .../Files/Item/FilesViewItemView.swift | 46 +++++++++++-------- .../Files/RecycleBinContainer.swift | 8 +++- 10 files changed, 64 insertions(+), 45 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index 5b2a29a5620..0dad138cecb 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -216,8 +216,12 @@ public extension WireMessagingFactory { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository) + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ) ), isCellsStatePending: false, localAssetRepository: localAssetRepository, diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index 66b9ac64925..473e9e699de 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -102,19 +102,19 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository package func cancelDownload(nodeID: UUID) { downloadTasks[nodeID]?.cancel() } - + @MainActor package func updateAsset(_ asset: WireDriveLocalAsset) throws { try store.upsertAsset(asset) } - + @MainActor package func deleteAsset(nodeID: UUID) async throws { guard let asset = try store.asset(nodeID: nodeID), let cacheKey = asset.downloadState.cacheKey else { return } - + try await store.deleteAssets(nodeIDs: [nodeID]) try await fileCache.deleteFile(forKey: cacheKey) } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index e7d116ce392..ff6a310378b 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -46,10 +46,10 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { /// Observes the asset for the given `nodeID`. A value of `nil` is emitted if the asset has never been fetched. @MainActor func observeAsset(nodeID: UUID) -> AnyPublisher - + @MainActor func updateAsset(_ asset: WireDriveLocalAsset) throws - + /// Deletes an asset from both the database and file cache. @MainActor func deleteAsset(nodeID: UUID) async throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift index 6b7ba01f145..f9366b20f41 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMakeAssetAvailableOfflineUseCase.swift @@ -20,13 +20,13 @@ package import Foundation @MainActor package struct WireDriveMakeAssetAvailableOfflineUseCase { - + private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol - + package init(localAssetRepository: any WireDriveLocalAssetRepositoryProtocol) { self.localAssetRepository = localAssetRepository } - + package func invoke(nodeID: UUID) async throws { if var asset = try localAssetRepository.asset(nodeID: nodeID), asset.downloadState.cacheKey != nil { asset.isAvailableOffline = true diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift index 4d45c0c8d2e..077ef8546bf 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift @@ -20,22 +20,22 @@ package import Foundation @MainActor package struct WireDriveRemoveAssetAvailableOfflineUseCase { - + enum Failure: Error { case assetNotFound } - + private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol - + package init(localAssetRepository: any WireDriveLocalAssetRepositoryProtocol) { self.localAssetRepository = localAssetRepository } - + package func invoke(nodeID: UUID) throws { guard var asset = try localAssetRepository.asset(nodeID: nodeID) else { throw Failure.assetNotFound } - + asset.isAvailableOffline = false try localAssetRepository.updateAsset(asset) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index e51e3d4ccb4..2822a6f08f3 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -351,11 +351,11 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr return (node, localAsset) } - + func updateAsset(_ asset: WireDriveLocalAsset) throws { publishers[asset.nodeID]?.send(asset) } - + func deleteAsset(nodeID: UUID) async throws { publishers[nodeID]?.send(nil) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index 7f4ffd4d7d1..dd27f377fff 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -155,8 +155,12 @@ package struct FilesViewContainer: View { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository) + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ) ), title: path.last?.name, navigationPath: path, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 3f5172f8d16..785c6e20187 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -865,9 +865,9 @@ package final class FilesViewModel: ObservableObject { filtersSelection = filters Task { await reload() } } - + // MARK: - Offline mode - + private func makeAssetAvailableOffline(item: FilesViewItem) { Task { do { @@ -877,13 +877,14 @@ package final class FilesViewModel: ObservableObject { } } } - + private func removeAssetAvailableOffline(item: FilesViewItem) { Task { do { try useCases.removeAssetAvailableOfflineUseCase.invoke(nodeID: item.id) } catch { - WireLogger.wireDrive.error("Failed to remove asset from available offline: \(String(describing: error))") + WireLogger.wireDrive + .error("Failed to remove asset from available offline: \(String(describing: error))") } } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 0e443fd0922..3b8370d0157 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -32,31 +32,31 @@ struct FilesItemView: View { private let iconSpaceWidth: CGFloat = 56 // this is explicitly not supposed to scale. @ScaledMetric private var iconSpaceHeight: CGFloat = 28 @ScaledMetric private var iconHorizontalPadding: CGFloat = 7 - + @Environment(\.wireAccentColor) private var wireAccentColor - + init(viewModel: @autoclosure @escaping () -> FilesItemViewModel) { self._viewModel = StateObject(wrappedValue: viewModel()) } - + var body: some View { VStack(spacing: 0) { HStack(spacing: 0) { icon().accessibilitySortPriority(3) - + VStack(alignment: .leading, spacing: 5) { Text(viewModel.fileName) .font(for: .body2) .lineLimit(1) .foregroundStyle(ColorTheme.Backgrounds.onSurface.color) - + infoRow() } .accessibilityElement(children: .combine) .accessibilitySortPriority(2) - + Spacer() - + Menu { menuContent() } label: { @@ -113,16 +113,19 @@ struct FilesItemView: View { ) } .padding(.vertical, 8) - + Divider() - + }.contentShape(Rectangle()) // Tap area } @ViewBuilder private func icon() -> some View { switch viewModel.fileTracker.state { - case .notLoaded, .loaded(showReadyToOpen: false), .failed: + case .notLoaded, + .loaded(showReadyToOpen: false), + .loaded(showReadyToOpen: true) where viewModel.isAvailableOffline, + .failed: fileTypeIcon() case .loaded(showReadyToOpen: true): progressIcon(progress: 1, readyToOpen: true) @@ -165,7 +168,7 @@ struct FilesItemView: View { } .foregroundStyle(wireAccentColor) } - + @ViewBuilder private func availableOfflineIcon() -> some View { Image(systemName: "arrow.down.circle.fill") @@ -216,7 +219,9 @@ struct FilesItemView: View { @ViewBuilder private func infoRow() -> some View { switch viewModel.fileTracker.state { - case .notLoaded, .loaded(showReadyToOpen: false): + case .notLoaded, + .loaded(showReadyToOpen: false), + .loaded(showReadyToOpen: true) where viewModel.isAvailableOffline: HStack(spacing: 5) { if viewModel.isAvailableOffline { availableOfflineIcon() } tagsInfo() @@ -234,8 +239,9 @@ struct FilesItemView: View { infoRowTextLine(Strings.Files.downloadFailed, error: true) } } - - @ViewBuilder private func downloadingInfoRowTextLine() -> some View { + + @ViewBuilder + private func downloadingInfoRowTextLine() -> some View { Text(Strings.Files.downloadingFile) .font(for: .subline1) .lineLimit(1) @@ -245,13 +251,13 @@ struct FilesItemView: View { @ViewBuilder private func menuContent() -> some View { #if DEBUG - Button { - viewModel.deleteAsset() - } label: { - Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") - } + Button { + viewModel.deleteAsset() + } label: { + Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") + } #endif - + menuItem(.primaryAction) { item in Button { viewModel.performAction(item) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index 0db74b520aa..a8571ac95e8 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift @@ -115,8 +115,12 @@ package struct RecycleBinContainer: View { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase(localAssetRepository: localAssetRepository) + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ) ), title: path.last?.name, navigationPath: path, From 7df2770d9dba6be0815be882750a418824dddf83 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 1 Apr 2026 18:13:20 +0200 Subject: [PATCH 16/62] don't automatically open file when file is made available offline --- .../Files/Item/FilesItemViewModel.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 1fc8ad43529..f88ffa2e417 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -118,6 +118,7 @@ final class FilesItemViewModel: ObservableObject { self.fileTracker = .init() fileTracker.onSmallFileLoaded = { [weak self] in + guard let asset = self?.asset, !asset.isAvailableOffline else { return } self?.performAction(.primaryAction) } @@ -127,7 +128,7 @@ final class FilesItemViewModel: ObservableObject { guard let self else { return } self.asset = asset if let asset { - self.fileTracker.handleDownloadState(fromAsset: asset) + fileTracker.handleDownloadState(fromAsset: asset) } self.menuActions = makeMenuActions() }.store(in: &cancellables) @@ -147,7 +148,7 @@ final class FilesItemViewModel: ObservableObject { true } } - + var isDownloadingForOfflineUse: Bool { switch fileTracker.state { case .loading where asset?.isAvailableOffline == true: @@ -223,13 +224,15 @@ final class FilesItemViewModel: ObservableObject { func confirmRestore() async { await onItemAction(.restore, item) } - + #if DEBUG - func deleteAsset() { - Task { - try await localAssetRepository.deleteAsset(nodeID: nodeID) + func deleteAsset() { + Task { + do { + try await localAssetRepository.deleteAsset(nodeID: nodeID) + } catch {} + } } - } #endif private static func subtitle( @@ -406,7 +409,7 @@ final class FilesItemViewModel: ObservableObject { default: false } - + return isAvailableOffline && isDownloaded } } From b548c28c258231e731caa3c91db13e83f813cbe7 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 2 Apr 2026 09:31:30 +0200 Subject: [PATCH 17/62] fix UTs --- .../WireDriveLocalAssetRepositoryTests.swift | 6 +++--- .../Files/FilesBrowserViewTests.swift | 19 ++++++++++++++++++- .../Files/FilesViewModelTests.swift | 12 +++++++++--- .../Components/Files/FilesViewTests.swift | 11 +++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift index 66184fa701b..e5632b0f77e 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift @@ -237,7 +237,7 @@ final class WireDriveLocalAssetRepositoryTests { } // when - try await sut.downloadAsset(nodeID: nodeID) + try await sut.downloadAsset(nodeID: nodeID, isAvailableOffline: false) // then #expect( @@ -340,7 +340,7 @@ final class WireDriveLocalAssetRepositoryTests { } // when - try await sut.downloadAsset(nodeID: nodeID) + try await sut.downloadAsset(nodeID: nodeID, isAvailableOffline: false) // then #expect( @@ -449,7 +449,7 @@ final class WireDriveLocalAssetRepositoryTests { ) { [nodeID, sut, store] taskGroup in for _ in 1 ... 3 { taskGroup.addTask { - try await sut.downloadAsset(nodeID: nodeID) + try await sut.downloadAsset(nodeID: nodeID, isAvailableOffline: false) return try await store.asset(nodeID: nodeID) } } diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift index 3f886cb4ec5..9127d8da5c6 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift @@ -50,6 +50,8 @@ final class FilesBrowserViewTests: XCTestCase { private var updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase! private var updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase! private var getDriveConversationsUseCase: WireDriveGetConversationsUseCase! + private var makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase! + private var removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase! private let record: Bool? = nil @@ -112,11 +114,20 @@ final class FilesBrowserViewTests: XCTestCase { editingURLRepository: editingURLRepository ) + localAssetsRepository.assetNodeID_MockValue = WireDriveLocalAsset.fixture() + getPublicLinkData = WireDriveGetPublicLinkDataUseCase(nodesAPI: nodesApi) createPublicLink = WireDriveCreatePublicLinkUseCase(nodesAPI: nodesApi) deletePublicLink = WireDriveDeletePublicLinkUseCase(nodesAPI: nodesApi) updatePublicLinkExpiration = WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesApi) updatePublicLinkPassword = WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesApi) + makeAssetAvailableOfflineUseCase = WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetsRepository + ) + removeAssetAvailableOfflineUseCase = WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetsRepository + ) + } @MainActor @@ -226,7 +237,13 @@ final class FilesBrowserViewTests: XCTestCase { deletePublicLink: deletePublicLink, updatePublicLinkExpiration: updatePublicLinkExpiration, updatePublicLinkPassword: updatePublicLinkPassword, - getDriveConversations: getDriveConversationsUseCase + getDriveConversations: getDriveConversationsUseCase, + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol() + ), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol() + ) ), isCellsStatePending: false, localAssetRepository: localAssetsRepository, diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift index f7f87c0b78a..3864ecd85e4 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift @@ -85,7 +85,13 @@ final class FilesViewModelTests { deletePublicLink: WireDriveDeletePublicLinkUseCase(nodesAPI: nodesApi), updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesApi), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesApi), - getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesApi) + getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesApi), + makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ) ), isCellsStatePending: false, localAssetRepository: localAssetRepository, @@ -98,7 +104,7 @@ final class FilesViewModelTests { localAssetRepository.assetNodeID_MockValue = .fixture() localAssetRepository .refreshAssetMetadataNodeID_MockValue = (WireDriveNode.fixture(), WireDriveLocalAsset.fixture()) - localAssetRepository.downloadAssetNodeID_MockMethod = { _ in } + localAssetRepository.downloadAssetNodeIDIsAvailableOffline_MockMethod = { _, _ in } sut.$state.dropFirst().sink { [weak self] state in self?.itemsUpdates.append(state.items) @@ -543,7 +549,7 @@ final class FilesViewModelTests { localAssetRepository.assetNodeID_MockMethod = { nodeID in assets[nodeID] } - localAssetRepository.downloadAssetNodeID_MockMethod = { nodeID in + localAssetRepository.downloadAssetNodeIDIsAvailableOffline_MockMethod = { nodeID, _ in assets[nodeID] = WireDriveLocalAsset.fixture(downloadState: .downloaded(cacheKey: "some-key")) } fileCache.fileURLForKey_MockValue = URL(fileURLWithPath: "/foo") diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift index 6ab150ca786..51e49651216 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift @@ -45,6 +45,8 @@ final class FilesViewTests: XCTestCase { private var updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase! private var updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase! private var driveConversationsUseCase: WireDriveGetConversationsUseCase! + private var makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase! + private var removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase! private let record: Bool? = nil @@ -102,6 +104,12 @@ final class FilesViewTests: XCTestCase { deletePublicLink = WireDriveDeletePublicLinkUseCase(nodesAPI: nodesApi) updatePublicLinkExpiration = WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesApi) updatePublicLinkPassword = WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesApi) + makeAssetAvailableOfflineUseCase = WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetsRepository + ) + removeAssetAvailableOfflineUseCase = WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetsRepository + ) } @MainActor @@ -430,6 +438,8 @@ final class FilesViewTests: XCTestCase { updatePublicLinkExpiration: updatePublicLinkExpiration, updatePublicLinkPassword: updatePublicLinkPassword, getDriveConversations: driveConversationsUseCase, + makeAssetAvailableOfflineUseCase: makeAssetAvailableOfflineUseCase, + removeAssetAvailableOfflineUseCase: removeAssetAvailableOfflineUseCase ), isCellsStatePending: false, localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol(), @@ -461,6 +471,7 @@ private extension FilesItemViewModel { let localAssetRepository = MockWireDriveLocalAssetRepositoryProtocol() localAssetRepository.observeAssetNodeID_MockValue = CurrentValueSubject(asset) .eraseToAnyPublisher() + localAssetRepository.assetNodeID_MockValue = WireDriveLocalAsset.fixture() return FilesItemViewModel( item: item, From c4effdcd4a944757b8b224a5b8f1c2f881952464 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 2 Apr 2026 10:12:05 +0200 Subject: [PATCH 18/62] moved the DEBUG button to the end of the list --- .../Files/Item/FilesViewItemView.swift | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 3b8370d0157..5af1bfb6391 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -250,14 +250,6 @@ struct FilesItemView: View { @ViewBuilder private func menuContent() -> some View { - #if DEBUG - Button { - viewModel.deleteAsset() - } label: { - Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") - } - #endif - menuItem(.primaryAction) { item in Button { viewModel.performAction(item) @@ -371,6 +363,21 @@ struct FilesItemView: View { label: { Label(Strings.Files.Item.Menu.delete, systemImage: "trash.fill") } ) } + + #if DEBUG + switch viewModel.fileTracker.state { + case .loaded: + Divider() + + Button { + viewModel.deleteAsset() + } label: { + Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") + } + default: + EmptyView() + } + #endif } @ViewBuilder From 1a8c631a5caf1eb8b600efa566f554f7ef5b923c Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 2 Apr 2026 10:54:20 +0200 Subject: [PATCH 19/62] removed the reference to bindNetworkConnection in a comment --- .../WireDrive/Components/Files/FilesViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index d9e2c7e7fa3..5c9745991cf 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -642,7 +642,7 @@ package final class FilesViewModel: ObservableObject { state = .error(isConnectionError: isNoInternetError) } else { if isNoInternetError { - // no-op, offline bar is dynamically shown/hidden on top of the list (see `bindNetworkConnection()`) + // no-op, offline bar is dynamically shown/hidden on top of the list } else { alert = .unknownError } From 12e912e1f70afda185ebcf7eaa2bfedd07fe2d24 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 2 Apr 2026 10:12:05 +0200 Subject: [PATCH 20/62] moved the DEBUG button to the end of the list --- .../Files/Item/FilesViewItemView.swift | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 3b8370d0157..5af1bfb6391 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -250,14 +250,6 @@ struct FilesItemView: View { @ViewBuilder private func menuContent() -> some View { - #if DEBUG - Button { - viewModel.deleteAsset() - } label: { - Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") - } - #endif - menuItem(.primaryAction) { item in Button { viewModel.performAction(item) @@ -371,6 +363,21 @@ struct FilesItemView: View { label: { Label(Strings.Files.Item.Menu.delete, systemImage: "trash.fill") } ) } + + #if DEBUG + switch viewModel.fileTracker.state { + case .loaded: + Divider() + + Button { + viewModel.deleteAsset() + } label: { + Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") + } + default: + EmptyView() + } + #endif } @ViewBuilder From edf6609e7013eb4d3c55507765cae3b40e3dc7ab Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 2 Apr 2026 11:11:59 +0200 Subject: [PATCH 21/62] add UTs --- .../Helpers/WireDriveLocalAsset+Fixture.swift | 3 +- ...akeAssetAvailableOfflineUseCaseTests.swift | 105 ++++++++++++++++++ ...oveAssetAvailableOfflineUseCaseTests.swift | 86 ++++++++++++++ .../WireDriveRestoreNodeUseCaseTests.swift | 2 + .../WireDriveLocalAssetRepositoryTests.swift | 6 +- 5 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveMakeAssetAvailableOfflineUseCaseTests.swift create mode 100644 WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift index 6bca35605cf..1ad800a3683 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift @@ -27,6 +27,7 @@ extension WireDriveLocalAsset { path: String = "path/to/file.txt", contentType: String? = "text/plain", size: UInt64? = 1234, + isAvailableOffline: Bool = false, downloadState: DownloadState = .pending ) -> WireDriveLocalAsset { WireDriveLocalAsset( @@ -38,7 +39,7 @@ extension WireDriveLocalAsset { conversationName: "Conversation 1", ownerName: "User 1", modified: nil, - isAvailableOffline: false, + isAvailableOffline: isAvailableOffline, downloadState: downloadState ) } diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveMakeAssetAvailableOfflineUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveMakeAssetAvailableOfflineUseCaseTests.swift new file mode 100644 index 00000000000..35a7d794a87 --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveMakeAssetAvailableOfflineUseCaseTests.swift @@ -0,0 +1,105 @@ +// +// 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 Foundation +import Testing + +import WireMessagingDomainSupport +@testable import WireMessagingData +@testable import WireMessagingDomain + +@MainActor +final class WireDriveMakeAssetAvailableOfflineUseCaseTests { + + private let nodesAPI = MockNodesAPIProtocol() + private let localAssetRepository: WireDriveLocalAssetRepository! + private let fileDownloader = MockFileDownloading() + private let fileCache = MockFileCache() + private let store = MockWireDriveLocalAssetStoreProtocol() + private var storeBacking: [UUID: WireDriveLocalAsset] = [:] + private let sut: WireDriveMakeAssetAvailableOfflineUseCase + + init() { + self.localAssetRepository = WireDriveLocalAssetRepository( + nodesAPI: nodesAPI, + fileDownloader: fileDownloader, + fileCache: fileCache, + store: store + ) + + self.sut = WireDriveMakeAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository, + ) + + fileCache.saveFileAtKey_MockMethod = { _, _ in } + fileCache.deleteFileForKey_MockMethod = { _ in } + + store.assetNodeID_MockMethod = { [weak self] nodeID in + self?.storeBacking[nodeID] + } + store.upsertAsset_MockMethod = { [weak self] asset in + self?.storeBacking[asset.nodeID] = asset + } + } + + @Test + func `It retrieves the asset locally and sets the available offline flag to true`() async throws { + // given + let asset = WireDriveLocalAsset.fixture( + isAvailableOffline: false, + downloadState: .downloaded(cacheKey: UUID.mockID1.uuidString) + ) + storeBacking[asset.nodeID] = asset + #expect(asset.isAvailableOffline == false) + + // when + try await sut.invoke(nodeID: asset.nodeID) + + // then + #expect(storeBacking[asset.nodeID]?.isAvailableOffline == true) + } + + @Test + func `It downloads and sets the available offline flag to true`() async throws { + // given + let nodeID = UUID() + let asset = WireDriveLocalAsset.fixture(nodeID: nodeID, isAvailableOffline: false) + + nodesAPI.getNodeNodeID_MockValue = .fixture( + uuid: nodeID, + eTag: "eTag", + downloadURL: URL(string: "https://wire.com")! + ) + let (progressStream, progressContinuation) = AsyncThrowingStream.makeStream(of: Double.self) + fileDownloader.downloadFrom_MockValue = (progress: progressStream, download: Task.fixture()) + + Task { + progressContinuation.yield(0.5) + progressContinuation.yield(1) + progressContinuation.finish() + } + + // when + try await sut.invoke(nodeID: nodeID) + + // then + #expect(fileDownloader.downloadFrom_Invocations.count == 1) + #expect(storeBacking[asset.nodeID]?.isAvailableOffline == true) + } + +} diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift new file mode 100644 index 00000000000..0ad1bfb2f81 --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift @@ -0,0 +1,86 @@ +// +// 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 Foundation +import Testing + +import WireMessagingDomainSupport +@testable import WireMessagingData +@testable import WireMessagingDomain + +@MainActor +final class WireDriveRemoveAssetAvailableOfflineUseCaseTests { + + private let nodesAPI = MockNodesAPIProtocol() + private let localAssetRepository: WireDriveLocalAssetRepository! + private let store = MockWireDriveLocalAssetStoreProtocol() + private var storeBacking: [UUID: WireDriveLocalAsset] = [:] + private let sut: WireDriveRemoveAssetAvailableOfflineUseCase + + init() { + self.localAssetRepository = WireDriveLocalAssetRepository( + nodesAPI: nodesAPI, + fileCache: MockFileCache(), + store: store + ) + + self.sut = WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository, + ) + + store.assetNodeID_MockMethod = { [weak self] nodeID in + self?.storeBacking[nodeID] + } + store.upsertAsset_MockMethod = { [weak self] asset in + self?.storeBacking[asset.nodeID] = asset + } + } + + @Test + func `It retrieves and sets the available offline flag to false`() async throws { + // given + let asset = WireDriveLocalAsset.fixture( + isAvailableOffline: true, + downloadState: .downloaded(cacheKey: UUID.mockID1.uuidString) + ) + storeBacking[asset.nodeID] = asset + #expect(asset.isAvailableOffline == true) + + // when + try sut.invoke(nodeID: asset.nodeID) + + // then + #expect(storeBacking[asset.nodeID]?.isAvailableOffline == false) + } + + @Test + func `It throws when asset is missing locally`() async throws { + // given + let asset = WireDriveLocalAsset.fixture( + isAvailableOffline: true, + downloadState: .downloaded(cacheKey: UUID.mockID1.uuidString) + ) + + // then + #expect(throws: WireDriveRemoveAssetAvailableOfflineUseCase.Failure.assetNotFound) { + // when + try sut.invoke(nodeID: asset.nodeID) + } + } + +} diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRestoreNodeUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRestoreNodeUseCaseTests.swift index 37a1bce91b8..009413b9943 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRestoreNodeUseCaseTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRestoreNodeUseCaseTests.swift @@ -37,6 +37,7 @@ final class WireDriveRestoreNodeUseCaseTests { ) } + @Test func `It invokes methods to restore version and updates asset locally`() async throws { // given nodeCache.setItemFor_MockMethod = { _, _ in } @@ -55,6 +56,7 @@ final class WireDriveRestoreNodeUseCaseTests { #expect(localAssetRepository.refreshAssetMetadataNodeID_Invocations.count == 1) } + @Test func `It fails restoring a version`() async throws { // given repository.restoreVersionNodeIDVersionID_MockError = NSError(domain: "any", code: 0) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift index e5632b0f77e..9b8a8d7234e 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift @@ -102,7 +102,7 @@ final class WireDriveLocalAssetRepositoryTests { nodesAPI.getNodeNodeID_MockValue = node // when - try await sut.refreshAssetMetadata(nodeID: nodeID) + _ = try await sut.refreshAssetMetadata(nodeID: nodeID) // then the store is updated with the new metadata #expect( @@ -167,7 +167,7 @@ final class WireDriveLocalAssetRepositoryTests { ) // when - try await sut.refreshAssetMetadata(nodeID: nodeID) + _ = try await sut.refreshAssetMetadata(nodeID: nodeID) // then the store is updated with the new metadata #expect( @@ -542,7 +542,7 @@ final class WireDriveLocalAssetRepositoryTests { // MARK: - Helper Extensions -private extension Task where Success == (URL, URLResponse), Failure == any Error { +extension Task where Success == (URL, URLResponse), Failure == any Error { static func fixture() -> Task { Task { From a7bf499cf6a5d222f0936084b54cac1fcb163b7d Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 2 Apr 2026 11:50:32 +0200 Subject: [PATCH 22/62] correct design for the offline bar (except for the color, which is a separate ticket) --- .../Localization/en.lproj/Localizable.strings | 1 + .../Components/Files/FilesContentView.swift | 1 - .../Files/FilesOfflineBarView.swift | 53 +++++++++++++------ 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings index b467bd0b706..cf782ba3d6f 100644 --- a/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings +++ b/WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings @@ -183,6 +183,7 @@ "conversation.wireCells.files.tapToCancelDownload" = "Tap to cancel loading"; "conversation.wireCells.files.downloadFailed" = "Unable to load, retry"; "conversation.wireCells.files.downloadingFile" = "Downloading file..."; +"conversation.wireCells.files.offlineModeHint" = "You can still access your offline files."; // MARK: - Recycle Bin "conversation.wireCells.recycleBin.navigationTitle" = "Recycle Bin"; diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 5ad5ff01798..da15e370513 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -239,7 +239,6 @@ private extension FilesContentView { var offlineBar: some View { FilesOfflineBarView() - .padding(.bottom, 4) .background(backgroundColor) .transition( .move(edge: .top) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift index b3b48d27758..c49312dbbe3 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift @@ -19,15 +19,24 @@ import SwiftUI struct FilesOfflineBarView: View { + @ScaledMetric private var scale: CGFloat = 1 + var body: some View { - VStack { - Text( - L10n.Localizable.General.NoInternet.title.uppercased() - ) + VStack(spacing: 8) { + bar() + hint() + } + .padding(.horizontal, 16) + .padding(.bottom, 4) + } + + @ViewBuilder private func bar() -> some View { + Text(L10n.Localizable.General.NoInternet.title.uppercased()) .font(for: .subline2) + .multilineTextAlignment(.center) .foregroundColor(.white) // TODO: [WPB-24475] use proper color .frame(maxWidth: .infinity) - .frame(height: 25) + .padding(4) .background { // TODO: [WPB-24475] use proper color Color( @@ -37,20 +46,30 @@ struct FilesOfflineBarView: View { opacity: 1 ) } - .cornerRadius(6) - - //TODO: Olga said this design will probably change. don't localise yet. - Label { - Text("You can only see downloaded files in the offline mode.") //TODO: localize - //.multilineTextAlignment(.center) - } icon: { - Image(systemName: "wifi.slash") - } - } - .padding(.horizontal, 16) + .cornerRadius(6 * scale) + } + + @ViewBuilder private func hint() -> some View { + Text(L10n.Localizable.Conversation.WireCells.Files.offlineModeHint) + .font(for: .subline1) + .multilineTextAlignment(.center) } } #Preview { - FilesOfflineBarView() + VStack(spacing: 0) { + Text("content above the bar") + .opacity(0.5) + .padding() + + Divider() + + FilesOfflineBarView() + + Divider() + + Text("content below the bar") + .opacity(0.5) + .padding() + } } From 6316025eeb9f8e2431a3b0a7eb172d21fa4015a4 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 2 Apr 2026 12:03:47 +0200 Subject: [PATCH 23/62] add condition checks to display actions, remove available offline flag when deleting to the recycle bin --- .../WireDrive/Components/Files/Item/FilesItemViewModel.swift | 5 ++++- .../WireDrive/Components/Files/Item/FilesViewItemView.swift | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index f88ffa2e417..3d9c35b8b66 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -217,6 +217,9 @@ final class FilesItemViewModel: ObservableObject { if permanently { await onItemAction(.deletePermanently, item) } else { + if let asset, asset.isAvailableOffline { + await onItemAction(.removeAvailableOffline, item) + } await onItemAction(.deleteToRecycleBin, item) } } @@ -375,7 +378,7 @@ final class FilesItemViewModel: ObservableObject { actions.insert(.shareLink) } - if !isEditable { + if !isEditable, !isInRecycleBin, item.kind == .file { actions.insert(isAvailableOffline ? .removeAvailableOffline : .makeAvailableOffline) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift index 5af1bfb6391..59668f5c08f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift @@ -368,8 +368,8 @@ struct FilesItemView: View { switch viewModel.fileTracker.state { case .loaded: Divider() - - Button { + + Button(role: .destructive) { viewModel.deleteAsset() } label: { Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") From dee0dd75c20c5db85b97480958d84a4404a53b51 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 2 Apr 2026 12:20:38 +0200 Subject: [PATCH 24/62] reverse asset deletion code order (first delete asset locally then in-memory objects) --- .../WireDrive/WireDriveLocalAssetStore.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index 53530fe1e95..10c0ba2ce67 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -84,11 +84,6 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { } package func deleteAssets(nodeIDs: [UUID]) async throws { - for nodeID in nodeIDs { - assets[nodeID] = nil - updates.send((nodeID, nil)) - } - let context = contextProvider.newBackgroundContext() try await Task.detached { try await context.perform { @@ -100,6 +95,11 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { try context.save() } }.value + + for nodeID in nodeIDs { + assets[nodeID] = nil + updates.send((nodeID, nil)) + } } // MARK: Helpers From edaa8fdb7d99c1922e4c6e437c955d51c4fd3095 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 2 Apr 2026 15:07:57 +0200 Subject: [PATCH 25/62] fetching offline assets in offline mode --- .../WireMessagingFactory.swift | 11 ++- .../WireDriveLocalAssetRepository.swift | 10 ++ .../WireDrive/WireDriveLocalAssetStore.swift | 4 + ...ireDriveLocalAssetRepositoryProtocol.swift | 8 ++ .../WireDriveLocalAssetStoreProtocol.swift | 3 + ...veFetchOfflineAvailableAssetsUseCase.swift | 33 +++++++ .../Files/FilesPreviewHelpers.swift | 20 +++- .../Components/Files/FilesViewContainer.swift | 11 ++- .../Components/Files/FilesViewModel.swift | 96 +++++++++++-------- .../Files/RecycleBinContainer.swift | 11 ++- 10 files changed, 151 insertions(+), 56 deletions(-) create mode 100644 WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index 0dad138cecb..be5f3ab0ed8 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -198,7 +198,7 @@ public extension WireMessagingFactory { ), updateTags: WireDriveUpdateTagsUseCase(nodesAPI: nodesAPI), getTagSuggestions: WireDriveGetTagSuggestionsUseCase(nodesAPI: nodesAPI), - createFileUseCase: WireDriveCreateFileUseCase(nodesRepository: nodesAPI), + createFile: WireDriveCreateFileUseCase(nodesRepository: nodesAPI), fetchNodeVersions: WireDriveFetchNodeVersionsUseCase(repository: nodesAPI), restoreNodeVersion: WireDriveRestoreNodeVersionUseCase( repository: nodesAPI, @@ -206,7 +206,7 @@ public extension WireMessagingFactory { nodeCache: nodeCache ), getEditingURL: WireDriveGetEditingURLUseCase(editingURLRepository: nodesAPI), - getAssetUseCase: WireDriveGetAssetUseCase( + getAsset: WireDriveGetAssetUseCase( localAssetRepository: localAssetRepository, fileCache: fileCache ), @@ -216,10 +216,13 @@ public extension WireMessagingFactory { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase( localAssetRepository: localAssetRepository ) ), diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index 473e9e699de..b26700eefe3 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -63,6 +63,16 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository package func asset(nodeID: UUID) throws -> WireMessagingDomain.WireDriveLocalAsset? { try store.asset(nodeID: nodeID) } + + @MainActor + package func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { + try store.allAssets() + } + + @MainActor + package func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { + try store.allAssets().filter { $0.isAvailableOffline } + } /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. /// diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index 10c0ba2ce67..80ce36f06a0 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -45,6 +45,10 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { return nil } } + + package func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { + assets.values.map { $0 } + } package func upsertAsset(_ asset: WireMessagingDomain.WireDriveLocalAsset) throws { guard assets[asset.nodeID] != asset else { return } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index ff6a310378b..7b58f84ac06 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -27,6 +27,14 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { /// fetched. @MainActor func asset(nodeID: UUID) throws -> WireDriveLocalAsset? + + /// Returns all local assets. + @MainActor + func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] + + /// Returns all offline available local assets. + @MainActor + func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. /// diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift index 87f0059e1bd..e729d6c74a3 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift @@ -25,6 +25,9 @@ package protocol WireDriveLocalAssetStoreProtocol: Sendable { /// Returns the `WireDriveLocalAsset` for a given `nodeID` or `nil`. func asset(nodeID: UUID) throws -> WireDriveLocalAsset? + + /// Returns all stored local assets. + func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] /// Updates an existing `WireDriveLocalAsset` or creates a new one if none exists with its `nodeID`. func upsertAsset(_ asset: WireDriveLocalAsset) throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift new file mode 100644 index 00000000000..f42e4172b89 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift @@ -0,0 +1,33 @@ +// +// 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/. +// + +package import Foundation + +@MainActor +package struct WireDriveFetchOfflineAvailableAssetsUseCase { + + private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol + + package init(localAssetRepository: any WireDriveLocalAssetRepositoryProtocol) { + self.localAssetRepository = localAssetRepository + } + + package func invoke() throws -> [WireDriveLocalAsset] { + try localAssetRepository.offlineAssets() + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 2822a6f08f3..805889a8843 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -63,7 +63,7 @@ extension FilesViewModel { getTagSuggestions: WireDriveGetTagSuggestionsUseCase( nodesAPI: previewTagsApi() ), - createFileUseCase: WireDriveCreateFileUseCase( + createFile: WireDriveCreateFileUseCase( nodesRepository: previewNodesRepository() ), fetchNodeVersions: WireDriveFetchNodeVersionsUseCase( @@ -77,7 +77,7 @@ extension FilesViewModel { getEditingURL: WireDriveGetEditingURLUseCase( editingURLRepository: previewEditingURLRepository() ), - getAssetUseCase: WireDriveGetAssetUseCase( + getAsset: WireDriveGetAssetUseCase( localAssetRepository: localAssetRepository, fileCache: cache ), getPublicLinkData: WireDriveGetPublicLinkDataUseCase( @@ -98,10 +98,13 @@ extension FilesViewModel { getDriveConversations: WireDriveGetConversationsUseCase( nodesAPI: previewConversationsApi() ), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase( localAssetRepository: localAssetRepository ) ), @@ -319,13 +322,20 @@ private func mockFileCache() -> any FileCache { } private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryProtocol, @unchecked Sendable { - var failIndex = 0 var publishers: [UUID: CurrentValueSubject] = [:] func asset(nodeID: UUID) throws -> WireMessagingDomain.WireDriveLocalAsset? { publishers[nodeID]?.value } + + func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { + publishers.values.compactMap { $0.value } + } + + func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { + publishers.values.compactMap { $0.value }.filter { $0.isAvailableOffline } + } func refreshAssetMetadata( nodeID: UUID diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index dd27f377fff..d3d7f370789 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -137,7 +137,7 @@ package struct FilesViewContainer: View { ), updateTags: WireDriveUpdateTagsUseCase(nodesAPI: nodesAPI), getTagSuggestions: WireDriveGetTagSuggestionsUseCase(nodesAPI: nodesAPI), - createFileUseCase: WireDriveCreateFileUseCase(nodesRepository: nodesAPI), + createFile: WireDriveCreateFileUseCase(nodesRepository: nodesAPI), fetchNodeVersions: WireDriveFetchNodeVersionsUseCase(repository: nodesAPI), restoreNodeVersion: WireDriveRestoreNodeVersionUseCase( repository: nodesAPI, @@ -145,7 +145,7 @@ package struct FilesViewContainer: View { nodeCache: nodeCache ), getEditingURL: WireDriveGetEditingURLUseCase(editingURLRepository: nodesAPI), - getAssetUseCase: WireDriveGetAssetUseCase( + getAsset: WireDriveGetAssetUseCase( localAssetRepository: localAssetRepository, fileCache: fileCache ), @@ -155,10 +155,13 @@ package struct FilesViewContainer: View { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase( localAssetRepository: localAssetRepository ) ), diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 5c9745991cf..0bc002e4dfa 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -163,19 +163,20 @@ package final class FilesViewModel: ObservableObject { renameNode: any WireDriveRenameNodeUseCaseProtocol, updateTags: any WireDriveUpdateTagsUseCaseProtocol, getTagSuggestions: any WireDriveGetTagSuggestionsUseCaseProtocol, - createFileUseCase: any WireDriveCreateFileUseCaseProtocol, + createFile: any WireDriveCreateFileUseCaseProtocol, fetchNodeVersions: any WireDriveFetchNodeVersionsUseCaseProtocol, restoreNodeVersion: any WireDriveRestoreNodeVersionUseCaseProtocol, getEditingURL: WireDriveGetEditingURLUseCase, - getAssetUseCase: WireDriveGetAssetUseCase, + getAsset: WireDriveGetAssetUseCase, getPublicLinkData: any WireDriveGetPublicLinkDataUseCaseProtocol, createPublicLink: WireDriveCreatePublicLinkUseCase, deletePublicLink: WireDriveDeletePublicLinkUseCase, updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase, updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase, getDriveConversations: any WireDriveGetConversationsUseCaseProtocol, - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase, - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase, + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase, + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase ) { self.fetchNodes = fetchNodes @@ -184,19 +185,20 @@ package final class FilesViewModel: ObservableObject { self.renameNode = renameNode self.updateTags = updateTags self.getTagSuggestions = getTagSuggestions - self.createFileUseCase = createFileUseCase + self.createFile = createFile self.fetchNodeVersions = fetchNodeVersions self.restoreNodeVersion = restoreNodeVersion self.getEditingURL = getEditingURL - self.getAssetUseCase = getAssetUseCase + self.getAsset = getAsset self.getPublicLinkData = getPublicLinkData self.createPublicLink = createPublicLink self.deletePublicLink = deletePublicLink self.updatePublicLinkExpiration = updatePublicLinkExpiration self.updatePublicLinkPassword = updatePublicLinkPassword self.getDriveConversations = getDriveConversations - self.makeAssetAvailableOfflineUseCase = makeAssetAvailableOfflineUseCase - self.removeAssetAvailableOfflineUseCase = removeAssetAvailableOfflineUseCase + self.makeAssetAvailableOffline = makeAssetAvailableOffline + self.removeAssetAvailableOffline = removeAssetAvailableOffline + self.getOfflineAvailableAssets = getOfflineAvailableAssets } let fetchNodes: WireDriveFetchNodesPageUseCase @@ -205,19 +207,20 @@ package final class FilesViewModel: ObservableObject { let renameNode: any WireDriveRenameNodeUseCaseProtocol let updateTags: any WireDriveUpdateTagsUseCaseProtocol let getTagSuggestions: any WireDriveGetTagSuggestionsUseCaseProtocol - let createFileUseCase: any WireDriveCreateFileUseCaseProtocol + let createFile: any WireDriveCreateFileUseCaseProtocol let fetchNodeVersions: any WireDriveFetchNodeVersionsUseCaseProtocol let restoreNodeVersion: any WireDriveRestoreNodeVersionUseCaseProtocol let getEditingURL: WireDriveGetEditingURLUseCase - let getAssetUseCase: WireDriveGetAssetUseCase + let getAsset: WireDriveGetAssetUseCase let getPublicLinkData: any WireDriveGetPublicLinkDataUseCaseProtocol let createPublicLink: WireDriveCreatePublicLinkUseCase let deletePublicLink: WireDriveDeletePublicLinkUseCase let updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase let updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase let getDriveConversations: any WireDriveGetConversationsUseCaseProtocol - let makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase - let removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase + let makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase + let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase + let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase } private let setNavigation: ([FilesViewItem]) -> Void @@ -467,7 +470,7 @@ package final class FilesViewModel: ObservableObject { nodesRepository: nodesRepository, localAssetRepository: assetRepository, moveNodeUseCase: WireDriveMoveNodeUseCase(nodesRepository: nodesRepository), - createFileUseCase: useCases.createFileUseCase + createFileUseCase: useCases.createFile ) ) } @@ -554,15 +557,15 @@ package final class FilesViewModel: ObservableObject { precondition(item.kind == .file) do { - let downloadState = try await useCases.getAssetUseCase.downloadState(nodeID: item.id) ?? .pending + let downloadState = try await useCases.getAsset.downloadState(nodeID: item.id) ?? .pending switch downloadState { case .pending, .failed: - _ = try await useCases.getAssetUseCase.invoke(nodeID: item.id, eTag: item.eTag) + _ = try await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) case .downloaded: - let url = try await useCases.getAssetUseCase.invoke(nodeID: item.id, eTag: item.eTag) + let url = try await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) viewingURL = url case .downloading: - await useCases.getAssetUseCase.cancelDownload(nodeID: item.id) + await useCases.getAsset.cancelDownload(nodeID: item.id) } } catch is CancellationError { // Cancelled by the user, ignore. @@ -582,7 +585,7 @@ package final class FilesViewModel: ObservableObject { let viewModel = CreateFileViewModel( creationTarget: target, path: path, - createFileUseCase: useCases.createFileUseCase + createFileUseCase: useCases.createFile ) // to know whether we need to reload nodes. @@ -653,26 +656,37 @@ package final class FilesViewModel: ObservableObject { } private func loadOfflineFiles() async { - //TODO: fetch the offline files - state = .received( - items: [ - .init( - id: UUID(), - eTag: "eTag", + do { + let offlineAssets = try useCases.getOfflineAvailableAssets.invoke() + + let items: [FilesViewItem] = offlineAssets.map { asset in + let fileUrl = URL(fileURLWithPath: asset.path) + let fileName = fileUrl.lastPathComponent + let fileExtension = fileUrl.pathExtension + let fileType = UTType(filenameExtension: fileExtension) + + return .init( + id: asset.nodeID, + eTag: asset.eTag, kind: .file, - name: "offline file dummy", - filePath: "filePath", - ownedBy: nil, - modifiedAt: nil, - icon: .code, - tags: ["offline file"], - isEditable: false, - publicLinkID: nil, - conversationName: nil, - size: nil + name: fileName, + filePath: asset.path, + ownedBy: asset.ownerName, + modifiedAt: asset.modified, + icon: .make(type: fileType, fileExtension: fileExtension), + tags: [], // change later if we want to show tags in offline mode. + isEditable: false, // change later if we want to edit files in offline mode. + publicLinkID: nil, // change later if we want to be able to share a public link in offline mode. + conversationName: asset.conversationName, + size: asset.size ) - ] - ) + } + + state = .received(items: items) + } catch { + alert = .unknownError + WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") + } hasMore = false } @@ -853,7 +867,7 @@ package final class FilesViewModel: ObservableObject { eTag: item.eTag, fetchNodeVersionsUseCase: useCases.fetchNodeVersions, restoreNodeVersionUseCase: useCases.restoreNodeVersion, - getAssetUseCase: useCases.getAssetUseCase, + getAssetUseCase: useCases.getAsset, accentColorProvider: accentColorProvider ) @@ -888,7 +902,7 @@ package final class FilesViewModel: ObservableObject { private func makeAssetAvailableOffline(item: FilesViewItem) { Task { do { - try await useCases.makeAssetAvailableOfflineUseCase.invoke(nodeID: item.id) + try await useCases.makeAssetAvailableOffline.invoke(nodeID: item.id) } catch { WireLogger.wireDrive.error("Failed to make asset available offline: \(String(describing: error))") } @@ -898,7 +912,11 @@ package final class FilesViewModel: ObservableObject { private func removeAssetAvailableOffline(item: FilesViewItem) { Task { do { - try useCases.removeAssetAvailableOfflineUseCase.invoke(nodeID: item.id) + try useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) + + if isOffline { + await reload() + } } catch { WireLogger.wireDrive .error("Failed to remove asset from available offline: \(String(describing: error))") diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index a8571ac95e8..755cd1a1650 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift @@ -97,7 +97,7 @@ package struct RecycleBinContainer: View { ), updateTags: WireDriveUpdateTagsUseCase(nodesAPI: nodesAPI), getTagSuggestions: WireDriveGetTagSuggestionsUseCase(nodesAPI: nodesAPI), - createFileUseCase: WireDriveCreateFileUseCase(nodesRepository: nodesAPI), + createFile: WireDriveCreateFileUseCase(nodesRepository: nodesAPI), fetchNodeVersions: WireDriveFetchNodeVersionsUseCase(repository: nodesRepository), restoreNodeVersion: WireDriveRestoreNodeVersionUseCase( repository: nodesRepository, @@ -105,7 +105,7 @@ package struct RecycleBinContainer: View { nodeCache: nodeCache ), getEditingURL: WireDriveGetEditingURLUseCase(editingURLRepository: nodesAPI), - getAssetUseCase: WireDriveGetAssetUseCase( + getAsset: WireDriveGetAssetUseCase( localAssetRepository: localAssetRepository, fileCache: fileCache ), @@ -115,10 +115,13 @@ package struct RecycleBinContainer: View { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase( localAssetRepository: localAssetRepository ) ), From 0c034a574a9e048f60413c190ba4fc7be007fd1e Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 2 Apr 2026 17:28:47 +0200 Subject: [PATCH 26/62] added offline bar to empty state --- .../Components/Files/FilesContentView.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index da15e370513..ad786845858 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -166,12 +166,22 @@ private extension FilesContentView { @ViewBuilder private var listBackgroundView: some View { switch viewModel.state { case let .received(items) where items.isEmpty: - FilesInfoView( - info: .noFilesFound( - scope: viewModel.isRecycleBin ? .recycleBin : isBrowsing ? .allConversations : .oneConversation, - isSearch: !viewModel.searchText.isEmpty || viewModel.filtersSelection != .empty + VStack(spacing: 0) { + if viewModel.isOffline { + FilesOfflineBarView() + } + + Spacer() + + FilesInfoView( + info: .noFilesFound( + scope: viewModel.isRecycleBin ? .recycleBin : isBrowsing ? .allConversations : .oneConversation, + isSearch: !viewModel.searchText.isEmpty || viewModel.filtersSelection != .empty + ) ) - ) + + Spacer() + } case .pending: FilesInfoView(info: .preparingFiles) default: From 72ac7aa8ccb679fe143c1efcb175f8d4054572da Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Fri, 3 Apr 2026 11:00:54 +0200 Subject: [PATCH 27/62] fetch offline assets from db if don't already exist in memory, fix incorrect UI transition when connection status is not resolved yet. --- .../WireDriveLocalAssetRepository.swift | 11 ++--- .../WireDrive/WireDriveLocalAssetStore.swift | 48 +++++++++++++++++-- ...ireDriveLocalAssetRepositoryProtocol.swift | 8 +--- .../WireDriveLocalAssetStoreProtocol.swift | 6 +-- ...veFetchOfflineAvailableAssetsUseCase.swift | 6 +-- .../Components/Files/FilesViewModel.swift | 30 ++++++++---- 6 files changed, 77 insertions(+), 32 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index b26700eefe3..8d25f29d961 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -63,15 +63,10 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository package func asset(nodeID: UUID) throws -> WireMessagingDomain.WireDriveLocalAsset? { try store.asset(nodeID: nodeID) } - - @MainActor - package func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { - try store.allAssets() - } - + @MainActor - package func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { - try store.allAssets().filter { $0.isAvailableOffline } + package func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] { + try await store.offlineAssets() } /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index 80ce36f06a0..dd64263a3fb 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -45,9 +45,37 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { return nil } } - - package func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { - assets.values.map { $0 } + + package func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] { + if !assets.isEmpty { + return assets.values.filter(\.isAvailableOffline) + } else { + let context = contextProvider.newBackgroundContext() + return try await context.perform { + let managedAssets = try context.fetchLocalOfflineAssets() + + return managedAssets.compactMap { managed in + let cacheKey = WireDriveLocalAsset.cacheKey( + nodeID: managed.nodeID, + eTag: managed.eTag, + path: managed.path + ) + + return WireDriveLocalAsset( + nodeID: managed.nodeID, + eTag: managed.eTag, + path: managed.path, + contentType: managed.contentType, + size: managed.size >= 0 ? UInt64(managed.size) : nil, + conversationName: managed.conversationName, + ownerName: managed.ownerName, + modified: managed.modified, + isAvailableOffline: managed.isAvailableOffline, + downloadState: .downloaded(cacheKey: cacheKey) + ) + } + } + } } package func upsertAsset(_ asset: WireMessagingDomain.WireDriveLocalAsset) throws { @@ -171,4 +199,18 @@ private extension NSManagedObjectContext { return try fetch(request).first } + func fetchLocalOfflineAssets() throws -> [ManagedLocalAsset] { + let request = ManagedLocalAsset.fetchRequest() as! NSFetchRequest + let isAvailableOfflinePredicate = NSPredicate(format: "isAvailableOffline == YES") + let isDownloadedPredicate = + NSPredicate(format: "isDownloaded == YES") // assets available offline should always be downloaded, this is + // just a safety measure. + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + isAvailableOfflinePredicate, + isDownloadedPredicate + ]) + request.predicate = predicate + return try fetch(request) + } + } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index 7b58f84ac06..c97a0cced4f 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -27,14 +27,10 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { /// fetched. @MainActor func asset(nodeID: UUID) throws -> WireDriveLocalAsset? - - /// Returns all local assets. - @MainActor - func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] - + /// Returns all offline available local assets. @MainActor - func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] + func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. /// diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift index e729d6c74a3..738f4844744 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift @@ -25,9 +25,9 @@ package protocol WireDriveLocalAssetStoreProtocol: Sendable { /// Returns the `WireDriveLocalAsset` for a given `nodeID` or `nil`. func asset(nodeID: UUID) throws -> WireDriveLocalAsset? - - /// Returns all stored local assets. - func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] + + /// Returns all stored local offline assets. + func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] /// Updates an existing `WireDriveLocalAsset` or creates a new one if none exists with its `nodeID`. func upsertAsset(_ asset: WireDriveLocalAsset) throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift index f42e4172b89..baefeaf0a4d 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift @@ -16,7 +16,7 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -package import Foundation +import Foundation @MainActor package struct WireDriveFetchOfflineAvailableAssetsUseCase { @@ -27,7 +27,7 @@ package struct WireDriveFetchOfflineAvailableAssetsUseCase { self.localAssetRepository = localAssetRepository } - package func invoke() throws -> [WireDriveLocalAsset] { - try localAssetRepository.offlineAssets() + package func invoke() async throws -> [WireDriveLocalAsset] { + try await localAssetRepository.offlineAssets() } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 0bc002e4dfa..e6f955a92b3 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -243,7 +243,7 @@ package final class FilesViewModel: ObservableObject { guard !isOffline else { return false } - + return switch state { case .loading, .received: true @@ -268,7 +268,7 @@ package final class FilesViewModel: ObservableObject { var isLoading: Bool { loadMoreTask != nil } - + var isOffline: Bool { networkMonitor.currentStatus == .disconnected } @@ -290,7 +290,7 @@ package final class FilesViewModel: ObservableObject { @Published var templates: [WireDriveFileTemplate] = [] @Published var conversations: [WireDriveConversation] = [] @Published var filtersSelection: FilesFilteringViewModel.FiltersSelection = .empty - + @Published private var networkMonitor = NetworkMonitor.shared private var selfUserID: String? { @@ -328,6 +328,16 @@ package final class FilesViewModel: ObservableObject { self.triggerReload = triggerReload self.accentColorProvider = accentColorProvider + // Waits for the first non-nil network status to avoid incorrect UI transitions, + // then triggers a single reload with a resolved connection state. + networkMonitor.$currentStatus + .filter { $0 != nil } + .first() + .sink { [weak self] status in + guard status != nil else { return } + Task { await self?.reload() } + }.store(in: &subscriptions) + bindSearch() fetchTemplates() fetchConversations() @@ -391,6 +401,8 @@ package final class FilesViewModel: ObservableObject { /// When `refreshing` is `true`, the current state is preserved since loading is managed by the system. func reload(refreshing: Bool = false) async { + guard networkMonitor.currentStatus != nil else { return } + cancelLoad() state = refreshing ? state : .loading hasMore = !refreshing @@ -622,7 +634,7 @@ package final class FilesViewModel: ObservableObject { await loadOnlineFiles(refreshing: refreshing) } } - + private func loadOnlineFiles(refreshing: Bool) async { guard loadMoreTask == nil else { return } @@ -657,14 +669,14 @@ package final class FilesViewModel: ObservableObject { private func loadOfflineFiles() async { do { - let offlineAssets = try useCases.getOfflineAvailableAssets.invoke() - + let offlineAssets = try await useCases.getOfflineAvailableAssets.invoke() + let items: [FilesViewItem] = offlineAssets.map { asset in let fileUrl = URL(fileURLWithPath: asset.path) let fileName = fileUrl.lastPathComponent let fileExtension = fileUrl.pathExtension let fileType = UTType(filenameExtension: fileExtension) - + return .init( id: asset.nodeID, eTag: asset.eTag, @@ -681,7 +693,7 @@ package final class FilesViewModel: ObservableObject { size: asset.size ) } - + state = .received(items: items) } catch { alert = .unknownError @@ -913,7 +925,7 @@ package final class FilesViewModel: ObservableObject { Task { do { try useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) - + if isOffline { await reload() } From 9370d9050f3599e6c99002134ba0c31c61cb8d23 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Fri, 3 Apr 2026 11:01:16 +0200 Subject: [PATCH 28/62] lint and format --- .../Components/Common/NetworkMonitor.swift | 6 +++--- .../Components/Files/FilesContentView.swift | 10 +++++----- .../Components/Files/FilesOfflineBarView.swift | 18 ++++++++++-------- .../Components/Files/FilesPreviewHelpers.swift | 8 ++++---- .../WireDrive/Components/Files/FilesView.swift | 2 +- .../Files/Item/FilesItemViewModel.swift | 10 +++++----- 6 files changed, 28 insertions(+), 26 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift index 135b2073c29..bf0874cf5e0 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift @@ -23,7 +23,7 @@ import Observation /// Provides observable changes in internet connection. /// Conforms to both, `Observable` and `ObservableObject` to support ViewModels with the old and the new system. @MainActor -final class NetworkMonitor: Sendable, Observable, ObservableObject { +final class NetworkMonitor: Observable, ObservableObject { enum NetworkStatus { case connected @@ -45,10 +45,10 @@ final class NetworkMonitor: Sendable, Observable, ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] status in guard let self else { return } - currentStatus = status + self.currentStatus = status } .store(in: &cancellables) - + monitor.pathUpdateHandler = { [weak self] path in guard let self else { return } Task { @MainActor in diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index ad786845858..6468c45e9bb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -62,11 +62,11 @@ package struct FilesContentView: View { .opacity(isFilterBarPresented ? 1 : 0) .frame(height: isFilterBarPresented ? nil : 0) .padding(.bottom, isFilterBarPresented ? 15 : 0) - + FilesSortingView(viewModel: viewModel.makeFilesSortingViewModel()) } .padding(.top, 4) - + Spacer() } @@ -170,16 +170,16 @@ private extension FilesContentView { if viewModel.isOffline { FilesOfflineBarView() } - + Spacer() - + FilesInfoView( info: .noFilesFound( scope: viewModel.isRecycleBin ? .recycleBin : isBrowsing ? .allConversations : .oneConversation, isSearch: !viewModel.searchText.isEmpty || viewModel.filtersSelection != .empty ) ) - + Spacer() } case .pending: diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift index c49312dbbe3..daa9ee749d0 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesOfflineBarView.swift @@ -20,7 +20,7 @@ import SwiftUI struct FilesOfflineBarView: View { @ScaledMetric private var scale: CGFloat = 1 - + var body: some View { VStack(spacing: 8) { bar() @@ -29,8 +29,9 @@ struct FilesOfflineBarView: View { .padding(.horizontal, 16) .padding(.bottom, 4) } - - @ViewBuilder private func bar() -> some View { + + @ViewBuilder + private func bar() -> some View { Text(L10n.Localizable.General.NoInternet.title.uppercased()) .font(for: .subline2) .multilineTextAlignment(.center) @@ -48,8 +49,9 @@ struct FilesOfflineBarView: View { } .cornerRadius(6 * scale) } - - @ViewBuilder private func hint() -> some View { + + @ViewBuilder + private func hint() -> some View { Text(L10n.Localizable.Conversation.WireCells.Files.offlineModeHint) .font(for: .subline1) .multilineTextAlignment(.center) @@ -63,11 +65,11 @@ struct FilesOfflineBarView: View { .padding() Divider() - + FilesOfflineBarView() - + Divider() - + Text("content below the bar") .opacity(0.5) .padding() diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 805889a8843..acbc4451ebb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -328,13 +328,13 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr func asset(nodeID: UUID) throws -> WireMessagingDomain.WireDriveLocalAsset? { publishers[nodeID]?.value } - + func allAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { - publishers.values.compactMap { $0.value } + publishers.values.compactMap(\.value) } - + func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { - publishers.values.compactMap { $0.value }.filter { $0.isAvailableOffline } + publishers.values.compactMap(\.value).filter(\.isAvailableOffline) } func refreshAssetMetadata( diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift index fb3ce36e25f..4d16b31c8e8 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift @@ -85,7 +85,7 @@ private extension FilesView { } } - if !viewModel.isRecycleBin && !viewModel.isOffline { + if !viewModel.isRecycleBin, !viewModel.isOffline { ToolbarItem(placement: .navigationBarTrailing) { moreActionsButton } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 274e6702d5b..c16776d3492 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -52,7 +52,7 @@ final class FilesItemViewModel: ObservableObject { } let onItemAction: (ItemAction, FilesViewItem) async -> Void - + @Published private var asset: WireDriveLocalAsset? @Published var fileTracker: WireDriveFileUITracker @Published var isPresentingDeleteFilePermanentlyConfirmation = false @@ -136,7 +136,7 @@ final class FilesItemViewModel: ObservableObject { } var isDownloadOptionAvailable: Bool { - guard item.kind == .file && !isOffline else { return false } + guard item.kind == .file, !isOffline else { return false } return switch fileTracker.state { case .loaded: @@ -366,7 +366,7 @@ final class FilesItemViewModel: ObservableObject { additionalTagsIndicator: formattedNumber ) } - + var isOffline: Bool { networkMonitor.currentStatus == .disconnected } @@ -376,7 +376,7 @@ final class FilesItemViewModel: ObservableObject { if !isInRecycleBin { actions.insert(.primaryAction) - + if !isOffline { actions.insert(.shareLink) } @@ -392,7 +392,7 @@ final class FilesItemViewModel: ObservableObject { } } - if !isBrowsing && !isOffline { + if !isBrowsing, !isOffline { if isInRecycleBin { actions.insert(.restore) actions.insert(.deletePermanently) From 2e00762e742b61639c4a687977b96e96facd27e8 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Fri, 3 Apr 2026 12:21:57 +0200 Subject: [PATCH 29/62] add logic to fetch only assets of a given conversation, introduced a (initial) setup method in FilesViewModel --- .../WireDriveLocalAssetRepository.swift | 4 +- .../WireDrive/WireDriveLocalAssetStore.swift | 66 ++++----- ...ireDriveLocalAssetRepositoryProtocol.swift | 4 +- .../WireDriveLocalAssetStoreProtocol.swift | 4 +- ...veFetchOfflineAvailableAssetsUseCase.swift | 4 +- .../Components/Files/FilesContentView.swift | 4 +- .../Files/FilesPreviewHelpers.swift | 2 +- .../Components/Files/FilesViewModel.swift | 131 +++++++++--------- 8 files changed, 115 insertions(+), 104 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index 8d25f29d961..c1fce5c755f 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -65,8 +65,8 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository } @MainActor - package func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] { - try await store.offlineAssets() + package func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] { + try await store.offlineAssets(conversationName: conversationName) } /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index dd64263a3fb..cc2171a849f 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -46,34 +46,30 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { } } - package func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] { - if !assets.isEmpty { - return assets.values.filter(\.isAvailableOffline) - } else { - let context = contextProvider.newBackgroundContext() - return try await context.perform { - let managedAssets = try context.fetchLocalOfflineAssets() - - return managedAssets.compactMap { managed in - let cacheKey = WireDriveLocalAsset.cacheKey( - nodeID: managed.nodeID, - eTag: managed.eTag, - path: managed.path - ) - - return WireDriveLocalAsset( - nodeID: managed.nodeID, - eTag: managed.eTag, - path: managed.path, - contentType: managed.contentType, - size: managed.size >= 0 ? UInt64(managed.size) : nil, - conversationName: managed.conversationName, - ownerName: managed.ownerName, - modified: managed.modified, - isAvailableOffline: managed.isAvailableOffline, - downloadState: .downloaded(cacheKey: cacheKey) - ) - } + package func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] { + let context = contextProvider.newBackgroundContext() + return try await context.perform { + let managedAssets = try context.fetchLocalOfflineAssets(conversationName: conversationName) + + return managedAssets.compactMap { managed in + let cacheKey = WireDriveLocalAsset.cacheKey( + nodeID: managed.nodeID, + eTag: managed.eTag, + path: managed.path + ) + + return WireDriveLocalAsset( + nodeID: managed.nodeID, + eTag: managed.eTag, + path: managed.path, + contentType: managed.contentType, + size: managed.size >= 0 ? UInt64(managed.size) : nil, + conversationName: managed.conversationName, + ownerName: managed.ownerName, + modified: managed.modified, + isAvailableOffline: managed.isAvailableOffline, + downloadState: .downloaded(cacheKey: cacheKey) + ) } } } @@ -199,17 +195,23 @@ private extension NSManagedObjectContext { return try fetch(request).first } - func fetchLocalOfflineAssets() throws -> [ManagedLocalAsset] { + func fetchLocalOfflineAssets(conversationName: String?) throws -> [ManagedLocalAsset] { let request = ManagedLocalAsset.fetchRequest() as! NSFetchRequest let isAvailableOfflinePredicate = NSPredicate(format: "isAvailableOffline == YES") let isDownloadedPredicate = NSPredicate(format: "isDownloaded == YES") // assets available offline should always be downloaded, this is // just a safety measure. - let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + + var predicates: [NSPredicate] = [ isAvailableOfflinePredicate, isDownloadedPredicate - ]) - request.predicate = predicate + ] + + if let conversationName { + predicates.append(NSPredicate(format: "conversationName == %@", conversationName)) + } + + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) return try fetch(request) } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index c97a0cced4f..a855352c45e 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -28,9 +28,9 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { @MainActor func asset(nodeID: UUID) throws -> WireDriveLocalAsset? - /// Returns all offline available local assets. + /// Returns offline available local assets for a given conversation or for all conversations if nil. @MainActor - func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] + func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. /// diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift index 738f4844744..73873fafda2 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift @@ -26,8 +26,8 @@ package protocol WireDriveLocalAssetStoreProtocol: Sendable { /// Returns the `WireDriveLocalAsset` for a given `nodeID` or `nil`. func asset(nodeID: UUID) throws -> WireDriveLocalAsset? - /// Returns all stored local offline assets. - func offlineAssets() async throws -> [WireMessagingDomain.WireDriveLocalAsset] + /// Returns offline available local assets for a given conversation or for all conversations if nil. + func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] /// Updates an existing `WireDriveLocalAsset` or creates a new one if none exists with its `nodeID`. func upsertAsset(_ asset: WireDriveLocalAsset) throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift index baefeaf0a4d..914b472c760 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift @@ -27,7 +27,7 @@ package struct WireDriveFetchOfflineAvailableAssetsUseCase { self.localAssetRepository = localAssetRepository } - package func invoke() async throws -> [WireDriveLocalAsset] { - try await localAssetRepository.offlineAssets() + package func invoke(conversationName: String?) async throws -> [WireDriveLocalAsset] { + try await localAssetRepository.offlineAssets(conversationName: conversationName) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 6468c45e9bb..6cc7ba5b2c1 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -98,7 +98,6 @@ package struct FilesContentView: View { .toolbarBackground(backgroundColor, for: .navigationBar) .toolbar { toolbarContent() } .if(viewModel.showSearchBar, transform: searchView(content:)) - .onAppear { reloadTask() } .onDisappear { isSearchFocused = false viewModel.resetFilters() @@ -124,6 +123,9 @@ package struct FilesContentView: View { await viewModel.reload(refreshing: true) } } + .task { + await viewModel.setup() + } } private var isFilterBarPresented: Bool { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index acbc4451ebb..bfd6e22bc02 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -333,7 +333,7 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr publishers.values.compactMap(\.value) } - func offlineAssets() throws -> [WireMessagingDomain.WireDriveLocalAsset] { + func offlineAssets(conversationName: String?) throws -> [WireMessagingDomain.WireDriveLocalAsset] { publishers.values.compactMap(\.value).filter(\.isAvailableOffline) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index e6f955a92b3..f05e33d0ab4 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -327,70 +327,14 @@ package final class FilesViewModel: ObservableObject { self.isRecycleBin = isRecycleBin self.triggerReload = triggerReload self.accentColorProvider = accentColorProvider + } - // Waits for the first non-nil network status to avoid incorrect UI transitions, - // then triggers a single reload with a resolved connection state. - networkMonitor.$currentStatus - .filter { $0 != nil } - .first() - .sink { [weak self] status in - guard status != nil else { return } - Task { await self?.reload() } - }.store(in: &subscriptions) - + func setup() async { + await fetchConversations() bindSearch() + bindConnectionStatusResolved() fetchTemplates() - fetchConversations() - } - - private func fetchTemplates() { - Task { - // TODO: [WPB-22926] Replace hard coded values with server values when GET/ templates endpoint ready. - // Do `templates = try await WireDriveFetchFileTemplatesUseCase.invoke()` - templates = [ - .init( - kind: .document, - editable: true, - label: "Microsoft Word", - id: "01-Microsoft Word.docx" - ), - .init( - kind: .spreadsheet, - editable: true, - label: "Microsoft Excel", - id: "02-Microsoft Excel.xlsx" - ), - .init( - kind: .presentation, - editable: true, - label: "Microsoft PowerPoint", - id: "03-Microsoft PowerPoint.pptx" - ) - ] - } - } - - private func fetchConversations() { - Task { - let allDriveConversations = await useCases.getDriveConversations.invoke() - - if let cellName { - self.conversations = allDriveConversations.filter { $0.id == cellName } - } else { - self.conversations = allDriveConversations - } - } - } - - private func bindSearch() { - $searchText - .removeDuplicates() - .dropFirst() - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - Task { await self?.reload() } - } - .store(in: &subscriptions) + Task { await reload() } } /// Reloads the items, clearing any previously loaded items. @@ -540,6 +484,67 @@ package final class FilesViewModel: ObservableObject { // MARK: - Private + /// Waits for the first non-nil network status to avoid incorrect UI transitions at launch. + private func bindConnectionStatusResolved() { + guard networkMonitor.currentStatus == nil else { return } + + networkMonitor.$currentStatus + .filter { $0 != nil } + .first() + .sink { [weak self] status in + guard status != nil else { return } + Task { await self?.reload() } + }.store(in: &subscriptions) + } + + private func fetchTemplates() { + Task { + // TODO: [WPB-22926] Replace hard coded values with server values when GET/ templates endpoint ready. + // Do `templates = try await WireDriveFetchFileTemplatesUseCase.invoke()` + templates = [ + .init( + kind: .document, + editable: true, + label: "Microsoft Word", + id: "01-Microsoft Word.docx" + ), + .init( + kind: .spreadsheet, + editable: true, + label: "Microsoft Excel", + id: "02-Microsoft Excel.xlsx" + ), + .init( + kind: .presentation, + editable: true, + label: "Microsoft PowerPoint", + id: "03-Microsoft PowerPoint.pptx" + ) + ] + } + } + + private func fetchConversations() async { + let allDriveConversations = await useCases.getDriveConversations.invoke() + + if let cellName { + conversations = allDriveConversations.filter { $0.id == cellName } + } else { + conversations = allDriveConversations + } + } + + private func bindSearch() { + $searchText + .removeDuplicates() + .dropFirst() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + Task { await self?.reload() } + } + .store(in: &subscriptions) + } + /// Navigates to the folder represented by the given item. private func openFolder(item: FilesViewItem) { precondition(item.kind == .folder) @@ -669,7 +674,9 @@ package final class FilesViewModel: ObservableObject { private func loadOfflineFiles() async { do { - let offlineAssets = try await useCases.getOfflineAvailableAssets.invoke() + let offlineAssets = try await useCases.getOfflineAvailableAssets.invoke( + conversationName: cellName != nil ? conversations.first?.name : nil + ) let items: [FilesViewItem] = offlineAssets.map { asset in let fileUrl = URL(fileURLWithPath: asset.path) From d20d86390641e3b5f340bd12b9be6b9883f772c0 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Tue, 7 Apr 2026 09:23:10 +0200 Subject: [PATCH 30/62] fix UTs compilation issue --- .../Components/Files/FilesBrowserViewTests.swift | 11 +++++++---- .../Components/Files/FilesViewModelTests.swift | 15 +++++++++------ .../UITests/Components/Files/FilesViewTests.swift | 14 ++++++++++---- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift index 9127d8da5c6..3f3c4abd6a2 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesBrowserViewTests.swift @@ -227,21 +227,24 @@ final class FilesBrowserViewTests: XCTestCase { renameNode: renameNodeUseCase, updateTags: updateTagsUseCase, getTagSuggestions: getTagSuggestionsUseCase, - createFileUseCase: createFileUseCase, + createFile: createFileUseCase, fetchNodeVersions: fetchNodeVersionsUseCase, restoreNodeVersion: restoreNodeVersionUseCase, getEditingURL: getEditingURLUseCase, - getAssetUseCase: getAssetUseCase, + getAsset: getAssetUseCase, getPublicLinkData: getPublicLinkData, createPublicLink: createPublicLink, deletePublicLink: deletePublicLink, updatePublicLinkExpiration: updatePublicLinkExpiration, updatePublicLinkPassword: updatePublicLinkPassword, getDriveConversations: getDriveConversationsUseCase, - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol() ), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol() + ), + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase( localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol() ) ), diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift index 3864ecd85e4..3de06c5eb17 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift @@ -68,7 +68,7 @@ final class FilesViewModelTests { ), updateTags: WireDriveUpdateTagsUseCase(nodesAPI: nodesApi), getTagSuggestions: WireDriveGetTagSuggestionsUseCase(nodesAPI: nodesApi), - createFileUseCase: WireDriveCreateFileUseCase(nodesRepository: nodesRepository), + createFile: WireDriveCreateFileUseCase(nodesRepository: nodesRepository), fetchNodeVersions: WireDriveFetchNodeVersionsUseCase(repository: nodesRepository), restoreNodeVersion: WireDriveRestoreNodeVersionUseCase( repository: nodesRepository, @@ -76,7 +76,7 @@ final class FilesViewModelTests { nodeCache: MockWireDriveNodeCacheProtocol() ), getEditingURL: WireDriveGetEditingURLUseCase(editingURLRepository: editingURLRepository), - getAssetUseCase: WireDriveGetAssetUseCase( + getAsset: WireDriveGetAssetUseCase( localAssetRepository: localAssetRepository, fileCache: fileCache ), @@ -86,10 +86,13 @@ final class FilesViewModelTests { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesApi), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesApi), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesApi), - makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase( + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), - removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase( + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase( + localAssetRepository: localAssetRepository + ), + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase( localAssetRepository: localAssetRepository ) ), @@ -581,7 +584,7 @@ final class FilesViewModelTests { @Test func testOfflineBarIsHiddenWhenItemsAreEmpty() { - sut.connectionState = .offline + NetworkMonitor.shared.currentStatus = .disconnected sut.state = .received(items: []) #expect(sut.shouldShowOfflineBar == false) @@ -592,7 +595,7 @@ final class FilesViewModelTests { let nodeA = WireDriveNode.fixture(path: "foo/aa.xyz") let now = Date() - sut.connectionState = .offline + NetworkMonitor.shared.currentStatus = .disconnected sut.state = .received(items: [ FilesViewItem( id: nodeA.id, diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift index 51e49651216..73afb65dbd7 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift @@ -47,6 +47,7 @@ final class FilesViewTests: XCTestCase { private var driveConversationsUseCase: WireDriveGetConversationsUseCase! private var makeAssetAvailableOfflineUseCase: WireDriveMakeAssetAvailableOfflineUseCase! private var removeAssetAvailableOfflineUseCase: WireDriveRemoveAssetAvailableOfflineUseCase! + private var fetchOfflineAvailableAssetsUseCase: WireDriveFetchOfflineAvailableAssetsUseCase! private let record: Bool? = nil @@ -110,6 +111,10 @@ final class FilesViewTests: XCTestCase { removeAssetAvailableOfflineUseCase = WireDriveRemoveAssetAvailableOfflineUseCase( localAssetRepository: localAssetsRepository ) + + fetchOfflineAvailableAssetsUseCase = WireDriveFetchOfflineAvailableAssetsUseCase( + localAssetRepository: localAssetsRepository + ) } @MainActor @@ -418,7 +423,7 @@ final class FilesViewTests: XCTestCase { renameNode: renameNodeUseCase, updateTags: updateTagsUseCase, getTagSuggestions: getTagSuggestionsUseCase, - createFileUseCase: WireDriveCreateFileUseCase( + createFile: WireDriveCreateFileUseCase( nodesRepository: nodesRepository ), fetchNodeVersions: WireDriveFetchNodeVersionsUseCase(repository: nodesRepository), @@ -428,7 +433,7 @@ final class FilesViewTests: XCTestCase { nodeCache: MockWireDriveNodeCacheProtocol() ), getEditingURL: getEditingURLUseCase, - getAssetUseCase: WireDriveGetAssetUseCase( + getAsset: WireDriveGetAssetUseCase( localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol(), fileCache: MockFileCache() ), @@ -438,8 +443,9 @@ final class FilesViewTests: XCTestCase { updatePublicLinkExpiration: updatePublicLinkExpiration, updatePublicLinkPassword: updatePublicLinkPassword, getDriveConversations: driveConversationsUseCase, - makeAssetAvailableOfflineUseCase: makeAssetAvailableOfflineUseCase, - removeAssetAvailableOfflineUseCase: removeAssetAvailableOfflineUseCase + makeAssetAvailableOffline: makeAssetAvailableOfflineUseCase, + removeAssetAvailableOffline: removeAssetAvailableOfflineUseCase, + getOfflineAvailableAssets: fetchOfflineAvailableAssetsUseCase ), isCellsStatePending: false, localAssetRepository: MockWireDriveLocalAssetRepositoryProtocol(), From eff5b36a32004d83293a24460f2eb72742b74e99 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Tue, 7 Apr 2026 11:33:23 +0200 Subject: [PATCH 31/62] allow mocking NWPathMonitor to fix UTs --- .../Components/Common/NetworkMonitor.swift | 19 ++++- .../Components/Files/FilesViewModel.swift | 6 +- .../Helpers/WireDriveLocalAsset+Fixture.swift | 3 +- ...chOfflineAvailableAssetsUseCaseTests.swift | 85 +++++++++++++++++++ .../Files/FilesViewModelTests.swift | 16 +++- 5 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift index bf0874cf5e0..3407ea79997 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift @@ -23,7 +23,7 @@ import Observation /// Provides observable changes in internet connection. /// Conforms to both, `Observable` and `ObservableObject` to support ViewModels with the old and the new system. @MainActor -final class NetworkMonitor: Observable, ObservableObject { +package final class NetworkMonitor: Observable, ObservableObject { enum NetworkStatus { case connected @@ -32,14 +32,18 @@ final class NetworkMonitor: Observable, ObservableObject { static let shared = NetworkMonitor() - private let monitor = NWPathMonitor() + private var monitor: any NWPathMonitoring private let queue = DispatchQueue(label: "NetworkMonitorQueue") private let subject = CurrentValueSubject(.disconnected) private var cancellables = Set() @Published var currentStatus: NetworkStatus? - private init() { + init( + monitor: any NWPathMonitoring = NWPathMonitor(), + ) { + self.monitor = monitor + subject .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) @@ -49,7 +53,7 @@ final class NetworkMonitor: Observable, ObservableObject { } .store(in: &cancellables) - monitor.pathUpdateHandler = { [weak self] path in + self.monitor.pathUpdateHandler = { [weak self] path in guard let self else { return } Task { @MainActor in let status: NetworkStatus = path.status == .satisfied ? .connected : .disconnected @@ -60,3 +64,10 @@ final class NetworkMonitor: Observable, ObservableObject { monitor.start(queue: queue) } } + +protocol NWPathMonitoring { + var pathUpdateHandler: (@Sendable (NWPath) -> Void)? { get set } + func start(queue: DispatchQueue) +} + +extension NWPathMonitor: NWPathMonitoring {} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index f05e33d0ab4..5503d307f3c 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -291,7 +291,7 @@ package final class FilesViewModel: ObservableObject { @Published var conversations: [WireDriveConversation] = [] @Published var filtersSelection: FilesFilteringViewModel.FiltersSelection = .empty - @Published private var networkMonitor = NetworkMonitor.shared + @Published private var networkMonitor: NetworkMonitor private var selfUserID: String? { conversations @@ -312,7 +312,8 @@ package final class FilesViewModel: ObservableObject { isBrowsing: Bool, isRecycleBin: Bool = false, triggerReload: PassthroughSubject = .init(), - accentColorProvider: @escaping () -> WireAccentColor + accentColorProvider: @escaping () -> WireAccentColor, + networkMonitor: NetworkMonitor = .shared ) { self.useCases = useCases self.title = title @@ -327,6 +328,7 @@ package final class FilesViewModel: ObservableObject { self.isRecycleBin = isRecycleBin self.triggerReload = triggerReload self.accentColorProvider = accentColorProvider + self.networkMonitor = networkMonitor } func setup() async { diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift index 1ad800a3683..e17eb49f92d 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/Helpers/WireDriveLocalAsset+Fixture.swift @@ -28,6 +28,7 @@ extension WireDriveLocalAsset { contentType: String? = "text/plain", size: UInt64? = 1234, isAvailableOffline: Bool = false, + conversationName: String = "Conversation 1", downloadState: DownloadState = .pending ) -> WireDriveLocalAsset { WireDriveLocalAsset( @@ -36,7 +37,7 @@ extension WireDriveLocalAsset { path: path, contentType: contentType, size: size, - conversationName: "Conversation 1", + conversationName: conversationName, ownerName: "User 1", modified: nil, isAvailableOffline: isAvailableOffline, diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift new file mode 100644 index 00000000000..e6b46a453b6 --- /dev/null +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift @@ -0,0 +1,85 @@ +// +// 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 Foundation +import Testing + +import WireMessagingDomainSupport +@testable import WireMessagingData +@testable import WireMessagingDomain + +@MainActor +final class WireDriveFetchOfflineAvailableAssetsUseCaseTests { + + private let localAssetRepository: WireDriveLocalAssetRepository! + private let store = MockWireDriveLocalAssetStoreProtocol() + private var storeBacking: [UUID: WireDriveLocalAsset] = [:] + private let sut: WireDriveFetchOfflineAvailableAssetsUseCase + + init() { + self.localAssetRepository = WireDriveLocalAssetRepository( + nodesAPI: MockNodesAPIProtocol(), + fileDownloader: MockFileDownloading(), + fileCache: MockFileCache(), + store: store + ) + + self.sut = WireDriveFetchOfflineAvailableAssetsUseCase( + localAssetRepository: localAssetRepository, + ) + + store.upsertAsset_MockMethod = { [weak self] asset in + self?.storeBacking[asset.nodeID] = asset + } + + store.offlineAssetsConversationName_MockMethod = { [weak self] conversationName in + if let conversationName { + return self?.storeBacking.map(\.value).filter { $0.conversationName == conversationName } ?? [] + } else { + return self?.storeBacking.map(\.value) ?? [] + } + } + } + + @Test + func `It retrieves all available offline assets locally`() async throws { + // given + let assets = [WireDriveLocalAsset.fixture(), .fixture(), .fixture()] + try assets.forEach(store.upsertAsset) + + // when + let availableAssets = try await sut.invoke(conversationName: nil) + + // then + #expect(availableAssets.count == assets.count) + } + + @Test + func `It retrieves available offline assets for a given conversation locally`() async throws { + // given + let assets = [WireDriveLocalAsset.fixture(conversationName: "Test"), .fixture(conversationName: "Test"), .fixture(), .fixture(), .fixture()] + try assets.forEach(store.upsertAsset) + + // when + let availableAssets = try await sut.invoke(conversationName: "Test") + + // then + #expect(availableAssets.count == 2) + } + +} diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift index 3de06c5eb17..5e3b6f4b537 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift @@ -19,6 +19,7 @@ import Combine import Foundation import Testing +import Network import WireMessagingDomain @testable import WireMessagingDomainSupport @@ -34,6 +35,7 @@ final class FilesViewModelTests { private let sut: FilesViewModel private var itemsUpdates: [[FilesViewItem]] = [] private var cancellables = Set() + private var networkMonitor = NetworkMonitor(monitor: MockNWPathMonitoring()) init() { let nodesApi = MockNodesAPIProtocol() @@ -43,6 +45,8 @@ final class FilesViewModelTests { let editingURLRepository = MockWireDriveEditingURLRepositoryProtocol() editingURLRepository.getEditorURLId_MockValue = nil + + networkMonitor.currentStatus = .connected self.sut = FilesViewModel( useCases: .init( @@ -101,7 +105,8 @@ final class FilesViewModelTests { nodesRepository: nodesRepository, fileCache: fileCache, isBrowsing: false, - accentColorProvider: { .default } + accentColorProvider: { .default }, + networkMonitor: networkMonitor ) localAssetRepository.assetNodeID_MockValue = .fixture() @@ -595,7 +600,8 @@ final class FilesViewModelTests { let nodeA = WireDriveNode.fixture(path: "foo/aa.xyz") let now = Date() - NetworkMonitor.shared.currentStatus = .disconnected + networkMonitor.currentStatus = .disconnected + sut.state = .received(items: [ FilesViewItem( id: nodeA.id, @@ -618,3 +624,9 @@ final class FilesViewModelTests { } } + +private final class MockNWPathMonitoring: NWPathMonitoring { + var pathUpdateHandler: (@Sendable (NWPath) -> Void)? + + func start(queue: DispatchQueue) {} +} From fb9e4fda4d4be1621d898cb874d2420fa589713a Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 7 Apr 2026 15:18:48 +0200 Subject: [PATCH 32/62] sorting offline files by FS creation date --- .../Components/Files/FilesViewModel.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 5503d307f3c..2e90e66817f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -702,8 +702,19 @@ package final class FilesViewModel: ObservableObject { size: asset.size ) } + + let itemsWithCreationDates: [(item: FilesViewItem, creationDate: Date)] = await items.asyncMap { item in + let url = try? await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) + let creationDate = (try? url?.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() + return (item: item, creationDate: creationDate) + } + + let sortedItems = itemsWithCreationDates.sorted { lhs, rhs in + lhs.creationDate.compare(rhs.creationDate) == .orderedDescending + } + .map(\.item) - state = .received(items: items) + state = .received(items: sortedItems) } catch { alert = .unknownError WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") @@ -982,3 +993,14 @@ extension WireDriveFileTemplate.Kind { } } } + +private extension Sequence { + @MainActor + func asyncMap(_ transform: @MainActor (Element) async throws -> T) async rethrows -> [T] { + var values = [T]() + for element in self { + try await values.append(transform(element)) + } + return values + } +} From c82f2af862738647d4676d4e1becb1d8a14b74c1 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 7 Apr 2026 16:25:26 +0200 Subject: [PATCH 33/62] skipping reload when the network status isnt available yet, but without Combine observation setup --- .../Components/Files/FilesContentView.swift | 8 +++++--- .../Components/Files/FilesViewModel.swift | 18 ++++-------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 6cc7ba5b2c1..1495534b487 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -118,9 +118,11 @@ package struct FilesContentView: View { } ) } - .onChange(of: viewModel.isOffline) { - Task { - await viewModel.reload(refreshing: true) + .onChange(of: viewModel.networkStatus) { oldValue, newValue in + if newValue != nil { + Task { + await viewModel.reload(refreshing: true) + } } } .task { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 2e90e66817f..de1fc991b19 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -269,6 +269,10 @@ package final class FilesViewModel: ObservableObject { loadMoreTask != nil } + var networkStatus: NetworkMonitor.NetworkStatus? { + networkMonitor.currentStatus + } + var isOffline: Bool { networkMonitor.currentStatus == .disconnected } @@ -334,7 +338,6 @@ package final class FilesViewModel: ObservableObject { func setup() async { await fetchConversations() bindSearch() - bindConnectionStatusResolved() fetchTemplates() Task { await reload() } } @@ -486,19 +489,6 @@ package final class FilesViewModel: ObservableObject { // MARK: - Private - /// Waits for the first non-nil network status to avoid incorrect UI transitions at launch. - private func bindConnectionStatusResolved() { - guard networkMonitor.currentStatus == nil else { return } - - networkMonitor.$currentStatus - .filter { $0 != nil } - .first() - .sink { [weak self] status in - guard status != nil else { return } - Task { await self?.reload() } - }.store(in: &subscriptions) - } - private func fetchTemplates() { Task { // TODO: [WPB-22926] Replace hard coded values with server values when GET/ templates endpoint ready. From 4e6f9f5b2c12a599eff070570363cb6a17cdc5b5 Mon Sep 17 00:00:00 2001 From: Jullian Mercier <31648126+jullianm@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:11:43 +0200 Subject: [PATCH 34/62] feat: re-create folder structure locally when offline - WPB-24437 (#4563) --- .../WireDriveLocalAssetRepository.swift | 7 ++- .../WireDrive/WireDriveLocalAssetStore.swift | 16 ++++- ...ireDriveLocalAssetRepositoryProtocol.swift | 6 +- .../WireDriveLocalAssetStoreProtocol.swift | 6 +- ...veFetchOfflineAvailableAssetsUseCase.swift | 4 +- .../UseCases/WireDriveMoveNodeUseCase.swift | 15 ++++- .../Components/Common/NetworkMonitor.swift | 2 +- .../Components/Files/FilesContentView.swift | 2 +- .../Files/FilesPreviewHelpers.swift | 5 +- .../Components/Files/FilesViewModel.swift | 63 ++++++++++++++++--- ...chOfflineAvailableAssetsUseCaseTests.swift | 18 ++++-- .../Files/FilesViewModelTests.swift | 8 +-- 12 files changed, 116 insertions(+), 36 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index c1fce5c755f..fe2f707e086 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -65,8 +65,11 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository } @MainActor - package func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] { - try await store.offlineAssets(conversationName: conversationName) + package func offlineAssets( + conversationName: String?, + assetsPath: String? + ) async throws -> [WireMessagingDomain.WireDriveLocalAsset] { + try await store.offlineAssets(conversationName: conversationName, assetsPath: assetsPath) } /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index cc2171a849f..8f67605d993 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -46,10 +46,16 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { } } - package func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] { + package func offlineAssets( + conversationName: String?, + assetsPath: String? + ) async throws -> [WireMessagingDomain.WireDriveLocalAsset] { let context = contextProvider.newBackgroundContext() return try await context.perform { - let managedAssets = try context.fetchLocalOfflineAssets(conversationName: conversationName) + let managedAssets = try context.fetchLocalOfflineAssets( + conversationName: conversationName, + assetsPath: assetsPath + ) return managedAssets.compactMap { managed in let cacheKey = WireDriveLocalAsset.cacheKey( @@ -195,7 +201,7 @@ private extension NSManagedObjectContext { return try fetch(request).first } - func fetchLocalOfflineAssets(conversationName: String?) throws -> [ManagedLocalAsset] { + func fetchLocalOfflineAssets(conversationName: String?, assetsPath: String?) throws -> [ManagedLocalAsset] { let request = ManagedLocalAsset.fetchRequest() as! NSFetchRequest let isAvailableOfflinePredicate = NSPredicate(format: "isAvailableOffline == YES") let isDownloadedPredicate = @@ -211,6 +217,10 @@ private extension NSManagedObjectContext { predicates.append(NSPredicate(format: "conversationName == %@", conversationName)) } + if let assetsPath { + predicates.append(NSPredicate(format: "path BEGINSWITH %@", assetsPath)) + } + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) return try fetch(request) } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index a855352c45e..a9ca3a3aadb 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -28,9 +28,11 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { @MainActor func asset(nodeID: UUID) throws -> WireDriveLocalAsset? - /// Returns offline available local assets for a given conversation or for all conversations if nil. + /// Returns offline `WireDriveLocalAsset` objects for a given path or conversation. + /// If both parameters are `nil`, all offline assets are returned. @MainActor - func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] + func offlineAssets(conversationName: String?, assetsPath: String?) async throws + -> [WireMessagingDomain.WireDriveLocalAsset] /// Refreshes the local asset metadata for a given `nodeID` and deletes any cached file if necessary. /// diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift index 73873fafda2..9721ac791af 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift @@ -26,8 +26,10 @@ package protocol WireDriveLocalAssetStoreProtocol: Sendable { /// Returns the `WireDriveLocalAsset` for a given `nodeID` or `nil`. func asset(nodeID: UUID) throws -> WireDriveLocalAsset? - /// Returns offline available local assets for a given conversation or for all conversations if nil. - func offlineAssets(conversationName: String?) async throws -> [WireMessagingDomain.WireDriveLocalAsset] + /// Returns offline `WireDriveLocalAsset` objects for a given path or conversation. + /// If both parameters are `nil`, all offline assets are returned. + func offlineAssets(conversationName: String?, assetsPath: String?) async throws + -> [WireMessagingDomain.WireDriveLocalAsset] /// Updates an existing `WireDriveLocalAsset` or creates a new one if none exists with its `nodeID`. func upsertAsset(_ asset: WireDriveLocalAsset) throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift index 914b472c760..32ed68800d1 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchOfflineAvailableAssetsUseCase.swift @@ -27,7 +27,7 @@ package struct WireDriveFetchOfflineAvailableAssetsUseCase { self.localAssetRepository = localAssetRepository } - package func invoke(conversationName: String?) async throws -> [WireDriveLocalAsset] { - try await localAssetRepository.offlineAssets(conversationName: conversationName) + package func invoke(conversationName: String?, assetsPath: String?) async throws -> [WireDriveLocalAsset] { + try await localAssetRepository.offlineAssets(conversationName: conversationName, assetsPath: assetsPath) } } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMoveNodeUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMoveNodeUseCase.swift index 14c1a28e573..96282b8462c 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMoveNodeUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveMoveNodeUseCase.swift @@ -19,17 +19,21 @@ package import Foundation /// Moves a `WireDriveNode` on the server. +@MainActor package struct WireDriveMoveNodeUseCase { private let nodesRepository: any WireDriveNodesRepositoryProtocol + private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol package init( - nodesRepository: any WireDriveNodesRepositoryProtocol + nodesRepository: any WireDriveNodesRepositoryProtocol, + localAssetRepository: any WireDriveLocalAssetRepositoryProtocol ) { self.nodesRepository = nodesRepository + self.localAssetRepository = localAssetRepository } - /// Moves a `WireCellNode` on the server. + /// Moves a `WireCellNode` on the server and updates the local asset with the new path. /// /// - Parameters: /// - nodeID: The ID of the node to move. @@ -39,6 +43,13 @@ package struct WireDriveMoveNodeUseCase { containerPath: String ) async throws { try await nodesRepository.moveNode(nodeID: nodeID, newContainerPath: containerPath) + + if var localAsset = try localAssetRepository.asset(nodeID: nodeID), + let node = try await nodesRepository.getNode(id: nodeID) { + localAsset.path = node.path + try localAssetRepository.updateAsset(localAsset) + } + } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift index 3407ea79997..9de627dcafa 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/NetworkMonitor.swift @@ -43,7 +43,7 @@ package final class NetworkMonitor: Observable, ObservableObject { monitor: any NWPathMonitoring = NWPathMonitor(), ) { self.monitor = monitor - + subject .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) .receive(on: DispatchQueue.main) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 1495534b487..a601bf6eb2e 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -118,7 +118,7 @@ package struct FilesContentView: View { } ) } - .onChange(of: viewModel.networkStatus) { oldValue, newValue in + .onChange(of: viewModel.networkStatus) { _, newValue in if newValue != nil { Task { await viewModel.reload(refreshing: true) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index bfd6e22bc02..0c52cf63040 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -333,7 +333,10 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr publishers.values.compactMap(\.value) } - func offlineAssets(conversationName: String?) throws -> [WireMessagingDomain.WireDriveLocalAsset] { + func offlineAssets( + conversationName: String?, + assetsPath: String? + ) throws -> [WireMessagingDomain.WireDriveLocalAsset] { publishers.values.compactMap(\.value).filter(\.isAvailableOffline) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index de1fc991b19..d1b14904c6c 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -272,7 +272,7 @@ package final class FilesViewModel: ObservableObject { var networkStatus: NetworkMonitor.NetworkStatus? { networkMonitor.currentStatus } - + var isOffline: Bool { networkMonitor.currentStatus == .disconnected } @@ -430,7 +430,10 @@ package final class FilesViewModel: ObservableObject { }, nodesRepository: nodesRepository, localAssetRepository: assetRepository, - moveNodeUseCase: WireDriveMoveNodeUseCase(nodesRepository: nodesRepository), + moveNodeUseCase: WireDriveMoveNodeUseCase( + nodesRepository: nodesRepository, + localAssetRepository: assetRepository + ), createFileUseCase: useCases.createFile ) ) @@ -667,24 +670,57 @@ package final class FilesViewModel: ObservableObject { private func loadOfflineFiles() async { do { let offlineAssets = try await useCases.getOfflineAvailableAssets.invoke( - conversationName: cellName != nil ? conversations.first?.name : nil + conversationName: cellName != nil ? conversations.first?.name : nil, + assetsPath: navigationPath.last?.filePath ) let items: [FilesViewItem] = offlineAssets.map { asset in let fileUrl = URL(fileURLWithPath: asset.path) - let fileName = fileUrl.lastPathComponent let fileExtension = fileUrl.pathExtension let fileType = UTType(filenameExtension: fileExtension) + func nextFolderPath(from fullPath: String, basePath: String) -> String? { + let baseComponents = basePath.split(separator: "/") + let fullComponents = fullPath.split(separator: "/") + + let noMoreFolders = fullComponents.count == baseComponents.count + 1 + + if noMoreFolders { + return nil + } + + guard fullComponents.starts(with: baseComponents) else { + return nil + } + + let nextCount = baseComponents.count + 1 + let nextComponents = fullComponents.prefix(nextCount) + return nextComponents.joined(separator: "/") + "/" + } + + let basePath = navigationPath.last?.filePath ?? asset.path.split(separator: "/").prefix(1).joined() + let nextFolderPath = isBrowsing ? nil : nextFolderPath(from: asset.path, basePath: basePath) + + let filekind: FilesViewItem.Kind = if nextFolderPath != nil { + .folder + } else { + .file + } + let filepath: String = if let nextFolderPath { + nextFolderPath + } else { + asset.path + } + return .init( id: asset.nodeID, eTag: asset.eTag, - kind: .file, - name: fileName, - filePath: asset.path, + kind: filekind, + name: URL(fileURLWithPath: filepath).lastPathComponent, + filePath: filepath, ownedBy: asset.ownerName, modifiedAt: asset.modified, - icon: .make(type: fileType, fileExtension: fileExtension), + icon: filekind == .folder ? .folder : .make(type: fileType, fileExtension: fileExtension), tags: [], // change later if we want to show tags in offline mode. isEditable: false, // change later if we want to edit files in offline mode. publicLinkID: nil, // change later if we want to be able to share a public link in offline mode. @@ -692,17 +728,24 @@ package final class FilesViewModel: ObservableObject { size: asset.size ) } - + let itemsWithCreationDates: [(item: FilesViewItem, creationDate: Date)] = await items.asyncMap { item in let url = try? await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) let creationDate = (try? url?.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() return (item: item, creationDate: creationDate) } - + let sortedItems = itemsWithCreationDates.sorted { lhs, rhs in lhs.creationDate.compare(rhs.creationDate) == .orderedDescending } .map(\.item) + .reduce(into: [FilesViewItem]()) { result, item in + let isDuplicate = result.map(\.filePath).contains(item.filePath) // removes duplicated folders + + if !isDuplicate { + result.append(item) + } + } state = .received(items: sortedItems) } catch { diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift index e6b46a453b6..4cf188f235c 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveFetchOfflineAvailableAssetsUseCaseTests.swift @@ -46,8 +46,8 @@ final class WireDriveFetchOfflineAvailableAssetsUseCaseTests { store.upsertAsset_MockMethod = { [weak self] asset in self?.storeBacking[asset.nodeID] = asset } - - store.offlineAssetsConversationName_MockMethod = { [weak self] conversationName in + + store.offlineAssetsConversationNameAssetsPath_MockMethod = { [weak self] conversationName, _ in if let conversationName { return self?.storeBacking.map(\.value).filter { $0.conversationName == conversationName } ?? [] } else { @@ -63,20 +63,26 @@ final class WireDriveFetchOfflineAvailableAssetsUseCaseTests { try assets.forEach(store.upsertAsset) // when - let availableAssets = try await sut.invoke(conversationName: nil) + let availableAssets = try await sut.invoke(conversationName: nil, assetsPath: nil) // then #expect(availableAssets.count == assets.count) } - + @Test func `It retrieves available offline assets for a given conversation locally`() async throws { // given - let assets = [WireDriveLocalAsset.fixture(conversationName: "Test"), .fixture(conversationName: "Test"), .fixture(), .fixture(), .fixture()] + let assets = [ + WireDriveLocalAsset.fixture(conversationName: "Test"), + .fixture(conversationName: "Test"), + .fixture(), + .fixture(), + .fixture() + ] try assets.forEach(store.upsertAsset) // when - let availableAssets = try await sut.invoke(conversationName: "Test") + let availableAssets = try await sut.invoke(conversationName: "Test", assetsPath: nil) // then #expect(availableAssets.count == 2) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift index 5e3b6f4b537..8d054c57825 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift @@ -18,8 +18,8 @@ import Combine import Foundation -import Testing import Network +import Testing import WireMessagingDomain @testable import WireMessagingDomainSupport @@ -45,7 +45,7 @@ final class FilesViewModelTests { let editingURLRepository = MockWireDriveEditingURLRepositoryProtocol() editingURLRepository.getEditorURLId_MockValue = nil - + networkMonitor.currentStatus = .connected self.sut = FilesViewModel( @@ -601,7 +601,7 @@ final class FilesViewModelTests { let now = Date() networkMonitor.currentStatus = .disconnected - + sut.state = .received(items: [ FilesViewItem( id: nodeA.id, @@ -627,6 +627,6 @@ final class FilesViewModelTests { private final class MockNWPathMonitoring: NWPathMonitoring { var pathUpdateHandler: (@Sendable (NWPath) -> Void)? - + func start(queue: DispatchQueue) {} } From d062c3196c232b154b3c4c808f6a1f72dd570458 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Tue, 14 Apr 2026 18:46:19 +0200 Subject: [PATCH 35/62] create async version of asset update, use it when removing a file available offline --- .../WireDriveLocalAssetRepository.swift | 5 ++ .../WireDrive/WireDriveLocalAssetStore.swift | 49 +++++++++++++------ ...ireDriveLocalAssetRepositoryProtocol.swift | 3 ++ .../WireDriveLocalAssetStoreProtocol.swift | 4 ++ ...veRemoveAssetAvailableOfflineUseCase.swift | 4 +- .../Files/FilesPreviewHelpers.swift | 4 ++ .../Components/Files/FilesViewModel.swift | 2 +- ...oveAssetAvailableOfflineUseCaseTests.swift | 8 +-- 8 files changed, 56 insertions(+), 23 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift index fe2f707e086..bf96838a210 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetRepository.swift @@ -116,6 +116,11 @@ package final class WireDriveLocalAssetRepository: WireDriveLocalAssetRepository try store.upsertAsset(asset) } + @MainActor + package func updateAssetAsync(_ asset: WireDriveLocalAsset) async throws { + try await store.upsertAssetAsync(asset) + } + @MainActor package func deleteAsset(nodeID: UUID) async throws { guard let asset = try store.asset(nodeID: nodeID), diff --git a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift index 8f67605d993..98f47fb6efb 100644 --- a/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift +++ b/WireMessaging/Sources/WireMessagingData/WireDrive/WireDriveLocalAssetStore.swift @@ -89,26 +89,23 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { if let oldAsset, asset.hasEqualMetadata(to: oldAsset) { return } - let context = contextProvider.newBackgroundContext() Task.detached { - try await context.perform { - let stored = try context.fetchLocalAsset(nodeID: asset.nodeID) ?? ManagedLocalAsset(context: context) - stored.nodeID = asset.nodeID - stored.eTag = asset.eTag - stored.path = asset.path - stored.contentType = asset.contentType - stored.size = asset.size.map { Int64($0) } ?? -1 - stored.conversationName = asset.conversationName - stored.ownerName = asset.ownerName - stored.modified = asset.modified - stored.isAvailableOffline = asset.isAvailableOffline - stored.isDownloaded = asset.isDownloaded - - try context.save() - } + try await self.storeAsset(asset) } } + package func upsertAssetAsync(_ asset: WireMessagingDomain.WireDriveLocalAsset) async throws { + guard assets[asset.nodeID] != asset else { return } + + let oldAsset = assets[asset.nodeID] + assets[asset.nodeID] = asset + updates.send((asset.nodeID, asset)) + + if let oldAsset, asset.hasEqualMetadata(to: oldAsset) { return } + + try await storeAsset(asset) + } + package func observeAsset(nodeID: UUID) -> AnyPublisher { updates .filter { $0.0 == nodeID } @@ -138,6 +135,26 @@ package final class WireDriveLocalAssetStore: WireDriveLocalAssetStoreProtocol { // MARK: Helpers + private func storeAsset(_ asset: WireMessagingDomain.WireDriveLocalAsset) async throws { + let context = contextProvider.newBackgroundContext() + + try await context.perform { + let stored = try context.fetchLocalAsset(nodeID: asset.nodeID) ?? ManagedLocalAsset(context: context) + stored.nodeID = asset.nodeID + stored.eTag = asset.eTag + stored.path = asset.path + stored.contentType = asset.contentType + stored.size = asset.size.map { Int64($0) } ?? -1 + stored.conversationName = asset.conversationName + stored.ownerName = asset.ownerName + stored.modified = asset.modified + stored.isAvailableOffline = asset.isAvailableOffline + stored.isDownloaded = asset.isDownloaded + + try context.save() + } + } + private func storedAsset(nodeID: UUID) throws -> WireMessagingDomain.WireDriveLocalAsset? { let context = contextProvider.viewContext return try context.performAndWait { diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift index a9ca3a3aadb..7afc791e664 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetRepositoryProtocol.swift @@ -56,6 +56,9 @@ package protocol WireDriveLocalAssetRepositoryProtocol: Sendable { @MainActor func updateAsset(_ asset: WireDriveLocalAsset) throws + @MainActor + func updateAssetAsync(_ asset: WireDriveLocalAsset) async throws + /// Deletes an asset from both the database and file cache. @MainActor func deleteAsset(nodeID: UUID) async throws diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift index 9721ac791af..e1b35b2af06 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/Protocols/WireDriveLocalAssetStoreProtocol.swift @@ -34,6 +34,10 @@ package protocol WireDriveLocalAssetStoreProtocol: Sendable { /// Updates an existing `WireDriveLocalAsset` or creates a new one if none exists with its `nodeID`. func upsertAsset(_ asset: WireDriveLocalAsset) throws + /// Updates an existing `WireDriveLocalAsset` or creates a new one if none exists with its `nodeID`. + /// This method waits for the database operation to complete before returning. + func upsertAssetAsync(_ asset: WireDriveLocalAsset) async throws + /// Returns a publisher to monitor changes to an `WireDriveLocalAsset` for a given `nodeID`. func observeAsset(nodeID: UUID) -> AnyPublisher diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift index 077ef8546bf..a87f5d0fd0a 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveRemoveAssetAvailableOfflineUseCase.swift @@ -31,12 +31,12 @@ package struct WireDriveRemoveAssetAvailableOfflineUseCase { self.localAssetRepository = localAssetRepository } - package func invoke(nodeID: UUID) throws { + package func invoke(nodeID: UUID) async throws { guard var asset = try localAssetRepository.asset(nodeID: nodeID) else { throw Failure.assetNotFound } asset.isAvailableOffline = false - try localAssetRepository.updateAsset(asset) + try await localAssetRepository.updateAssetAsync(asset) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 0c52cf63040..b9a1ea8a523 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -369,6 +369,10 @@ private final class PreviewLocalAssetRepository: WireDriveLocalAssetRepositoryPr publishers[asset.nodeID]?.send(asset) } + func updateAssetAsync(_ asset: WireDriveLocalAsset) async throws { + publishers[asset.nodeID]?.send(asset) + } + func deleteAsset(nodeID: UUID) async throws { publishers[nodeID]?.send(nil) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index d1b14904c6c..3e147582f31 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -977,7 +977,7 @@ package final class FilesViewModel: ObservableObject { private func removeAssetAvailableOffline(item: FilesViewItem) { Task { do { - try useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) + try await useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) if isOffline { await reload() diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift index 0ad1bfb2f81..c521ff756de 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/UseCases/WireDriveRemoveAssetAvailableOfflineUseCaseTests.swift @@ -46,7 +46,7 @@ final class WireDriveRemoveAssetAvailableOfflineUseCaseTests { store.assetNodeID_MockMethod = { [weak self] nodeID in self?.storeBacking[nodeID] } - store.upsertAsset_MockMethod = { [weak self] asset in + store.upsertAssetAsync_MockMethod = { [weak self] asset in self?.storeBacking[asset.nodeID] = asset } } @@ -62,7 +62,7 @@ final class WireDriveRemoveAssetAvailableOfflineUseCaseTests { #expect(asset.isAvailableOffline == true) // when - try sut.invoke(nodeID: asset.nodeID) + try await sut.invoke(nodeID: asset.nodeID) // then #expect(storeBacking[asset.nodeID]?.isAvailableOffline == false) @@ -77,9 +77,9 @@ final class WireDriveRemoveAssetAvailableOfflineUseCaseTests { ) // then - #expect(throws: WireDriveRemoveAssetAvailableOfflineUseCase.Failure.assetNotFound) { + await #expect(throws: WireDriveRemoveAssetAvailableOfflineUseCase.Failure.assetNotFound) { // when - try sut.invoke(nodeID: asset.nodeID) + try await sut.invoke(nodeID: asset.nodeID) } } From 012c5f8b06cb26f5e78b8c7333765686b7c7e253 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 15 Apr 2026 12:01:23 +0200 Subject: [PATCH 36/62] use .path instead of .id for the cell root, don't load offline files if in recycle bin --- .../WireDrive/Components/Files/FilesViewContainer.swift | 2 +- .../WireDrive/Components/Files/FilesViewModel.swift | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index d3d7f370789..a65aa54a73f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -115,7 +115,7 @@ package struct FilesViewContainer: View { useCases: .init( fetchNodes: WireDriveFetchNodesPageUseCase( configuration: .conversationFileView( - root: path.last.map { .id($0.id) } ?? .path(cellName), + root: path.last.map { .path($0.filePath) } ?? .path(cellName), ), repository: nodesRepository ), diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 3e147582f31..489525e782f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -668,6 +668,10 @@ package final class FilesViewModel: ObservableObject { } private func loadOfflineFiles() async { + guard !isRecycleBin else { + return state = .received(items: []) + } + do { let offlineAssets = try await useCases.getOfflineAvailableAssets.invoke( conversationName: cellName != nil ? conversations.first?.name : nil, From 87758e2d14683b3604b7a7ee1e26ca284db2a85a Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 15 Apr 2026 15:18:06 +0200 Subject: [PATCH 37/62] avoid code duplication --- .../WireDriveLocalAssetRepositoryTests.swift | 212 +++++------------- 1 file changed, 57 insertions(+), 155 deletions(-) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift index 9b8a8d7234e..5995e411964 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift @@ -35,6 +35,11 @@ final class WireDriveLocalAssetRepositoryTests { private var storeBacking: [UUID: WireDriveLocalAsset] = [:] private let sut: WireDriveLocalAssetRepository private var cancellables = Set() + private let wireDriveConversation = WireDriveConversation( + id: UUID().uuidString, + name: "Conversation 1", + participants: [] + ) init() { self.sut = WireDriveLocalAssetRepository( @@ -87,11 +92,7 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, - conversation: WireDriveConversation( - id: UUID().uuidString, - name: "Conversation 1", - participants: [] - ), + conversation: wireDriveConversation, path: "path/file.png", size: 1234, eTag: "abc", @@ -105,19 +106,21 @@ final class WireDriveLocalAssetRepositoryTests { _ = try await sut.refreshAssetMetadata(nodeID: nodeID) // then the store is updated with the new metadata + let expectedAsset = WireDriveLocalAsset( + nodeID: nodeID, + eTag: "abc", + path: "path/file.png", + contentType: "image/png", + size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, + downloadState: .pending + ) + #expect( - try store.asset(nodeID: nodeID) == WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .pending - ) + try store.asset(nodeID: nodeID) == expectedAsset ) // then no files are deleted @@ -126,18 +129,7 @@ final class WireDriveLocalAssetRepositoryTests { // then one asset change is observed try #require(store.upsertAsset_Invocations.count == 1) #expect( - store.upsertAsset_Invocations.first == WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .pending, - ) + store.upsertAsset_Invocations.first == expectedAsset ) } @@ -213,11 +205,7 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, - conversation: WireDriveConversation( - id: UUID().uuidString, - name: "Conversation 1", - participants: [] - ), + conversation: wireDriveConversation, path: "path/file.png", size: 1234, eTag: "abc", @@ -316,11 +304,7 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, - conversation: WireDriveConversation( - id: UUID().uuidString, - name: "Conversation 1", - participants: [] - ), + conversation: wireDriveConversation, path: "path/fileWithoutExtension", size: 1234, eTag: "abc", @@ -343,8 +327,8 @@ final class WireDriveLocalAssetRepositoryTests { try await sut.downloadAsset(nodeID: nodeID, isAvailableOffline: false) // then - #expect( - try store.asset(nodeID: nodeID) == WireDriveLocalAsset( + func expectedAsset(downloadState: WireDriveLocalAsset.DownloadState) -> WireDriveLocalAsset { + WireDriveLocalAsset( nodeID: nodeID, eTag: "abc", path: "path/fileWithoutExtension", @@ -354,60 +338,22 @@ final class WireDriveLocalAssetRepositoryTests { ownerName: "User 1", modified: nil, isAvailableOffline: false, - downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/fileWithoutExtension") + downloadState: downloadState, ) + } + + #expect( + try store + .asset(nodeID: nodeID) == + expectedAsset(downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/fileWithoutExtension")) ) #expect( store.upsertAsset_Invocations == [ - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/fileWithoutExtension", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .pending, - ), - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/fileWithoutExtension", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloading(progress: 0.5) - ), - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/fileWithoutExtension", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloading(progress: 1.0) - ), - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/fileWithoutExtension", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/fileWithoutExtension") - ) + expectedAsset(downloadState: .pending), + expectedAsset(downloadState: .downloading(progress: 0.5)), + expectedAsset(downloadState: .downloading(progress: 1)), + expectedAsset(downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/fileWithoutExtension")) ] ) } @@ -419,11 +365,7 @@ final class WireDriveLocalAssetRepositoryTests { let node = WireDriveNode.fixture( uuid: nodeID, - conversation: WireDriveConversation( - id: UUID().uuidString, - name: "Conversation 1", - participants: [] - ), + conversation: wireDriveConversation, path: "path/file.png", size: 1234, eTag: "abc", @@ -467,73 +409,33 @@ final class WireDriveLocalAssetRepositoryTests { // then #expect(assets.count == 3) + func expectedAsset(downloadState: WireDriveLocalAsset.DownloadState) -> WireDriveLocalAsset { + WireDriveLocalAsset( + nodeID: nodeID, + eTag: "abc", + path: "path/file.png", + contentType: "image/png", + size: 1234, + conversationName: "Conversation 1", + ownerName: "User 1", + modified: nil, + isAvailableOffline: false, + downloadState: downloadState, + ) + } + #expect( try assets.allSatisfy { asset in - asset == WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png") - ) + asset == expectedAsset(downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png")) } ) #expect( store.upsertAsset_Invocations == [ - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .pending, - ), - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloading(progress: 0.5) - ), - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloading(progress: 1.0) - ), - WireDriveLocalAsset( - nodeID: nodeID, - eTag: "abc", - path: "path/file.png", - contentType: "image/png", - size: 1234, - conversationName: "Conversation 1", - ownerName: "User 1", - modified: nil, - isAvailableOffline: false, - downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png") - ) + expectedAsset(downloadState: .pending), + expectedAsset(downloadState: .downloading(progress: 0.5)), + expectedAsset(downloadState: .downloading(progress: 1)), + expectedAsset(downloadState: .downloaded(cacheKey: "\(nodeID.uuidString)-abc/file.png")) ] ) } From 1e92ef5ce7280ec5d7ac36188bf79032f17bfedc Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Wed, 15 Apr 2026 15:52:24 +0200 Subject: [PATCH 38/62] factor out view builder method in UT --- .../Components/Files/FilesViewTests.swift | 131 +++++------------- 1 file changed, 35 insertions(+), 96 deletions(-) diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift index 73afb65dbd7..5413b10bbca 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewTests.swift @@ -136,21 +136,7 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_withShortStrings() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, - name: "image.jpg", - filePath: "", - ownedBy: "Natsuko Shiroi", - modifiedAt: modifiedAt, - icon: .image, - tags: [], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil - ) + let item = filesViewItem() let view = FilesItemView(viewModel: .make(item: item)) .frame(width: 390) @@ -165,20 +151,10 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_withLongStrings() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, + let item = filesViewItem( name: "some random file with a long name.excel", - filePath: "", ownedBy: "Liana Margaret Smith-Jones", - modifiedAt: modifiedAt, icon: .spreadsheet, - tags: [], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil ) let view = FilesItemView(viewModel: .make(item: item)) @@ -194,20 +170,8 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_withOneTag() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, - name: "image.jpg", - filePath: "", - ownedBy: "Natsuko Shiroi", - modifiedAt: modifiedAt, - icon: .image, - tags: ["important"], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil + let item = filesViewItem( + tags: ["important"] ) let view = FilesItemView(viewModel: .make(item: item)) @@ -223,20 +187,8 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_withThreeTags() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, - name: "image.jpg", - filePath: "", - ownedBy: "Natsuko Shiroi", - modifiedAt: modifiedAt, - icon: .image, - tags: ["tag1", "tag2", "abcdef"], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil + let item = filesViewItem( + tags: ["tag1", "tag2", "abcdef"] ) let view = FilesItemView(viewModel: .make(item: item)) @@ -252,20 +204,10 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_dynamicTypeVariants() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, + let item = filesViewItem( name: "some random file with a long name.excel", - filePath: "", ownedBy: "Natsuko Shiroi", - modifiedAt: modifiedAt, - icon: .spreadsheet, - tags: [], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil + icon: .spreadsheet ) let view = FilesItemView(viewModel: .make(item: item)) @@ -283,21 +225,8 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_whenDownloading() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, - name: "image.jpg", - filePath: "", - ownedBy: "Natsuko Shiroi", - modifiedAt: modifiedAt, - icon: .image, - tags: [], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil - ) + let item = filesViewItem() + let asset = WireDriveLocalAsset( nodeID: item.id, eTag: "eTag", @@ -324,21 +253,8 @@ final class FilesViewTests: XCTestCase { @MainActor func testFilesViewItemView_whenDownloadFailed() { - let item = FilesViewItem( - id: UUID(), - eTag: "eTag", - kind: .file, - name: "image.jpg", - filePath: "", - ownedBy: "Natsuko Shiroi", - modifiedAt: modifiedAt, - icon: .image, - tags: [], - isEditable: false, - publicLinkID: nil, - conversationName: "Conversation 1", - size: nil - ) + let item = filesViewItem() + let asset = WireDriveLocalAsset( nodeID: item.id, eTag: "eTag", @@ -411,6 +327,29 @@ final class FilesViewTests: XCTestCase { .verify(matching: view, named: "dark", record: record) } + private func filesViewItem( + name: String = "image.jpg", + ownedBy: String = "Natsuko Shiroi", + icon: WireDriveFileType = .image, + tags: [String] = [] + ) -> FilesViewItem { + FilesViewItem( + id: UUID(), + eTag: "eTag", + kind: .file, + name: name, + filePath: "", + ownedBy: ownedBy, + modifiedAt: modifiedAt, + icon: icon, + tags: tags, + isEditable: false, + publicLinkID: nil, + conversationName: "Conversation 1", + size: nil + ) + } + @MainActor private func makeFilesView( state: FilesViewModel.State From 790a8e8dd4e88f16c03986887d50ff1fcd3701dc Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 16 Apr 2026 11:44:40 +0200 Subject: [PATCH 39/62] remove available offline icon on folder --- .../WireDrive/Components/Files/Item/FilesItemViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index b830a323ddd..7ea9b3c4586 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -423,7 +423,9 @@ final class FilesItemViewModel: ObservableObject { false } - return isAvailableOffline && isDownloaded + let isFolder = item.kind == .folder + + return isAvailableOffline && isDownloaded && !isFolder } } From d688188d3eaff4348626bb47ed3a171e4099beb2 Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 16 Apr 2026 15:57:06 +0200 Subject: [PATCH 40/62] fix file opening automatically after download when made available offline --- .../WireDriveAttachmentsPreviewItemViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsPreviewView/WireDriveAttachmentsPreviewItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsPreviewView/WireDriveAttachmentsPreviewItemViewModel.swift index 72c2be745a4..55a5e1008f6 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsPreviewView/WireDriveAttachmentsPreviewItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/AttachmentsPreviewView/WireDriveAttachmentsPreviewItemViewModel.swift @@ -72,6 +72,7 @@ final class WireDriveAttachmentsPreviewItemViewModel: ObservableObject { self.isDeleted = false self.fileTracker = .init() fileTracker.onSmallFileLoaded = { [weak self] in + guard let asset = self?.asset, !asset.isAvailableOffline else { return } Task { await self?.handleAsset() } } From 43c690a91c536d88421f271cdf85ffe69ad6f77b Mon Sep 17 00:00:00 2001 From: Jullian Mercier Date: Thu, 16 Apr 2026 16:30:35 +0200 Subject: [PATCH 41/62] explicitly nil out viewingURL before opening the asset --- .../WireDrive/Components/Files/FilesViewModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 489525e782f..8aec9c23acb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -574,6 +574,7 @@ package final class FilesViewModel: ObservableObject { case .pending, .failed: _ = try await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) case .downloaded: + viewingURL = nil let url = try await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) viewingURL = url case .downloading: From 6616c4cbef81f7ccc36d5c3bf1bb76290c55bb84 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 16 Apr 2026 16:30:27 +0200 Subject: [PATCH 42/62] moving FilesSortingViewModel.SortingKey and WireDriveFileTemplate.Kind extensions away # Conflicts: # WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift --- .../Components/Files/FilesView.swift | 30 ++++++++++++++- .../Components/Files/FilesViewModel.swift | 37 ------------------- .../SortAndFilter/FilesSortingViewModel.swift | 11 ++++++ 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift index 4d16b31c8e8..05eadfaf3c1 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift @@ -207,8 +207,9 @@ private extension FilesView { } } -private extension FilesViewModel.FolderMenuOption { +// MARK: - folder menu title +private extension FilesViewModel.FolderMenuOption { var title: String { switch self { case let .folder(_, title): @@ -217,9 +218,36 @@ private extension FilesViewModel.FolderMenuOption { Strings.Files.navigationTitle } } +} + +// MARK: - template / create file +private extension WireDriveFileTemplate.Kind { + var title: String { + switch self { + case .document: + Strings.Files.List.CreateFile.document + case .spreadsheet: + Strings.Files.List.CreateFile.spreadsheet + case .presentation: + Strings.Files.List.CreateFile.presentation + } + } + + var systemImage: String { + switch self { + case .document: + "text.document" + case .spreadsheet: + "tablecells" + case .presentation: + "sparkles.tv" + } + } } +// MARK: - Preview + #Preview { NavigationStack { FilesView(viewModel: .preview()) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 8aec9c23acb..f571efdd373 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -995,43 +995,6 @@ package final class FilesViewModel: ObservableObject { } } -private extension FilesSortingViewModel.SortingKey { - var sortField: String { - switch self { - case .date: - "mtime" - case .name: - "name" - case .size: - "size" - } - } -} - -extension WireDriveFileTemplate.Kind { - var title: String { - switch self { - case .document: - Strings.Files.List.CreateFile.document - case .spreadsheet: - Strings.Files.List.CreateFile.spreadsheet - case .presentation: - Strings.Files.List.CreateFile.presentation - } - } - - var systemImage: String { - switch self { - case .document: - "text.document" - case .spreadsheet: - "tablecells" - case .presentation: - "sparkles.tv" - } - } -} - private extension Sequence { @MainActor func asyncMap(_ transform: @MainActor (Element) async throws -> T) async rethrows -> [T] { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift index 1e2e569a5f3..85927bdb4ee 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift @@ -63,6 +63,17 @@ final class FilesSortingViewModel: ObservableObject { Strings.Key.size } } + + var sortField: String { + switch self { + case .date: + "mtime" + case .name: + "name" + case .size: + "size" + } + } } struct SortingSelection { From b3534070520359124de678f81ae4d5431da95009 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Mon, 20 Apr 2026 16:42:16 +0200 Subject: [PATCH 43/62] removing duplicate file --- .../Source/Model/ReactionData.swift | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 wire-ios-data-model/Source/Model/ReactionData.swift diff --git a/wire-ios-data-model/Source/Model/ReactionData.swift b/wire-ios-data-model/Source/Model/ReactionData.swift deleted file mode 100644 index b2a1a7f5bbb..00000000000 --- a/wire-ios-data-model/Source/Model/ReactionData.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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/. -// - -@objc -public class ReactionData: NSObject { - public let reactionString: String - public let users: [UserType] - public let creationDate: Date - - public init(reactionString: String, users: [UserType], creationDate: Date) { - self.reactionString = reactionString - self.users = users - self.creationDate = creationDate - } - - public override var hash: Int { - reactionString.hash - } -} From 73013621745c62b8a4c1eaa82e4e6495e15b4ee3 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Mon, 20 Apr 2026 16:46:21 +0200 Subject: [PATCH 44/62] moved code related to offline files to OfflineAvailableFilesHandler --- .../Components/Files/FilesViewModel.swift | 132 ++------------- .../OfflineAvailableFilesHandler.swift | 150 ++++++++++++++++++ 2 files changed, 167 insertions(+), 115 deletions(-) create mode 100644 WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index f571efdd373..0a95500da72 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -222,6 +222,8 @@ package final class FilesViewModel: ObservableObject { let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase } + + private let offlineAvailableFilesHandler: OfflineAvailableFilesHandler private let setNavigation: ([FilesViewItem]) -> Void private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol @@ -333,6 +335,14 @@ package final class FilesViewModel: ObservableObject { self.triggerReload = triggerReload self.accentColorProvider = accentColorProvider self.networkMonitor = networkMonitor + self.offlineAvailableFilesHandler = .init( + useCases: .init( + getAsset: useCases.getAsset, + makeAvailableOffline: useCases.makeAssetAvailableOffline, + removeAvailableOffline: useCases.removeAssetAvailableOffline, + getOfflineAvailable: useCases.getOfflineAvailableAssets + ) + ) } func setup() async { @@ -404,9 +414,12 @@ package final class FilesViewModel: ObservableObject { case .edit: isEditing = item case .makeAvailableOffline: - makeAssetAvailableOffline(item: item) + offlineAvailableFilesHandler.makeAvailableOffline(item: item) case .removeAvailableOffline: - removeAssetAvailableOffline(item: item) + offlineAvailableFilesHandler.removeAvailableOffline(item: item) + if isOffline { + await reload() + } } }, isBrowsing: isBrowsing, @@ -674,85 +687,12 @@ package final class FilesViewModel: ObservableObject { } do { - let offlineAssets = try await useCases.getOfflineAvailableAssets.invoke( + let items = try await offlineAvailableFilesHandler.getOfflineAvailable( conversationName: cellName != nil ? conversations.first?.name : nil, assetsPath: navigationPath.last?.filePath ) - let items: [FilesViewItem] = offlineAssets.map { asset in - let fileUrl = URL(fileURLWithPath: asset.path) - let fileExtension = fileUrl.pathExtension - let fileType = UTType(filenameExtension: fileExtension) - - func nextFolderPath(from fullPath: String, basePath: String) -> String? { - let baseComponents = basePath.split(separator: "/") - let fullComponents = fullPath.split(separator: "/") - - let noMoreFolders = fullComponents.count == baseComponents.count + 1 - - if noMoreFolders { - return nil - } - - guard fullComponents.starts(with: baseComponents) else { - return nil - } - - let nextCount = baseComponents.count + 1 - let nextComponents = fullComponents.prefix(nextCount) - return nextComponents.joined(separator: "/") + "/" - } - - let basePath = navigationPath.last?.filePath ?? asset.path.split(separator: "/").prefix(1).joined() - let nextFolderPath = isBrowsing ? nil : nextFolderPath(from: asset.path, basePath: basePath) - - let filekind: FilesViewItem.Kind = if nextFolderPath != nil { - .folder - } else { - .file - } - let filepath: String = if let nextFolderPath { - nextFolderPath - } else { - asset.path - } - - return .init( - id: asset.nodeID, - eTag: asset.eTag, - kind: filekind, - name: URL(fileURLWithPath: filepath).lastPathComponent, - filePath: filepath, - ownedBy: asset.ownerName, - modifiedAt: asset.modified, - icon: filekind == .folder ? .folder : .make(type: fileType, fileExtension: fileExtension), - tags: [], // change later if we want to show tags in offline mode. - isEditable: false, // change later if we want to edit files in offline mode. - publicLinkID: nil, // change later if we want to be able to share a public link in offline mode. - conversationName: asset.conversationName, - size: asset.size - ) - } - - let itemsWithCreationDates: [(item: FilesViewItem, creationDate: Date)] = await items.asyncMap { item in - let url = try? await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) - let creationDate = (try? url?.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() - return (item: item, creationDate: creationDate) - } - - let sortedItems = itemsWithCreationDates.sorted { lhs, rhs in - lhs.creationDate.compare(rhs.creationDate) == .orderedDescending - } - .map(\.item) - .reduce(into: [FilesViewItem]()) { result, item in - let isDuplicate = result.map(\.filePath).contains(item.filePath) // removes duplicated folders - - if !isDuplicate { - result.append(item) - } - } - - state = .received(items: sortedItems) + state = .received(items: items) } catch { alert = .unknownError WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") @@ -966,42 +906,4 @@ package final class FilesViewModel: ObservableObject { filtersSelection = filters Task { await reload() } } - - // MARK: - Offline mode - - private func makeAssetAvailableOffline(item: FilesViewItem) { - Task { - do { - try await useCases.makeAssetAvailableOffline.invoke(nodeID: item.id) - } catch { - WireLogger.wireDrive.error("Failed to make asset available offline: \(String(describing: error))") - } - } - } - - private func removeAssetAvailableOffline(item: FilesViewItem) { - Task { - do { - try await useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) - - if isOffline { - await reload() - } - } catch { - WireLogger.wireDrive - .error("Failed to remove asset from available offline: \(String(describing: error))") - } - } - } -} - -private extension Sequence { - @MainActor - func asyncMap(_ transform: @MainActor (Element) async throws -> T) async rethrows -> [T] { - var values = [T]() - for element in self { - try await values.append(transform(element)) - } - return values - } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift new file mode 100644 index 00000000000..4b8478f4901 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift @@ -0,0 +1,150 @@ +// +// 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 Foundation +import WireFoundation +import WireLogging +import WireMessagingDomain +import WireMessagingDomainSupport +import UniformTypeIdentifiers + +struct OfflineAvailableFilesHandler { + struct UseCases { + let getAsset: WireDriveGetAssetUseCase + let makeAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase + let removeAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase + let getOfflineAvailable: WireDriveFetchOfflineAvailableAssetsUseCase + } + + let useCases: UseCases + + func makeAvailableOffline(item: FilesViewItem) { + Task { + do { + try await useCases.makeAvailableOffline.invoke(nodeID: item.id) + } catch { + WireLogger.wireDrive.error("Failed to make asset available offline: \(String(describing: error))") + } + } + } + + func removeAvailableOffline(item: FilesViewItem) { + Task { + do { + try await useCases.removeAvailableOffline.invoke(nodeID: item.id) + } catch { + WireLogger.wireDrive + .error("Failed to remove asset from available offline: \(String(describing: error))") + } + } + } + + func getOfflineAvailable(conversationName: String?, assetsPath: String?) async throws -> [FilesViewItem] { + let offlineAssets = try await useCases.getOfflineAvailable.invoke( + conversationName: conversationName, + assetsPath: assetsPath + ) + + let items: [FilesViewItem] = offlineAssets.map { asset in + let fileUrl = URL(fileURLWithPath: asset.path) + let fileExtension = fileUrl.pathExtension + let fileType = UTType(filenameExtension: fileExtension) + + func nextFolderPath(from fullPath: String, basePath: String) -> String? { + let baseComponents = basePath.split(separator: "/") + let fullComponents = fullPath.split(separator: "/") + + let noMoreFolders = fullComponents.count == baseComponents.count + 1 + + if noMoreFolders { + return nil + } + + guard fullComponents.starts(with: baseComponents) else { + return nil + } + + let nextCount = baseComponents.count + 1 + let nextComponents = fullComponents.prefix(nextCount) + return nextComponents.joined(separator: "/") + "/" + } + + let filesFromAllConversations = conversationName == nil + let basePath = assetsPath ?? asset.path.split(separator: "/").prefix(1).joined() + let nextFolderPath = filesFromAllConversations ? nil : nextFolderPath(from: asset.path, basePath: basePath) + + let filekind: FilesViewItem.Kind = if nextFolderPath != nil { + .folder + } else { + .file + } + let filepath: String = if let nextFolderPath { + nextFolderPath + } else { + asset.path + } + + return .init( + id: asset.nodeID, + eTag: asset.eTag, + kind: filekind, + name: URL(fileURLWithPath: filepath).lastPathComponent, + filePath: filepath, + ownedBy: asset.ownerName, + modifiedAt: asset.modified, + icon: filekind == .folder ? .folder : .make(type: fileType, fileExtension: fileExtension), + tags: [], // change later if we want to show tags in offline mode. + isEditable: false, // change later if we want to edit files in offline mode. + publicLinkID: nil, // change later if we want to be able to share a public link in offline mode. + conversationName: asset.conversationName, + size: asset.size + ) + } + + let itemsWithCreationDates: [(item: FilesViewItem, creationDate: Date)] = await items.asyncMap { item in + let url = try? await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) + let creationDate = (try? url?.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() + return (item: item, creationDate: creationDate) + } + + let sortedItems = itemsWithCreationDates.sorted { lhs, rhs in + lhs.creationDate.compare(rhs.creationDate) == .orderedDescending + } + .map(\.item) + .reduce(into: [FilesViewItem]()) { result, item in + let isDuplicate = result.map(\.filePath).contains(item.filePath) // removes duplicated folders + + if !isDuplicate { + result.append(item) + } + } + + return sortedItems + } +} + +private extension Sequence { + @MainActor + func asyncMap(_ transform: @MainActor (Element) async throws -> T) async rethrows -> [T] { + var values = [T]() + for element in self { + try await values.append(transform(element)) + } + return values + } +} From aeb2f6a92d81b9d2740393f1e0fa531cfeefd3e0 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 21 Apr 2026 15:36:21 +0200 Subject: [PATCH 45/62] using UIActions to encapsulate single actions rather than groups of actions --- .../Components/Files/FilesViewModel.swift | 52 +++++++++++------ .../SortAndFilter/FilesSortingViewModel.swift | 2 +- .../LoadOfflineAvailableFilesUIAction.swift} | 57 ++++++------------- 3 files changed, 53 insertions(+), 58 deletions(-) rename WireMessaging/Sources/WireMessagingUI/WireDrive/Components/{Handlers/OfflineAvailableFilesHandler.swift => UI Actions/LoadOfflineAvailableFilesUIAction.swift} (69%) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 0a95500da72..631fdbb0926 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -222,8 +222,6 @@ package final class FilesViewModel: ObservableObject { let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase } - - private let offlineAvailableFilesHandler: OfflineAvailableFilesHandler private let setNavigation: ([FilesViewItem]) -> Void private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol @@ -335,14 +333,6 @@ package final class FilesViewModel: ObservableObject { self.triggerReload = triggerReload self.accentColorProvider = accentColorProvider self.networkMonitor = networkMonitor - self.offlineAvailableFilesHandler = .init( - useCases: .init( - getAsset: useCases.getAsset, - makeAvailableOffline: useCases.makeAssetAvailableOffline, - removeAvailableOffline: useCases.removeAssetAvailableOffline, - getOfflineAvailable: useCases.getOfflineAvailableAssets - ) - ) } func setup() async { @@ -414,12 +404,9 @@ package final class FilesViewModel: ObservableObject { case .edit: isEditing = item case .makeAvailableOffline: - offlineAvailableFilesHandler.makeAvailableOffline(item: item) + makeAssetAvailableOffline(item: item) case .removeAvailableOffline: - offlineAvailableFilesHandler.removeAvailableOffline(item: item) - if isOffline { - await reload() - } + removeAssetAvailableOffline(item: item) } }, isBrowsing: isBrowsing, @@ -687,11 +674,15 @@ package final class FilesViewModel: ObservableObject { } do { - let items = try await offlineAvailableFilesHandler.getOfflineAvailable( + let actionInput = LoadOfflineAvailableFilesUIAction.Input( conversationName: cellName != nil ? conversations.first?.name : nil, - assetsPath: navigationPath.last?.filePath + assetsPath: navigationPath.last?.filePath, + getAsset: useCases.getAsset, + getOfflineAvailableAssets: useCases.getOfflineAvailableAssets ) + let items = try await LoadOfflineAvailableFilesUIAction(input: actionInput)() + state = .received(items: items) } catch { alert = .unknownError @@ -906,4 +897,31 @@ package final class FilesViewModel: ObservableObject { filtersSelection = filters Task { await reload() } } + + // MARK: - Offline mode + + private func makeAssetAvailableOffline(item: FilesViewItem) { + Task { + do { + try await useCases.makeAssetAvailableOffline.invoke(nodeID: item.id) + } catch { + WireLogger.wireDrive.error("Failed to make asset available offline: \(String(describing: error))") + } + } + } + + private func removeAssetAvailableOffline(item: FilesViewItem) { + Task { + do { + try await useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) + + if isOffline { + await reload() + } + } catch { + WireLogger.wireDrive + .error("Failed to remove asset from available offline: \(String(describing: error))") + } + } + } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift index 85927bdb4ee..631d7a49be9 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift @@ -63,7 +63,7 @@ final class FilesSortingViewModel: ObservableObject { Strings.Key.size } } - + var sortField: String { switch self { case .date: diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/UI Actions/LoadOfflineAvailableFilesUIAction.swift similarity index 69% rename from WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift rename to WireMessaging/Sources/WireMessagingUI/WireDrive/Components/UI Actions/LoadOfflineAvailableFilesUIAction.swift index 4b8478f4901..0d4b32db7d0 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Handlers/OfflineAvailableFilesHandler.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/UI Actions/LoadOfflineAvailableFilesUIAction.swift @@ -17,47 +17,26 @@ // import Foundation +import UniformTypeIdentifiers import WireFoundation import WireLogging import WireMessagingDomain import WireMessagingDomainSupport -import UniformTypeIdentifiers -struct OfflineAvailableFilesHandler { - struct UseCases { +struct LoadOfflineAvailableFilesUIAction { + struct Input { + let conversationName: String? + let assetsPath: String? let getAsset: WireDriveGetAssetUseCase - let makeAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase - let removeAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase - let getOfflineAvailable: WireDriveFetchOfflineAvailableAssetsUseCase - } - - let useCases: UseCases - - func makeAvailableOffline(item: FilesViewItem) { - Task { - do { - try await useCases.makeAvailableOffline.invoke(nodeID: item.id) - } catch { - WireLogger.wireDrive.error("Failed to make asset available offline: \(String(describing: error))") - } - } + let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase } - func removeAvailableOffline(item: FilesViewItem) { - Task { - do { - try await useCases.removeAvailableOffline.invoke(nodeID: item.id) - } catch { - WireLogger.wireDrive - .error("Failed to remove asset from available offline: \(String(describing: error))") - } - } - } - - func getOfflineAvailable(conversationName: String?, assetsPath: String?) async throws -> [FilesViewItem] { - let offlineAssets = try await useCases.getOfflineAvailable.invoke( - conversationName: conversationName, - assetsPath: assetsPath + let input: Input + + func callAsFunction() async throws -> [FilesViewItem] { + let offlineAssets = try await input.getOfflineAvailableAssets.invoke( + conversationName: input.conversationName, + assetsPath: input.assetsPath ) let items: [FilesViewItem] = offlineAssets.map { asset in @@ -84,9 +63,9 @@ struct OfflineAvailableFilesHandler { return nextComponents.joined(separator: "/") + "/" } - let filesFromAllConversations = conversationName == nil - let basePath = assetsPath ?? asset.path.split(separator: "/").prefix(1).joined() - let nextFolderPath = filesFromAllConversations ? nil : nextFolderPath(from: asset.path, basePath: basePath) + let isAllConversations = input.conversationName == nil + let basePath = input.assetsPath ?? asset.path.split(separator: "/").prefix(1).joined() + let nextFolderPath = isAllConversations ? nil : nextFolderPath(from: asset.path, basePath: basePath) let filekind: FilesViewItem.Kind = if nextFolderPath != nil { .folder @@ -117,12 +96,12 @@ struct OfflineAvailableFilesHandler { } let itemsWithCreationDates: [(item: FilesViewItem, creationDate: Date)] = await items.asyncMap { item in - let url = try? await useCases.getAsset.invoke(nodeID: item.id, eTag: item.eTag) + let url = try? await input.getAsset.invoke(nodeID: item.id, eTag: item.eTag) let creationDate = (try? url?.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() return (item: item, creationDate: creationDate) } - let sortedItems = itemsWithCreationDates.sorted { lhs, rhs in + return itemsWithCreationDates.sorted { lhs, rhs in lhs.creationDate.compare(rhs.creationDate) == .orderedDescending } .map(\.item) @@ -133,8 +112,6 @@ struct OfflineAvailableFilesHandler { result.append(item) } } - - return sortedItems } } From 1a4eb6f06fed97d2e77a25c90c4b3945e62217b1 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 21 Apr 2026 16:14:57 +0200 Subject: [PATCH 46/62] refactored processItems() --- .../Components/Files/FilesViewModel.swift | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 631fdbb0926..2594b073425 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -645,8 +645,8 @@ package final class FilesViewModel: ObservableObject { loadMoreTask = task do { let (newItems, isLastPage) = try await task.value - let receivedItems = Self.processItems(refreshing ? newItems : state.items + newItems) - state = .received(items: receivedItems) + let receivedItems = refreshing ? newItems : state.items + newItems + state = .received(items: receivedItems.latestModified()) hasMore = !isLastPage } catch is CancellationError { return // developer-driven error, discard @@ -716,7 +716,7 @@ package final class FilesViewModel: ObservableObject { var currentItems = state.items currentItems.removeAll { $0.id == asset.id } - state = .received(items: Self.processItems(currentItems)) + state = .received(items: currentItems.latestModified()) do { try await useCases.deleteNodes.invoke(nodeIDs: [asset.id], deletePermanently: permanently) @@ -725,7 +725,7 @@ package final class FilesViewModel: ObservableObject { var currentItems = state.items currentItems.append(asset) - state = .received(items: Self.processItems(currentItems)) + state = .received(items: currentItems.latestModified()) } } @@ -762,7 +762,7 @@ package final class FilesViewModel: ObservableObject { var currentItems = state.items currentItems.removeAll { $0.id == asset.id } - state = .received(items: Self.processItems(currentItems)) + state = .received(items: currentItems.latestModified()) let nodeIdToRestore = navigationPath.last?.recycleBinTopFolderId ?? asset.id @@ -775,33 +775,10 @@ package final class FilesViewModel: ObservableObject { var currentItems = state.items currentItems.append(asset) - state = .received(items: Self.processItems(currentItems)) + state = .received(items: currentItems.latestModified()) } } - /// Removes items with duplicate IDs keeping the latest modified if known, otherwise the first. - private static func processItems(_ items: [FilesViewItem]) -> [FilesViewItem] { - var latestByID: [UUID: FilesViewItem] = [:] - for item in items { - if let existing = latestByID[item.id] { - let existingDate = existing.modifiedAt ?? .distantPast - let newDate = item.modifiedAt ?? .distantPast - if newDate > existingDate { - latestByID[item.id] = item - } - } else { - latestByID[item.id] = item - } - } - - var results: [FilesViewItem] = [] - for item in items where item == latestByID[item.id] { - results.append(item) - } - - return results - } - private func makeFileRenameView( item: FilesViewItem ) -> FileRenameView { @@ -925,3 +902,28 @@ package final class FilesViewModel: ObservableObject { } } } + +private extension Collection { + /// Removes items with duplicate IDs keeping the latest modified if known, otherwise the first. + func latestModified() -> [FilesViewItem] { + var latestByID: [UUID: FilesViewItem] = [:] + for item in self { + if let existing = latestByID[item.id] { + let existingDate = existing.modifiedAt ?? .distantPast + let newDate = item.modifiedAt ?? .distantPast + if newDate > existingDate { + latestByID[item.id] = item + } + } else { + latestByID[item.id] = item + } + } + + var results: [FilesViewItem] = [] + for item in self where item == latestByID[item.id] { + results.append(item) + } + + return results + } +} From fc67e71a05b6e8cea2ad6e8f16895d0d5ce48f76 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 21 Apr 2026 17:42:26 +0200 Subject: [PATCH 47/62] extracted temporary workaround hardcoded templates fetching from the FilesViewModel into the use case --- .../WireMessagingFactory.swift | 1 + .../WireDriveFetchFileTemplatesUseCase.swift | 25 ++++++++++++++++-- .../Files/FilesPreviewHelpers.swift | 25 ++++++++++++++++++ .../Components/Files/FilesViewContainer.swift | 1 + .../Components/Files/FilesViewModel.swift | 26 +++---------------- .../Files/RecycleBinContainer.swift | 1 + 6 files changed, 55 insertions(+), 24 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index be5f3ab0ed8..c272b094581 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -216,6 +216,7 @@ public extension WireMessagingFactory { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), + getFileTemplates: WireDriveFetchFileTemplatesUseCase(repository: nodesAPI), makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchFileTemplatesUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchFileTemplatesUseCase.swift index f6f8e30d343..bfb002199c4 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchFileTemplatesUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveFetchFileTemplatesUseCase.swift @@ -16,7 +16,7 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -package import Foundation +import Foundation /// Fetches templates useable for document creation (.docx, .pptx, .xlsx. etc) package struct WireDriveFetchFileTemplatesUseCase: WireDriveFetchFileTemplatesUseCaseProtocol { @@ -30,7 +30,28 @@ package struct WireDriveFetchFileTemplatesUseCase: WireDriveFetchFileTemplatesUs } package func invoke() async throws -> [WireDriveFileTemplate] { - try await repository.getTemplates() + // TODO: [WPB-22926] Replace hard coded values with server values when GET/ templates endpoint ready. + // Do `try await repository.getTemplates()` + [ + .init( + kind: .document, + editable: true, + label: "Microsoft Word", + id: "01-Microsoft Word.docx" + ), + .init( + kind: .spreadsheet, + editable: true, + label: "Microsoft Excel", + id: "02-Microsoft Excel.xlsx" + ), + .init( + kind: .presentation, + editable: true, + label: "Microsoft PowerPoint", + id: "03-Microsoft PowerPoint.pptx" + ) + ] } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index b9a1ea8a523..c14cde492da 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -98,6 +98,9 @@ extension FilesViewModel { getDriveConversations: WireDriveGetConversationsUseCase( nodesAPI: previewConversationsApi() ), + getFileTemplates: WireDriveFetchFileTemplatesUseCase( + repository: previewNodesRepository() + ), makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), @@ -266,6 +269,28 @@ private func previewNodesRepository() -> any WireDriveNodesRepositoryProtocol { let nextOffset = end < nodes.count ? end : nil return (page, nextOffset) } + repository.getTemplates_MockMethod = { + [ + .init( + kind: .document, + editable: true, + label: "Microsoft Word", + id: "01-Microsoft Word.docx" + ), + .init( + kind: .spreadsheet, + editable: true, + label: "Microsoft Excel", + id: "02-Microsoft Excel.xlsx" + ), + .init( + kind: .presentation, + editable: true, + label: "Microsoft PowerPoint", + id: "03-Microsoft PowerPoint.pptx" + ) + ] + } return repository } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index a65aa54a73f..3c69542e9c6 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -155,6 +155,7 @@ package struct FilesViewContainer: View { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), + getFileTemplates: WireDriveFetchFileTemplatesUseCase(repository: nodesRepository), makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 2594b073425..07dd18eb8e9 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -174,6 +174,7 @@ package final class FilesViewModel: ObservableObject { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase, updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase, getDriveConversations: any WireDriveGetConversationsUseCaseProtocol, + getFileTemplates: any WireDriveFetchFileTemplatesUseCaseProtocol, makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase, removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase, getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase @@ -196,6 +197,7 @@ package final class FilesViewModel: ObservableObject { self.updatePublicLinkExpiration = updatePublicLinkExpiration self.updatePublicLinkPassword = updatePublicLinkPassword self.getDriveConversations = getDriveConversations + self.getFileTemplates = getFileTemplates self.makeAssetAvailableOffline = makeAssetAvailableOffline self.removeAssetAvailableOffline = removeAssetAvailableOffline self.getOfflineAvailableAssets = getOfflineAvailableAssets @@ -218,6 +220,7 @@ package final class FilesViewModel: ObservableObject { let updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase let updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase let getDriveConversations: any WireDriveGetConversationsUseCaseProtocol + let getFileTemplates: any WireDriveFetchFileTemplatesUseCaseProtocol let makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase @@ -494,28 +497,7 @@ package final class FilesViewModel: ObservableObject { private func fetchTemplates() { Task { - // TODO: [WPB-22926] Replace hard coded values with server values when GET/ templates endpoint ready. - // Do `templates = try await WireDriveFetchFileTemplatesUseCase.invoke()` - templates = [ - .init( - kind: .document, - editable: true, - label: "Microsoft Word", - id: "01-Microsoft Word.docx" - ), - .init( - kind: .spreadsheet, - editable: true, - label: "Microsoft Excel", - id: "02-Microsoft Excel.xlsx" - ), - .init( - kind: .presentation, - editable: true, - label: "Microsoft PowerPoint", - id: "03-Microsoft PowerPoint.pptx" - ) - ] + templates = try await useCases.getFileTemplates.invoke() } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index 755cd1a1650..ed78b3f5d58 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift @@ -115,6 +115,7 @@ package struct RecycleBinContainer: View { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesAPI), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesAPI), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesAPI), + getFileTemplates: WireDriveFetchFileTemplatesUseCase(repository: nodesRepository), makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), From 7f6303d55bf408a1748d1320d0b6bce6a3e1494b Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 21 Apr 2026 17:44:30 +0200 Subject: [PATCH 48/62] refactored file creation notification from Combine Publisher to callback closure --- .../Files/Create/CreateFileViewModel.swift | 9 +++++++-- .../Components/Files/FilesPreviewHelpers.swift | 3 ++- .../Components/Files/FilesViewModel.swift | 14 ++++---------- .../MoveToFolder/MoveToFolderPageViewModel.swift | 11 ++++------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Create/CreateFileViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Create/CreateFileViewModel.swift index e7d76082282..7881a69a1cb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Create/CreateFileViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Create/CreateFileViewModel.swift @@ -31,7 +31,8 @@ final class CreateFileViewModel: ObservableObject { @Published var errorMessage: String? @Published var isLoading: Bool = false @Published var isFocused: Bool = true - @Published var createdNode: WireDriveNode? + + let onNodeCreated: (WireDriveNode) -> Void var isCreateDisabled: Bool { errorMessage != nil || !isInputValid @@ -107,10 +108,12 @@ final class CreateFileViewModel: ObservableObject { creationTarget: WireDriveCreateFileUseCase.Target, path: String, createFileUseCase: any WireDriveCreateFileUseCaseProtocol, + onNodeCreated: @escaping (WireDriveNode) -> Void ) { self.creationTarget = creationTarget self.createFileUseCase = createFileUseCase self.path = path + self.onNodeCreated = onNodeCreated bindTextInput() } @@ -121,12 +124,14 @@ final class CreateFileViewModel: ObservableObject { do { isLoading = true - createdNode = try await createFileUseCase.invoke( + let createdNode = try await createFileUseCase.invoke( creationTarget: creationTarget, path: path, name: nameInput.trimmingCharacters(in: .whitespacesAndNewlines) ) + onNodeCreated(createdNode) + isLoading = false return true diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index c14cde492da..533ce83c44b 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -461,7 +461,8 @@ extension CreateFileViewModel { id: "01-Microsoft Word.docx" )), path: "Test-1/Test-2", - createFileUseCase: createFileUseCase + createFileUseCase: createFileUseCase, + onNodeCreated: { _ in } ) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 07dd18eb8e9..139303cb869 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -580,21 +580,15 @@ package final class FilesViewModel: ObservableObject { let viewModel = CreateFileViewModel( creationTarget: target, path: path, - createFileUseCase: useCases.createFile - ) - - // to know whether we need to reload nodes. - viewModel.$createdNode - .compactMap(\.self) - .sink { [weak self] createdNode in + createFileUseCase: useCases.createFile, + onNodeCreated: { [weak self] createdNode in guard let self else { return } shouldReload = true - if case .file = target { isEditing = makeFileViewItem(node: createdNode) } - - }.store(in: &subscriptions) + } + ) let createFileView = CreateFileView( viewModel: viewModel diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift index b16190c3119..823488bf501 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift @@ -271,14 +271,11 @@ final class MoveToFolderPageViewModel: MoveToFolderPageViewModelProtocol { let viewModel = CreateFileViewModel( creationTarget: .folder, path: containerPath, - createFileUseCase: createFileUseCase - ) - - viewModel.$createdNode - .compactMap(\.self) - .sink { [weak self] _ in + createFileUseCase: createFileUseCase, + onNodeCreated: { [weak self] _ in Task { await self?.reload() } - }.store(in: &subscriptions) + } + ) return viewModel } From b6a3f1fa2f1eb2a6686ed18c34b45beca4fa440e Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Thu, 23 Apr 2026 16:23:53 +0200 Subject: [PATCH 49/62] refactoring FileVersioningViewModel notification mechanism to a closure and removing the accentColorProvider from all of the places where it's being passed around to ViewModels (because it's not needed) --- .../WireMessagingFactory.swift | 12 ++++++------ .../Files/FileVersioning/FileVersionItemView.swift | 1 - .../FileVersioning/FileVersionItemViewModel.swift | 3 --- .../Files/FileVersioning/FileVersioningView.swift | 2 +- .../FileVersioning/FileVersioningViewModel.swift | 14 ++++++-------- .../Components/Files/FilesPreviewHelpers.swift | 6 ++---- .../Components/Files/FilesViewContainer.swift | 11 +++-------- .../Components/Files/FilesViewModel.swift | 12 ++++-------- .../Components/Files/Item/FilesItemViewModel.swift | 2 +- .../Components/Files/RecycleBinContainer.swift | 10 +++------- 10 files changed, 26 insertions(+), 47 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index c272b094581..219ae3df432 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -162,9 +162,9 @@ public extension WireMessagingFactory { localAssetRepository: localAssetRepository, nodeCache: nodeCache, nodeRenameNotifier: nodeRenameNotifier, - fileCache: fileCache, - accentColorProvider: accentColorProvider - ).environment(\.wireAccentColor, accentColorProvider()) + fileCache: fileCache + ) + .environment(\.wireAccentColor, accentColorProvider()) ) } @@ -231,10 +231,10 @@ public extension WireMessagingFactory { localAssetRepository: localAssetRepository, nodesRepository: nodesAPI, fileCache: fileCache, - isBrowsing: true, - accentColorProvider: accentColorProvider + isBrowsing: true ) - ).environment(\.wireAccentColor, accentColorProvider()) + ) + .environment(\.wireAccentColor, accentColorProvider()) ) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemView.swift index b1ddb9ae157..ad78e335dd1 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemView.swift @@ -28,7 +28,6 @@ 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( diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemViewModel.swift index 8661df06a0e..7c18e2c2574 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersionItemViewModel.swift @@ -29,18 +29,15 @@ final class FileVersionItemViewModel: ObservableObject { 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 } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift index 4c494dacd6b..2f5486ec30b 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift @@ -65,7 +65,7 @@ struct FileVersioningView: View, Identifiable { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } .toolbarBackground(ColorTheme.Backgrounds.background.color, for: .navigationBar) - .quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation + //.quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation .refreshable { await viewModel.fetch() } .alert( item: $viewModel.alert, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift index 033dc656a3d..f98f9fc7ba3 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift @@ -48,12 +48,9 @@ final class FileVersioningViewModel: ObservableObject { private let fetchNodeVersionsUseCase: any WireDriveFetchNodeVersionsUseCaseProtocol private let restoreNodeVersionUseCase: any WireDriveRestoreNodeVersionUseCaseProtocol private let getAssetUseCase: WireDriveGetAssetUseCase - private let accentColorProvider: () -> WireAccentColor private var subscriptions = Set() - var accentColor: WireAccentColor { - accentColorProvider() - } + let onVersionRestored: () -> Void enum State { case loading @@ -94,7 +91,7 @@ final class FileVersioningViewModel: ObservableObject { fetchNodeVersionsUseCase: any WireDriveFetchNodeVersionsUseCaseProtocol, restoreNodeVersionUseCase: any WireDriveRestoreNodeVersionUseCaseProtocol, getAssetUseCase: WireDriveGetAssetUseCase, - accentColorProvider: @escaping () -> WireAccentColor + onVersionRestored: @escaping () -> Void ) { self.nodeID = nodeID self.name = name @@ -103,8 +100,8 @@ final class FileVersioningViewModel: ObservableObject { self.fetchNodeVersionsUseCase = fetchNodeVersionsUseCase self.restoreNodeVersionUseCase = restoreNodeVersionUseCase self.getAssetUseCase = getAssetUseCase - self.accentColorProvider = accentColorProvider self.state = .loading + self.onVersionRestored = onVersionRestored } func startPolling() { @@ -122,7 +119,6 @@ final class FileVersioningViewModel: ObservableObject { FileVersionItemViewModel( nodeID: nodeID, item: state.versions[sectionIndex].items[itemIndex], - accentColor: accentColor, onRestore: { [weak self] item in Task { await self?.restore(item: item) } } @@ -148,6 +144,7 @@ final class FileVersioningViewModel: ObservableObject { private func restore(item: FileVersionItem) async { state = .restoringVersion + // Keep loader visible for at least 2 sec to avoid a glitch 😅 try? await Task.sleep(for: .seconds(2)) do { @@ -161,7 +158,8 @@ final class FileVersioningViewModel: ObservableObject { } viewingURL = try await getAssetUseCase.invoke(nodeID: nodeID, eTag: eTag) - + + onVersionRestored() } catch { alert = AlertModel( title: Strings.FilesVersioning.restoreFailureAlertTitle, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 533ce83c44b..49db62a1b6b 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -117,8 +117,7 @@ extension FilesViewModel { nodesRepository: previewNodesRepository(), fileCache: cache, cellName: "2b7d1f2c-74bf-4256-a746-8112e006dcd6", - isBrowsing: isBrowsing, - accentColorProvider: { .default } + isBrowsing: isBrowsing ) } } @@ -193,7 +192,6 @@ extension FileVersionItemViewModel { title: "5:46AM", subtitle: "Deniz Agha · 13MB" ), - accentColor: .default, onRestore: { _ in } ) } @@ -241,7 +239,7 @@ extension FileVersioningViewModel { localAssetRepository: localAssetsRepository, fileCache: MockFileCache() ), - accentColorProvider: { .default } + onVersionRestored: {} ) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index 3c69542e9c6..30bcbb06ad9 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -36,7 +36,6 @@ package struct FilesViewContainer: View { private let nodeCache: any WireDriveNodeCacheProtocol private let nodeRenameNotifier: WireDriveNodeRenameNotifier private let fileCache: any FileCache - private let accentColorProvider: () -> WireAccentColor private let triggerReloadFiles: PassthroughSubject = .init() @@ -57,8 +56,7 @@ package struct FilesViewContainer: View { localAssetRepository: any WireDriveLocalAssetRepositoryProtocol, nodeCache: any WireDriveNodeCacheProtocol, nodeRenameNotifier: WireDriveNodeRenameNotifier, - fileCache: any FileCache, - accentColorProvider: @escaping () -> WireAccentColor + fileCache: any FileCache ) { self.cellName = cellName self.nodesAPI = nodesAPI @@ -69,7 +67,6 @@ package struct FilesViewContainer: View { self.nodeCache = nodeCache self.nodeRenameNotifier = nodeRenameNotifier self.fileCache = fileCache - self.accentColorProvider = accentColorProvider } var body: some View { @@ -102,8 +99,7 @@ package struct FilesViewContainer: View { localAssetRepository: localAssetRepository, nodeCache: nodeCache, nodeRenameNotifier: nodeRenameNotifier, - fileCache: fileCache, - accentColorProvider: accentColorProvider + fileCache: fileCache ) } } @@ -178,8 +174,7 @@ package struct FilesViewContainer: View { cellName: cellName, isBrowsing: false, isRecycleBin: false, - triggerReload: triggerReloadFiles, - accentColorProvider: accentColorProvider + triggerReload: triggerReloadFiles ) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 16f814c5674..6c58a7c18e4 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -233,14 +233,13 @@ package final class FilesViewModel: ObservableObject { private let cellName: String? // nil when browsing all files private var subscriptions = Set() private let navigationPath: [FilesViewItem] - private let accentColorProvider: () -> WireAccentColor private var sortingSelection: FilesSortingViewModel.SortingSelection = .default let useCases: UseCases let isBrowsing: Bool let isRecycleBin: Bool let triggerReload: PassthroughSubject - var shouldReload: Bool = false + var shouldReload: Bool = false //TODO: refactor and remove let title: String? var showSearchBar: Bool { guard !isOffline else { @@ -319,7 +318,6 @@ package final class FilesViewModel: ObservableObject { isBrowsing: Bool, isRecycleBin: Bool = false, triggerReload: PassthroughSubject = .init(), - accentColorProvider: @escaping () -> WireAccentColor, networkMonitor: NetworkMonitor = .shared ) { self.useCases = useCases @@ -334,7 +332,6 @@ package final class FilesViewModel: ObservableObject { self.isBrowsing = isBrowsing self.isRecycleBin = isRecycleBin self.triggerReload = triggerReload - self.accentColorProvider = accentColorProvider self.networkMonitor = networkMonitor } @@ -813,9 +810,6 @@ package final class FilesViewModel: ObservableObject { 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, @@ -823,7 +817,9 @@ package final class FilesViewModel: ObservableObject { fetchNodeVersionsUseCase: useCases.fetchNodeVersions, restoreNodeVersionUseCase: useCases.restoreNodeVersion, getAssetUseCase: useCases.getAsset, - accentColorProvider: accentColorProvider + onVersionRestored: { [weak self] in + Task { await self?.reload() } + } ) return FileVersioningView(viewModel: viewModel) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 7ea9b3c4586..bfc36546e44 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -119,7 +119,7 @@ final class FilesItemViewModel: ObservableObject { self.fileTracker = .init() fileTracker.onSmallFileLoaded = { [weak self] in guard let asset = self?.asset, !asset.isAvailableOffline else { return } - self?.performAction(.primaryAction) + self?.performAction(.primaryAction) //TODO: this is also called when a file is downloaded after restoring a file version. this needs to be fixed. } localAssetRepository.observeAsset(nodeID: nodeID).sink { [weak self] asset in diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index ed78b3f5d58..af4c0d8c85f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift @@ -17,7 +17,7 @@ // import SwiftUI -package import WireFoundation +import WireFoundation package import WireMessagingDomain package import WireMessagingData @@ -35,7 +35,6 @@ package struct RecycleBinContainer: View { private let nodeCache: any WireDriveNodeCacheProtocol private let nodeRenameNotifier: WireDriveNodeRenameNotifier private let fileCache: any FileCache - private let accentColorProvider: () -> WireAccentColor package init( cellName: String, @@ -46,8 +45,7 @@ package struct RecycleBinContainer: View { localAssetRepository: any WireDriveLocalAssetRepositoryProtocol, nodeCache: any WireDriveNodeCacheProtocol, nodeRenameNotifier: WireDriveNodeRenameNotifier, - fileCache: any FileCache, - accentColorProvider: @escaping () -> WireAccentColor + fileCache: any FileCache ) { self.cellName = cellName self.nodesAPI = nodesAPI @@ -58,7 +56,6 @@ package struct RecycleBinContainer: View { self.nodeCache = nodeCache self.nodeRenameNotifier = nodeRenameNotifier self.fileCache = fileCache - self.accentColorProvider = accentColorProvider } var body: some View { @@ -137,8 +134,7 @@ package struct RecycleBinContainer: View { fileCache: fileCache, cellName: cellName, isBrowsing: false, - isRecycleBin: true, - accentColorProvider: accentColorProvider + isRecycleBin: true ) } } From cf231bf6d0a460268cae0223005702ed7d12aed8 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Fri, 24 Apr 2026 10:28:30 +0200 Subject: [PATCH 50/62] added .quickLookPreview($viewModel.viewingURL) to Versioning View again and removed the obsolete TODO --- .../Components/Files/FileVersioning/FileVersioningView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift index 2f5486ec30b..ff11ec9d2c2 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningView.swift @@ -65,7 +65,7 @@ struct FileVersioningView: View, Identifiable { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbarContent } .toolbarBackground(ColorTheme.Backgrounds.background.color, for: .navigationBar) - //.quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation + .quickLookPreview($viewModel.viewingURL) .refreshable { await viewModel.fetch() } .alert( item: $viewModel.alert, From bdffa973fc7506c7ae3a314d8f2de12f6a34815e Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Fri, 24 Apr 2026 12:10:03 +0200 Subject: [PATCH 51/62] refactoring notification mechanism to a closure --- .../Components/Files/FilesContentView.swift | 3 -- .../Files/FilesPreviewHelpers.swift | 3 +- .../Components/Files/FilesViewModel.swift | 38 +++++++------------ .../Files/Rename/FileRenameViewModel.swift | 9 +++-- .../ShareLink/ShareLinkView+ViewModel.swift | 10 ++++- .../Components/ShareLink/ShareLinkView.swift | 6 ++- 6 files changed, 35 insertions(+), 34 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 1955b91d265..b30e77414e9 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -114,9 +114,6 @@ package struct FilesContentView: View { ) .sheet( item: $viewModel.sheetNavigation, - onDismiss: { - Task { await viewModel.onSheetDismissed() } - }, content: { navigationItem in sheetContent(navigationItem) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 49db62a1b6b..2dff76a4a8a 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -141,7 +141,8 @@ extension FileRenameViewModel { filename: "foo.jpg", filepath: "5b189264-4300-4f21-8dca-7acd2b1925c7@wire.com/Image PNG-TEST3.png" ), - kind: kind + kind: kind, + onRenamed: {} ) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 6c58a7c18e4..366e775fa77 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -238,8 +238,7 @@ package final class FilesViewModel: ObservableObject { let useCases: UseCases let isBrowsing: Bool let isRecycleBin: Bool - let triggerReload: PassthroughSubject - var shouldReload: Bool = false //TODO: refactor and remove + let triggerReload: PassthroughSubject //TODO: check if needed let title: String? var showSearchBar: Bool { guard !isOffline else { @@ -483,13 +482,6 @@ package final class FilesViewModel: ObservableObject { setNavigation(newPath) } - func onSheetDismissed() async { - if shouldReload { - await reload() - shouldReload = false - } - } - var isInFolder: Bool { !navigationPath.isEmpty } @@ -584,10 +576,12 @@ package final class FilesViewModel: ObservableObject { createFileUseCase: useCases.createFile, onNodeCreated: { [weak self] createdNode in guard let self else { return } - shouldReload = true if case .file = target { isEditing = makeFileViewItem(node: createdNode) } + Task { + await reload() + } } ) @@ -763,15 +757,12 @@ package final class FilesViewModel: ObservableObject { filename: item.name, filepath: item.filePath, ), - kind: item.kind + kind: item.kind, + onRenamed: { [weak self] in + Task { await self?.reload() } + } ) - // to know whether we need to reload items. - viewModel.$didRename - .sink { [weak self] didRename in - self?.shouldReload = didRename - }.store(in: &subscriptions) - return FileRenameView(viewModel: viewModel) } @@ -790,19 +781,16 @@ package final class FilesViewModel: ObservableObject { getPublicLinkPasswordUseCase: WireDriveGetPublicLinkPasswordUseCase(keychain: Keychain()), storePublicLinkPasswordUseCase: WireDriveStorePublicLinkPasswordUseCase(keychain: Keychain()), deletePublicLinkPasswordUseCase: WireDriveDeletePublicLinkPasswordUseCase(keychain: Keychain()) - ) - ) - - viewModel.$publicLinkState - .sink { [weak self] state in + ), + onLinkStateChanged: { [weak self] state in switch state { case .enabled, .disabled: - self?.shouldReload = true + Task { await self?.reload() } default: break } - - }.store(in: &subscriptions) + } + ) return ShareLinkView(viewModel: viewModel) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Rename/FileRenameViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Rename/FileRenameViewModel.swift index 16e4e842f09..a268639e251 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Rename/FileRenameViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Rename/FileRenameViewModel.swift @@ -37,7 +37,8 @@ final class FileRenameViewModel: ObservableObject { @Published var errorMessage: String? @Published var isLoading: Bool = false @Published var isFocused: Bool = true - @Published var didRename: Bool = false + + let onRenamed: () -> Void var isSaveDisabled: Bool { errorMessage != nil || !isInputValid @@ -102,12 +103,14 @@ final class FileRenameViewModel: ObservableObject { init( renameNodeUseCase: any WireDriveRenameNodeUseCaseProtocol, model: Model, - kind: FilesViewItem.Kind + kind: FilesViewItem.Kind, + onRenamed: @escaping () -> Void ) { self.renameNodeUseCase = renameNodeUseCase self.filenameInput = kind == .folder ? model.filename : Self.removeFileExtension(from: model.filename) self.model = model self.kind = kind + self.onRenamed = onRenamed bindTextInput() } @@ -133,7 +136,7 @@ final class FileRenameViewModel: ObservableObject { isFolder: kind == .folder ) - didRename = true + onRenamed() } return true diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift index 9b31b09057e..a02cba9947a 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift @@ -76,9 +76,15 @@ extension ShareLinkView { // MARK: - UI State @Published var sheetNavigation: SheetNavigation? - @Published var publicLinkState: PublicLinkState + @Published var publicLinkState: PublicLinkState { + didSet { + onLinkStateChanged(publicLinkState) + } + } @Published var isPresentingError = false + let onLinkStateChanged: (PublicLinkState) -> Void + init( fileItem: FilesViewItem, context: DateFormattingContext = ( @@ -87,10 +93,12 @@ extension ShareLinkView { TimeZone.autoupdatingCurrent ), useCases: UseCases, + onLinkStateChanged: @escaping (PublicLinkState) -> Void ) { self.fileItem = fileItem self.context = context self.useCases = useCases + self.onLinkStateChanged = onLinkStateChanged self.publicLinkState = if let linkID = fileItem.publicLinkID { .initial(id: linkID) } else { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView.swift index 36ef82c74d1..e6baf8e903f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView.swift @@ -345,6 +345,10 @@ struct ShareLinkView: View { ) ShareLinkView( - viewModel: .init(fileItem: item, useCases: useCases) + viewModel: .init( + fileItem: item, + useCases: useCases, + onLinkStateChanged: { _ in } + ) ) } From 85c77294e463d8fa55af3820880d71600b1ba22c Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Fri, 24 Apr 2026 12:59:17 +0200 Subject: [PATCH 52/62] extracted some types into separate files --- .../Files/FilesViewModel+UseCases.swift | 91 ++++++++++++ .../Components/Files/FilesViewModel.swift | 134 +----------------- ...ViewItemView.swift => FilesItemView.swift} | 0 .../Components/Files/Item/FilesViewItem.swift | 77 ++++++++++ 4 files changed, 170 insertions(+), 132 deletions(-) create mode 100644 WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift rename WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/{FilesViewItemView.swift => FilesItemView.swift} (100%) create mode 100644 WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift new file mode 100644 index 00000000000..92378cc6787 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift @@ -0,0 +1,91 @@ +// +// 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/. +// + +package import WireMessagingDomain + +extension FilesViewModel { + package struct UseCases { + let fetchNodes: WireDriveFetchNodesPageUseCase + let deleteNodes: WireDriveDeleteNodesUseCase + let restoreNodes: WireDriveRestoreNodesUseCase + let renameNode: any WireDriveRenameNodeUseCaseProtocol + let updateTags: any WireDriveUpdateTagsUseCaseProtocol + let getTagSuggestions: any WireDriveGetTagSuggestionsUseCaseProtocol + let createFile: any WireDriveCreateFileUseCaseProtocol + let fetchNodeVersions: any WireDriveFetchNodeVersionsUseCaseProtocol + let restoreNodeVersion: any WireDriveRestoreNodeVersionUseCaseProtocol + let getEditingURL: WireDriveGetEditingURLUseCase + let getAsset: WireDriveGetAssetUseCase + let getPublicLinkData: any WireDriveGetPublicLinkDataUseCaseProtocol + let createPublicLink: WireDriveCreatePublicLinkUseCase + let deletePublicLink: WireDriveDeletePublicLinkUseCase + let updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase + let updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase + let getDriveConversations: any WireDriveGetConversationsUseCaseProtocol + let getFileTemplates: any WireDriveFetchFileTemplatesUseCaseProtocol + let makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase + let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase + let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase + + package init( + fetchNodes: WireDriveFetchNodesPageUseCase, + deleteNodes: WireDriveDeleteNodesUseCase, + restoreNodes: WireDriveRestoreNodesUseCase, + renameNode: any WireDriveRenameNodeUseCaseProtocol, + updateTags: any WireDriveUpdateTagsUseCaseProtocol, + getTagSuggestions: any WireDriveGetTagSuggestionsUseCaseProtocol, + createFile: any WireDriveCreateFileUseCaseProtocol, + fetchNodeVersions: any WireDriveFetchNodeVersionsUseCaseProtocol, + restoreNodeVersion: any WireDriveRestoreNodeVersionUseCaseProtocol, + getEditingURL: WireDriveGetEditingURLUseCase, + getAsset: WireDriveGetAssetUseCase, + getPublicLinkData: any WireDriveGetPublicLinkDataUseCaseProtocol, + createPublicLink: WireDriveCreatePublicLinkUseCase, + deletePublicLink: WireDriveDeletePublicLinkUseCase, + updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase, + updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase, + getDriveConversations: any WireDriveGetConversationsUseCaseProtocol, + getFileTemplates: any WireDriveFetchFileTemplatesUseCaseProtocol, + makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase, + removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase, + getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase + ) { + self.fetchNodes = fetchNodes + self.deleteNodes = deleteNodes + self.restoreNodes = restoreNodes + self.renameNode = renameNode + self.updateTags = updateTags + self.getTagSuggestions = getTagSuggestions + self.createFile = createFile + self.fetchNodeVersions = fetchNodeVersions + self.restoreNodeVersion = restoreNodeVersion + self.getEditingURL = getEditingURL + self.getAsset = getAsset + self.getPublicLinkData = getPublicLinkData + self.createPublicLink = createPublicLink + self.deletePublicLink = deletePublicLink + self.updatePublicLinkExpiration = updatePublicLinkExpiration + self.updatePublicLinkPassword = updatePublicLinkPassword + self.getDriveConversations = getDriveConversations + self.getFileTemplates = getFileTemplates + self.makeAssetAvailableOffline = makeAssetAvailableOffline + self.removeAssetAvailableOffline = removeAssetAvailableOffline + self.getOfflineAvailableAssets = getOfflineAvailableAssets + } + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 366e775fa77..e5a7a839054 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -17,70 +17,11 @@ // package import Combine -package import Foundation import SwiftUI import UniformTypeIdentifiers -package import WireFoundation +import WireFoundation import WireLogging package import WireMessagingDomain -import WireMessagingDomainSupport - -/// An item in the `FilesView`. -package struct FilesViewItem: Identifiable, Hashable { - - /// The kind of item - enum Kind { - - /// A file. - case file - - /// A folder. - case folder - } - - /// Identifier of this item on the wire drive backend. - package let id: UUID - - /// The ETag of this item. - let eTag: String - - /// The id of the topmost folder in the recycle bin, if the item is not at the root of the recycle bin. - /// Needed to restore items which are in folders rather than directly at the root of the recycle bin. - var recycleBinTopFolderId: UUID? - - /// The kind of this item - file or folder. - let kind: Kind - - /// The name of the this item. - let name: String - - /// The filepath of the item. - let filePath: String - - /// The name of the user who owns (uploaded) this file. - let ownedBy: String? - - /// The date when the item was last modified. - let modifiedAt: Date? - - /// The icon representing the item's type. - let icon: WireDriveFileType - - /// The tags that users have added for that file. - let tags: [String] - - /// Whether the item can be edited. - let isEditable: Bool - - /// The public link identifier if the item has a public link. - let publicLinkID: String? - - /// The name of the conversation the node is attached to. - let conversationName: String? - - /// The size of of this item - let size: UInt64? -} private typealias Strings = L10n.Localizable.Conversation.WireCells private typealias Accessibility = L10n.Accessibility.Conversation.WireCells @@ -155,77 +96,6 @@ package final class FilesViewModel: ObservableObject { } } - package struct UseCases { - package init( - fetchNodes: WireDriveFetchNodesPageUseCase, - deleteNodes: WireDriveDeleteNodesUseCase, - restoreNodes: WireDriveRestoreNodesUseCase, - renameNode: any WireDriveRenameNodeUseCaseProtocol, - updateTags: any WireDriveUpdateTagsUseCaseProtocol, - getTagSuggestions: any WireDriveGetTagSuggestionsUseCaseProtocol, - createFile: any WireDriveCreateFileUseCaseProtocol, - fetchNodeVersions: any WireDriveFetchNodeVersionsUseCaseProtocol, - restoreNodeVersion: any WireDriveRestoreNodeVersionUseCaseProtocol, - getEditingURL: WireDriveGetEditingURLUseCase, - getAsset: WireDriveGetAssetUseCase, - getPublicLinkData: any WireDriveGetPublicLinkDataUseCaseProtocol, - createPublicLink: WireDriveCreatePublicLinkUseCase, - deletePublicLink: WireDriveDeletePublicLinkUseCase, - updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase, - updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase, - getDriveConversations: any WireDriveGetConversationsUseCaseProtocol, - getFileTemplates: any WireDriveFetchFileTemplatesUseCaseProtocol, - makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase, - removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase, - getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase - ) { - - self.fetchNodes = fetchNodes - self.deleteNodes = deleteNodes - self.restoreNodes = restoreNodes - self.renameNode = renameNode - self.updateTags = updateTags - self.getTagSuggestions = getTagSuggestions - self.createFile = createFile - self.fetchNodeVersions = fetchNodeVersions - self.restoreNodeVersion = restoreNodeVersion - self.getEditingURL = getEditingURL - self.getAsset = getAsset - self.getPublicLinkData = getPublicLinkData - self.createPublicLink = createPublicLink - self.deletePublicLink = deletePublicLink - self.updatePublicLinkExpiration = updatePublicLinkExpiration - self.updatePublicLinkPassword = updatePublicLinkPassword - self.getDriveConversations = getDriveConversations - self.getFileTemplates = getFileTemplates - self.makeAssetAvailableOffline = makeAssetAvailableOffline - self.removeAssetAvailableOffline = removeAssetAvailableOffline - self.getOfflineAvailableAssets = getOfflineAvailableAssets - } - - let fetchNodes: WireDriveFetchNodesPageUseCase - let deleteNodes: WireDriveDeleteNodesUseCase - let restoreNodes: WireDriveRestoreNodesUseCase - let renameNode: any WireDriveRenameNodeUseCaseProtocol - let updateTags: any WireDriveUpdateTagsUseCaseProtocol - let getTagSuggestions: any WireDriveGetTagSuggestionsUseCaseProtocol - let createFile: any WireDriveCreateFileUseCaseProtocol - let fetchNodeVersions: any WireDriveFetchNodeVersionsUseCaseProtocol - let restoreNodeVersion: any WireDriveRestoreNodeVersionUseCaseProtocol - let getEditingURL: WireDriveGetEditingURLUseCase - let getAsset: WireDriveGetAssetUseCase - let getPublicLinkData: any WireDriveGetPublicLinkDataUseCaseProtocol - let createPublicLink: WireDriveCreatePublicLinkUseCase - let deletePublicLink: WireDriveDeletePublicLinkUseCase - let updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase - let updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase - let getDriveConversations: any WireDriveGetConversationsUseCaseProtocol - let getFileTemplates: any WireDriveFetchFileTemplatesUseCaseProtocol - let makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase - let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase - let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase - } - private let setNavigation: ([FilesViewItem]) -> Void private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol private let nodesRepository: any WireDriveNodesRepositoryProtocol @@ -238,7 +108,7 @@ package final class FilesViewModel: ObservableObject { let useCases: UseCases let isBrowsing: Bool let isRecycleBin: Bool - let triggerReload: PassthroughSubject //TODO: check if needed + let triggerReload: PassthroughSubject let title: String? var showSearchBar: Bool { guard !isOffline else { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemView.swift similarity index 100% rename from WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift rename to WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemView.swift diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift new file mode 100644 index 00000000000..64f0232f3f7 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift @@ -0,0 +1,77 @@ +// +// 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/. +// + +package import Foundation +import WireMessagingDomain + +/// An item in the `FilesView`. +package struct FilesViewItem: Identifiable, Hashable { + + /// The kind of item + enum Kind { + + /// A file. + case file + + /// A folder. + case folder + } + + /// Identifier of this item on the wire drive backend. + package let id: UUID + + /// The ETag of this item. + let eTag: String + + /// The id of the topmost folder in the recycle bin, if the item is not at the root of the recycle bin. + /// Needed to restore items which are in folders rather than directly at the root of the recycle bin. + var recycleBinTopFolderId: UUID? + + /// The kind of this item - file or folder. + let kind: Kind + + /// The name of the this item. + let name: String + + /// The filepath of the item. + let filePath: String + + /// The name of the user who owns (uploaded) this file. + let ownedBy: String? + + /// The date when the item was last modified. + let modifiedAt: Date? + + /// The icon representing the item's type. + let icon: WireDriveFileType + + /// The tags that users have added for that file. + let tags: [String] + + /// Whether the item can be edited. + let isEditable: Bool + + /// The public link identifier if the item has a public link. + let publicLinkID: String? + + /// The name of the conversation the node is attached to. + let conversationName: String? + + /// The size of of this item + let size: UInt64? +} From 06664f4ae0e63c4b18a1ae1f6d8de3be9c6872db Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Fri, 24 Apr 2026 16:55:51 +0200 Subject: [PATCH 53/62] refactored SheetNavigation to not use View as the payload --- .../UseCases/WireDriveCreateFileUseCase.swift | 11 +- .../Components/Files/FilesBrowserView.swift | 4 +- .../Components/Files/FilesView.swift | 25 ++-- .../Components/Files/FilesViewModel.swift | 110 +++++++++--------- 4 files changed, 79 insertions(+), 71 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift index 5b42fcfc991..81e2932ca0f 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift @@ -27,9 +27,18 @@ package enum WireDriveCreateFileUseCaseError: Error { /// Creates a file or a folder on the server. package struct WireDriveCreateFileUseCase: WireDriveCreateFileUseCaseProtocol { - package enum Target: Equatable { + package enum Target: Equatable, Identifiable { case folder case file(WireDriveFileTemplate) + + package var id: String { + switch self { + case .folder: + return "folder" + case let .file(template): + return "file:\(template.id)" + } + } } private let nodesRepository: any WireDriveNodesRepositoryProtocol diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift index 04768379a15..042cb2b50ca 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift @@ -63,8 +63,8 @@ private extension FilesBrowserView { @ViewBuilder func sheetContent(_ navigationItem: FilesViewModel.SheetNavigation) -> some View { switch navigationItem { - case let .shareLink(shareLinkView): - shareLinkView + case let .shareLink(item): + viewModel.makeShareLinkView(item: item) default: EmptyView() } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift index 05eadfaf3c1..a47d4258f65 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift @@ -178,13 +178,14 @@ private extension FilesView { // MARK: - Sheet Navigation private extension FilesView { - @ViewBuilder func sheetContent(_ navigationItem: FilesViewModel.SheetNavigation) -> some View { switch navigationItem { - case let .editTags(fileItem: fileItem): + case let .create(target): + viewModel.makeCreateFileView(target: target) + case let .editTags(fileItem: item): TagsEditView( - fileItem: fileItem, + fileItem: item, useCases: .init( updateTags: viewModel.useCases.updateTags, getSuggestions: viewModel.useCases.getTagSuggestions @@ -193,16 +194,14 @@ private extension FilesView { await viewModel.reload() } ) - case let .shareLink(shareLinkView): - shareLinkView - case let .renameFile(fileRenameView): - fileRenameView - case let .create(folderView): - folderView - case let .versionHistory(versionHistoryView): - versionHistoryView - case let .moveToFolder(fileItem): - viewModel.moveToFolderView(item: fileItem) + case let .shareLink(item): + viewModel.makeShareLinkView(item: item) + case let .renameFile(item): + viewModel.makeFileRenameView(item: item) + case let .versionHistory(item): + viewModel.makeFileVersioningView(item: item) + case let .moveToFolder(item): + viewModel.moveToFolderView(item: item) } } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index e5a7a839054..1a3a25e954c 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -39,27 +39,27 @@ package final class FilesViewModel: ObservableObject { } enum SheetNavigation: Identifiable { + case create(target: WireDriveCreateFileUseCase.Target) case editTags(fileItem: FilesViewItem) - case shareLink(view: ShareLinkView) + case shareLink(fileItem: FilesViewItem) case moveToFolder(fileItem: FilesViewItem) - case renameFile(view: FileRenameView) - case create(view: CreateFileView) - case versionHistory(view: FileVersioningView) + case renameFile(fileItem: FilesViewItem) + case versionHistory(fileItem: FilesViewItem) var id: String { switch self { - case let .editTags(fileItem: item): + case let .create(target): + "create(\(target.id))" + case let .editTags(item): "editTags(\(item.id))" - case let .shareLink(view): - "shareLink(\(view.id))" - case let .moveToFolder(fileItem): - "moveToFolder(\(fileItem.id))" - case let .create(view): - "create(\(view.id))" - case let .renameFile(view): - "renameFile(\(view.id))" - case let .versionHistory(view): - "versionHistory(\(view.id))" + case let .shareLink(item): + "shareLink(\(item.id))" + case let .moveToFolder(item): + "moveToFolder(\(item.id))" + case let .renameFile(item): + "renameFile(\(item.id))" + case let .versionHistory(item): + "versionHistory(\(item.id))" } } } @@ -261,15 +261,15 @@ package final class FilesViewModel: ObservableObject { case .restore: await restoreItem(item) case .rename: - sheetNavigation = .renameFile(view: makeFileRenameView(item: item)) + sheetNavigation = .renameFile(fileItem: item) case .editTags: sheetNavigation = .editTags(fileItem: item) case .shareLink: - sheetNavigation = .shareLink(view: makeShareLinkView(item: item)) + sheetNavigation = .shareLink(fileItem: item) case .moveToFolder: sheetNavigation = .moveToFolder(fileItem: item) case .showVersionHistory: - sheetNavigation = .versionHistory(view: makeFileVersioningView(item: item)) + sheetNavigation = .versionHistory(fileItem: item) case .edit: isEditing = item case .makeAvailableOffline: @@ -435,31 +435,35 @@ package final class FilesViewModel: ObservableObject { } func onCreate(target: WireDriveCreateFileUseCase.Target) { - guard let cellName else { return } - - // When navigation path is empty, file/folder is created at the root path (cell name) - let path = navigationPath.last?.filePath ?? cellName - - let viewModel = CreateFileViewModel( - creationTarget: target, - path: path, - createFileUseCase: useCases.createFile, - onNodeCreated: { [weak self] createdNode in - guard let self else { return } - if case .file = target { - isEditing = makeFileViewItem(node: createdNode) - } - Task { - await reload() + guard cellName != nil else { return } + sheetNavigation = .create(target: target) + } + + @ViewBuilder + func makeCreateFileView(target: WireDriveCreateFileUseCase.Target) -> some View { + if let cellName { + // When navigation path is empty, file/folder is created at the root path (cell name) + let path = navigationPath.last?.filePath ?? cellName + + let viewModel = CreateFileViewModel( + creationTarget: target, + path: path, + createFileUseCase: useCases.createFile, + onNodeCreated: { [weak self] createdNode in + guard let self else { return } + if case .file = target { + isEditing = makeFileViewItem(node: createdNode) + } + Task { + await reload() + } } - } - ) - - let createFileView = CreateFileView( - viewModel: viewModel - ) - - sheetNavigation = .create(view: createFileView) + ) + + CreateFileView( + viewModel: viewModel + ) + } } // MARK: - Private @@ -617,9 +621,8 @@ package final class FilesViewModel: ObservableObject { } } - private func makeFileRenameView( - item: FilesViewItem - ) -> FileRenameView { + @ViewBuilder + func makeFileRenameView(item: FilesViewItem) -> some View { let viewModel = FileRenameViewModel( renameNodeUseCase: useCases.renameNode, model: .init( @@ -633,13 +636,11 @@ package final class FilesViewModel: ObservableObject { } ) - return FileRenameView(viewModel: viewModel) + FileRenameView(viewModel: viewModel) } - private func makeShareLinkView( - item: FilesViewItem - ) -> ShareLinkView { - + @ViewBuilder + func makeShareLinkView(item: FilesViewItem) -> some View { let viewModel = ShareLinkView.ViewModel( fileItem: item, useCases: ShareLinkView.ViewModel.UseCases( @@ -662,12 +663,11 @@ package final class FilesViewModel: ObservableObject { } ) - return ShareLinkView(viewModel: viewModel) + ShareLinkView(viewModel: viewModel) } - private func makeFileVersioningView( - item: FilesViewItem - ) -> FileVersioningView { + @ViewBuilder + func makeFileVersioningView(item: FilesViewItem) -> some View { let viewModel = FileVersioningViewModel( nodeID: item.id, name: item.name, @@ -680,7 +680,7 @@ package final class FilesViewModel: ObservableObject { } ) - return FileVersioningView(viewModel: viewModel) + FileVersioningView(viewModel: viewModel) } // MARK: - Sorting & Filtering From d47d7ca353c32cae8f748b92e94b396d8f4af797 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Fri, 24 Apr 2026 17:22:20 +0200 Subject: [PATCH 54/62] refactored creation of multiple Views so that import SwiftUI isn't required in the FilesViewModel anymore. --- .../Components/Files/FilesBrowserView.swift | 2 +- .../Components/Files/FilesView.swift | 12 +- .../Components/Files/FilesViewModel.swift | 113 +++++++----------- 3 files changed, 51 insertions(+), 76 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift index 042cb2b50ca..be61c9bb868 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift @@ -64,7 +64,7 @@ private extension FilesBrowserView { func sheetContent(_ navigationItem: FilesViewModel.SheetNavigation) -> some View { switch navigationItem { case let .shareLink(item): - viewModel.makeShareLinkView(item: item) + ShareLinkView(viewModel: viewModel.shareLinkViewModel(item: item)) default: EmptyView() } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift index a47d4258f65..fe5ca14d606 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift @@ -68,7 +68,7 @@ package struct FilesView: View { Task { await viewModel.reload() } }, content: { item in - viewModel.editFileView(item: item) + EditFileView(viewModel: viewModel.editFileViewModel(item: item)) } ) } @@ -182,7 +182,7 @@ private extension FilesView { func sheetContent(_ navigationItem: FilesViewModel.SheetNavigation) -> some View { switch navigationItem { case let .create(target): - viewModel.makeCreateFileView(target: target) + CreateFileView(viewModel: viewModel.createFileViewModel(target: target)) case let .editTags(fileItem: item): TagsEditView( fileItem: item, @@ -195,13 +195,13 @@ private extension FilesView { } ) case let .shareLink(item): - viewModel.makeShareLinkView(item: item) + ShareLinkView(viewModel: viewModel.shareLinkViewModel(item: item)) case let .renameFile(item): - viewModel.makeFileRenameView(item: item) + FileRenameView(viewModel: viewModel.fileRenameViewModel(item: item)) case let .versionHistory(item): - viewModel.makeFileVersioningView(item: item) + FileVersioningView(viewModel: viewModel.fileVersioningViewModel(item: item)) case let .moveToFolder(item): - viewModel.moveToFolderView(item: item) + MoveToFolderView(viewModel: viewModel.moveToFolderViewModel(item: item)) } } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 1a3a25e954c..60baf91e4fe 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -17,7 +17,6 @@ // package import Combine -import SwiftUI import UniformTypeIdentifiers import WireFoundation import WireLogging @@ -244,7 +243,7 @@ package final class FilesViewModel: ObservableObject { /// Returns a `FilesItemViewModel` for the item at the given index. func itemViewModel(index: Int) -> FilesItemViewModel { - FilesItemViewModel( + .init( item: state.items[index], selectedSortingKey: sortingSelection.sortingKey, conversationName: isBrowsing ? state.items[index].conversationName : nil, @@ -283,39 +282,31 @@ package final class FilesViewModel: ObservableObject { ) } - func moveToFolderView(item: FilesViewItem) -> some View { + func moveToFolderViewModel(item: FilesViewItem) -> MoveToFolderViewModel { let containerPath = item.filePath.components(separatedBy: "/").dropLast().joined(separator: "/") - let nodesRepository = nodesRepository - let assetRepository = localAssetRepository - let useCases = useCases - return MoveToFolderView( - viewModel: MoveToFolderViewModel( - containerPath: containerPath, - nodeID: item.id, - nodeName: item.name, - onFinish: { [weak self] in - self?.sheetNavigation = nil - Task { await self?.reload(refreshing: true) } - }, + return .init( + containerPath: containerPath, + nodeID: item.id, + nodeName: item.name, + onFinish: { [weak self] in + self?.sheetNavigation = nil + Task { await self?.reload(refreshing: true) } + }, + nodesRepository: nodesRepository, + localAssetRepository: localAssetRepository, + moveNodeUseCase: WireDriveMoveNodeUseCase( nodesRepository: nodesRepository, - localAssetRepository: assetRepository, - moveNodeUseCase: WireDriveMoveNodeUseCase( - nodesRepository: nodesRepository, - localAssetRepository: assetRepository - ), - createFileUseCase: useCases.createFile - ) + localAssetRepository: localAssetRepository + ), + createFileUseCase: useCases.createFile ) } - func editFileView(item: FilesViewItem) -> some View { - let getEditingURLUseCase = useCases.getEditingURL - return EditFileView( - viewModel: EditFileViewModel( - nodeID: item.id, - fileName: item.name, - getEditingURLUseCase: getEditingURLUseCase - ) + func editFileViewModel(item: FilesViewItem) -> EditFileViewModel { + .init( + nodeID: item.id, + fileName: item.name, + getEditingURLUseCase: useCases.getEditingURL ) } @@ -439,31 +430,24 @@ package final class FilesViewModel: ObservableObject { sheetNavigation = .create(target: target) } - @ViewBuilder - func makeCreateFileView(target: WireDriveCreateFileUseCase.Target) -> some View { - if let cellName { - // When navigation path is empty, file/folder is created at the root path (cell name) - let path = navigationPath.last?.filePath ?? cellName - - let viewModel = CreateFileViewModel( - creationTarget: target, - path: path, - createFileUseCase: useCases.createFile, - onNodeCreated: { [weak self] createdNode in - guard let self else { return } - if case .file = target { - isEditing = makeFileViewItem(node: createdNode) - } - Task { - await reload() - } + func createFileViewModel(target: WireDriveCreateFileUseCase.Target) -> CreateFileViewModel { + // When navigation path is empty, file/folder is created at the root path (cell name) + let path = navigationPath.last?.filePath ?? cellName ?? "" + + return CreateFileViewModel( + creationTarget: target, + path: path, + createFileUseCase: useCases.createFile, + onNodeCreated: { [weak self] createdNode in + guard let self else { return } + if case .file = target { + isEditing = makeFileViewItem(node: createdNode) } - ) - - CreateFileView( - viewModel: viewModel - ) - } + Task { + await reload() + } + } + ) } // MARK: - Private @@ -621,9 +605,8 @@ package final class FilesViewModel: ObservableObject { } } - @ViewBuilder - func makeFileRenameView(item: FilesViewItem) -> some View { - let viewModel = FileRenameViewModel( + func fileRenameViewModel(item: FilesViewItem) -> FileRenameViewModel { + .init( renameNodeUseCase: useCases.renameNode, model: .init( nodeID: item.id, @@ -635,13 +618,10 @@ package final class FilesViewModel: ObservableObject { Task { await self?.reload() } } ) - - FileRenameView(viewModel: viewModel) } - @ViewBuilder - func makeShareLinkView(item: FilesViewItem) -> some View { - let viewModel = ShareLinkView.ViewModel( + func shareLinkViewModel(item: FilesViewItem) -> ShareLinkView.ViewModel { + .init( fileItem: item, useCases: ShareLinkView.ViewModel.UseCases( getLinkData: useCases.getPublicLinkData, @@ -662,13 +642,10 @@ package final class FilesViewModel: ObservableObject { } } ) - - ShareLinkView(viewModel: viewModel) } - @ViewBuilder - func makeFileVersioningView(item: FilesViewItem) -> some View { - let viewModel = FileVersioningViewModel( + func fileVersioningViewModel(item: FilesViewItem) -> FileVersioningViewModel { + .init( nodeID: item.id, name: item.name, eTag: item.eTag, @@ -679,8 +656,6 @@ package final class FilesViewModel: ObservableObject { Task { await self?.reload() } } ) - - FileVersioningView(viewModel: viewModel) } // MARK: - Sorting & Filtering From fb28997b51ada00e1b1f01cea4c839789d8da238 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Fri, 24 Apr 2026 17:45:58 +0200 Subject: [PATCH 55/62] moving all functions which create ViewModels into a separate file --- .../Components/Files/FilesContentView.swift | 2 +- .../FilesViewModel+make_ViewModels.swift | 173 ++++++++++++++ .../Components/Files/FilesViewModel.swift | 223 +++--------------- 3 files changed, 207 insertions(+), 191 deletions(-) create mode 100644 WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index b30e77414e9..5af155977bf 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -63,7 +63,7 @@ package struct FilesContentView: View { .frame(height: isFilterBarPresented ? nil : 0) .padding(.bottom, isFilterBarPresented ? 15 : 0) - FilesSortingView(viewModel: viewModel.makeFilesSortingViewModel()) + FilesSortingView(viewModel: viewModel.filesSortingViewModel()) } .padding(.top, 4) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift new file mode 100644 index 00000000000..f45da7232ff --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift @@ -0,0 +1,173 @@ +// +// 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 WireFoundation +import WireMessagingDomain + +extension FilesViewModel { + func itemViewModel(index: Int) -> FilesItemViewModel { + .init( + item: state.items[index], + selectedSortingKey: sortingSelection.sortingKey, + conversationName: isBrowsing ? state.items[index].conversationName : nil, + localAssetRepository: localAssetRepository, + onItemAction: { [weak self] action, item in + guard let self else { return } + switch action { + case .primaryAction: + await performPrimaryAction(item: item) + case .deleteToRecycleBin: + await deleteItem(item, permanently: false) + case .deletePermanently: + await deleteItem(item, permanently: true) + case .restore: + await restoreItem(item) + case .rename: + sheetNavigation = .renameFile(fileItem: item) + case .editTags: + sheetNavigation = .editTags(fileItem: item) + case .shareLink: + sheetNavigation = .shareLink(fileItem: item) + case .moveToFolder: + sheetNavigation = .moveToFolder(fileItem: item) + case .showVersionHistory: + sheetNavigation = .versionHistory(fileItem: item) + case .edit: + isEditing = item + case .makeAvailableOffline: + makeAssetAvailableOffline(item: item) + case .removeAvailableOffline: + removeAssetAvailableOffline(item: item) + } + }, + isBrowsing: isBrowsing, + isInRecycleBin: isRecycleBin, + ) + } + + func createFileViewModel(target: WireDriveCreateFileUseCase.Target) -> CreateFileViewModel { + // When navigation path is empty, file/folder is created at the root path (cell name) + let path = navigationPath.last?.filePath ?? cellName ?? "" + + return CreateFileViewModel( + creationTarget: target, + path: path, + createFileUseCase: useCases.createFile, + onNodeCreated: { [weak self] createdNode in + guard let self else { return } + if case .file = target { + isEditing = makeFileViewItem(node: createdNode) + } + Task { + await reload() + } + } + ) + } + + func moveToFolderViewModel(item: FilesViewItem) -> MoveToFolderViewModel { + let containerPath = item.filePath.components(separatedBy: "/").dropLast().joined(separator: "/") + return .init( + containerPath: containerPath, + nodeID: item.id, + nodeName: item.name, + onFinish: { [weak self] in + self?.sheetNavigation = nil + Task { await self?.reload(refreshing: true) } + }, + nodesRepository: nodesRepository, + localAssetRepository: localAssetRepository, + moveNodeUseCase: WireDriveMoveNodeUseCase( + nodesRepository: nodesRepository, + localAssetRepository: localAssetRepository + ), + createFileUseCase: useCases.createFile + ) + } + + func editFileViewModel(item: FilesViewItem) -> EditFileViewModel { + .init( + nodeID: item.id, + fileName: item.name, + getEditingURLUseCase: useCases.getEditingURL + ) + } + + func fileRenameViewModel(item: FilesViewItem) -> FileRenameViewModel { + .init( + renameNodeUseCase: useCases.renameNode, + model: .init( + nodeID: item.id, + filename: item.name, + filepath: item.filePath, + ), + kind: item.kind, + onRenamed: { [weak self] in + Task { await self?.reload() } + } + ) + } + + func shareLinkViewModel(item: FilesViewItem) -> ShareLinkView.ViewModel { + .init( + fileItem: item, + useCases: ShareLinkView.ViewModel.UseCases( + getLinkData: useCases.getPublicLinkData, + createPublicLink: useCases.createPublicLink, + deletePublicLink: useCases.deletePublicLink, + updatePublicLinkExpiration: useCases.updatePublicLinkExpiration, + updatePublicLinkPassword: useCases.updatePublicLinkPassword, + getPublicLinkPasswordUseCase: WireDriveGetPublicLinkPasswordUseCase(keychain: Keychain()), + storePublicLinkPasswordUseCase: WireDriveStorePublicLinkPasswordUseCase(keychain: Keychain()), + deletePublicLinkPasswordUseCase: WireDriveDeletePublicLinkPasswordUseCase(keychain: Keychain()) + ), + onLinkStateChanged: { [weak self] state in + switch state { + case .enabled, .disabled: + Task { await self?.reload() } + default: + break + } + } + ) + } + + func fileVersioningViewModel(item: FilesViewItem) -> FileVersioningViewModel { + .init( + nodeID: item.id, + name: item.name, + eTag: item.eTag, + fetchNodeVersionsUseCase: useCases.fetchNodeVersions, + restoreNodeVersionUseCase: useCases.restoreNodeVersion, + getAssetUseCase: useCases.getAsset, + onVersionRestored: { [weak self] in + Task { await self?.reload() } + } + ) + } + + func filesSortingViewModel() -> FilesSortingViewModel { + FilesSortingViewModel( + isBrowsing: isBrowsing, + subfolderName: navigationPath.last?.name + ) { [weak self] sortingSelection in + self?.sortingSelection = sortingSelection + Task { await self?.reload() } + } + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 60baf91e4fe..f3ade3e596c 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -18,15 +18,14 @@ package import Combine import UniformTypeIdentifiers -import WireFoundation import WireLogging package import WireMessagingDomain private typealias Strings = L10n.Localizable.Conversation.WireCells private typealias Accessibility = L10n.Accessibility.Conversation.WireCells -@MainActor /// View model for the `FilesView`. +@MainActor package final class FilesViewModel: ObservableObject { private typealias LoadItemsTask = Task<(items: [FilesViewItem], isLastPage: Bool), any Error> @@ -96,19 +95,21 @@ package final class FilesViewModel: ObservableObject { } private let setNavigation: ([FilesViewItem]) -> Void - private let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol - private let nodesRepository: any WireDriveNodesRepositoryProtocol private let fileCache: any FileCache - private let cellName: String? // nil when browsing all files private var subscriptions = Set() - private let navigationPath: [FilesViewItem] - private var sortingSelection: FilesSortingViewModel.SortingSelection = .default + + let cellName: String? // nil when browsing all files + let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol + let nodesRepository: any WireDriveNodesRepositoryProtocol + let navigationPath: [FilesViewItem] + var sortingSelection: FilesSortingViewModel.SortingSelection = .default let useCases: UseCases let isBrowsing: Bool let isRecycleBin: Bool let triggerReload: PassthroughSubject let title: String? + var showSearchBar: Bool { guard !isOffline else { return false @@ -241,75 +242,6 @@ package final class FilesViewModel: ObservableObject { } } - /// Returns a `FilesItemViewModel` for the item at the given index. - func itemViewModel(index: Int) -> FilesItemViewModel { - .init( - item: state.items[index], - selectedSortingKey: sortingSelection.sortingKey, - conversationName: isBrowsing ? state.items[index].conversationName : nil, - localAssetRepository: localAssetRepository, - onItemAction: { [weak self] action, item in - guard let self else { return } - switch action { - case .primaryAction: - await performPrimaryAction(item: item) - case .deleteToRecycleBin: - await deleteItem(item, permanently: false) - case .deletePermanently: - await deleteItem(item, permanently: true) - case .restore: - await restoreItem(item) - case .rename: - sheetNavigation = .renameFile(fileItem: item) - case .editTags: - sheetNavigation = .editTags(fileItem: item) - case .shareLink: - sheetNavigation = .shareLink(fileItem: item) - case .moveToFolder: - sheetNavigation = .moveToFolder(fileItem: item) - case .showVersionHistory: - sheetNavigation = .versionHistory(fileItem: item) - case .edit: - isEditing = item - case .makeAvailableOffline: - makeAssetAvailableOffline(item: item) - case .removeAvailableOffline: - removeAssetAvailableOffline(item: item) - } - }, - isBrowsing: isBrowsing, - isInRecycleBin: isRecycleBin, - ) - } - - func moveToFolderViewModel(item: FilesViewItem) -> MoveToFolderViewModel { - let containerPath = item.filePath.components(separatedBy: "/").dropLast().joined(separator: "/") - return .init( - containerPath: containerPath, - nodeID: item.id, - nodeName: item.name, - onFinish: { [weak self] in - self?.sheetNavigation = nil - Task { await self?.reload(refreshing: true) } - }, - nodesRepository: nodesRepository, - localAssetRepository: localAssetRepository, - moveNodeUseCase: WireDriveMoveNodeUseCase( - nodesRepository: nodesRepository, - localAssetRepository: localAssetRepository - ), - createFileUseCase: useCases.createFile - ) - } - - func editFileViewModel(item: FilesViewItem) -> EditFileViewModel { - .init( - nodeID: item.id, - fileName: item.name, - getEditingURLUseCase: useCases.getEditingURL - ) - } - /// If item is a folder, navigates into it. If it's a file, downloads the related asset if necessary or views it or /// cancels the download. func performPrimaryAction(item: FilesViewItem) async { @@ -347,8 +279,6 @@ package final class FilesViewModel: ObservableObject { !navigationPath.isEmpty } - // MARK: - Private - private func fetchTemplates() { Task { templates = try await useCases.getFileTemplates.invoke() @@ -430,28 +360,6 @@ package final class FilesViewModel: ObservableObject { sheetNavigation = .create(target: target) } - func createFileViewModel(target: WireDriveCreateFileUseCase.Target) -> CreateFileViewModel { - // When navigation path is empty, file/folder is created at the root path (cell name) - let path = navigationPath.last?.filePath ?? cellName ?? "" - - return CreateFileViewModel( - creationTarget: target, - path: path, - createFileUseCase: useCases.createFile, - onNodeCreated: { [weak self] createdNode in - guard let self else { return } - if case .file = target { - isEditing = makeFileViewItem(node: createdNode) - } - Task { - await reload() - } - } - ) - } - - // MARK: - Private - private func cancelLoad() { loadMoreTask?.cancel() loadMoreTask = nil @@ -534,28 +442,7 @@ package final class FilesViewModel: ObservableObject { return (items, isLastPage) } - private func deleteItem(_ asset: FilesViewItem, permanently: Bool) async { - guard state.isLoaded else { - WireLogger.wireDrive.error("Attempt to delete asset while not visible", attributes: .safePublic) - return - } - - var currentItems = state.items - currentItems.removeAll { $0.id == asset.id } - state = .received(items: currentItems.latestModified()) - - do { - try await useCases.deleteNodes.invoke(nodeIDs: [asset.id], deletePermanently: permanently) - } catch { - guard state.isLoaded else { return } - - var currentItems = state.items - currentItems.append(asset) - state = .received(items: currentItems.latestModified()) - } - } - - private nonisolated func makeFileViewItem(node: WireDriveNode) -> FilesViewItem? { + nonisolated func makeFileViewItem(node: WireDriveNode) -> FilesViewItem? { guard let eTag = node.eTag else { return nil } let url = URL(string: node.path) @@ -579,10 +466,10 @@ package final class FilesViewModel: ObservableObject { size: node.size ) } - - private func restoreItem(_ asset: FilesViewItem) async { + + func deleteItem(_ asset: FilesViewItem, permanently: Bool) async { guard state.isLoaded else { - WireLogger.wireDrive.error("Attempt to restore asset while not visible", attributes: .safePublic) + WireLogger.wireDrive.error("Attempt to delete asset while not visible", attributes: .safePublic) return } @@ -590,12 +477,8 @@ package final class FilesViewModel: ObservableObject { currentItems.removeAll { $0.id == asset.id } state = .received(items: currentItems.latestModified()) - let nodeIdToRestore = navigationPath.last?.recycleBinTopFolderId ?? asset.id - do { - try await useCases.restoreNodes.invoke(nodeIDs: [nodeIdToRestore]) - - setNavigation([]) + try await useCases.deleteNodes.invoke(nodeIDs: [asset.id], deletePermanently: permanently) } catch { guard state.isLoaded else { return } @@ -605,68 +488,28 @@ package final class FilesViewModel: ObservableObject { } } - func fileRenameViewModel(item: FilesViewItem) -> FileRenameViewModel { - .init( - renameNodeUseCase: useCases.renameNode, - model: .init( - nodeID: item.id, - filename: item.name, - filepath: item.filePath, - ), - kind: item.kind, - onRenamed: { [weak self] in - Task { await self?.reload() } - } - ) - } + func restoreItem(_ asset: FilesViewItem) async { + guard state.isLoaded else { + WireLogger.wireDrive.error("Attempt to restore asset while not visible", attributes: .safePublic) + return + } - func shareLinkViewModel(item: FilesViewItem) -> ShareLinkView.ViewModel { - .init( - fileItem: item, - useCases: ShareLinkView.ViewModel.UseCases( - getLinkData: useCases.getPublicLinkData, - createPublicLink: useCases.createPublicLink, - deletePublicLink: useCases.deletePublicLink, - updatePublicLinkExpiration: useCases.updatePublicLinkExpiration, - updatePublicLinkPassword: useCases.updatePublicLinkPassword, - getPublicLinkPasswordUseCase: WireDriveGetPublicLinkPasswordUseCase(keychain: Keychain()), - storePublicLinkPasswordUseCase: WireDriveStorePublicLinkPasswordUseCase(keychain: Keychain()), - deletePublicLinkPasswordUseCase: WireDriveDeletePublicLinkPasswordUseCase(keychain: Keychain()) - ), - onLinkStateChanged: { [weak self] state in - switch state { - case .enabled, .disabled: - Task { await self?.reload() } - default: - break - } - } - ) - } + var currentItems = state.items + currentItems.removeAll { $0.id == asset.id } + state = .received(items: currentItems.latestModified()) - func fileVersioningViewModel(item: FilesViewItem) -> FileVersioningViewModel { - .init( - nodeID: item.id, - name: item.name, - eTag: item.eTag, - fetchNodeVersionsUseCase: useCases.fetchNodeVersions, - restoreNodeVersionUseCase: useCases.restoreNodeVersion, - getAssetUseCase: useCases.getAsset, - onVersionRestored: { [weak self] in - Task { await self?.reload() } - } - ) - } + let nodeIdToRestore = navigationPath.last?.recycleBinTopFolderId ?? asset.id - // MARK: - Sorting & Filtering + do { + try await useCases.restoreNodes.invoke(nodeIDs: [nodeIdToRestore]) + + setNavigation([]) + } catch { + guard state.isLoaded else { return } - func makeFilesSortingViewModel() -> FilesSortingViewModel { - FilesSortingViewModel( - isBrowsing: isBrowsing, - subfolderName: navigationPath.last?.name - ) { [weak self] sortingSelection in - self?.sortingSelection = sortingSelection - Task { await self?.reload() } + var currentItems = state.items + currentItems.append(asset) + state = .received(items: currentItems.latestModified()) } } @@ -683,7 +526,7 @@ package final class FilesViewModel: ObservableObject { // MARK: - Offline mode - private func makeAssetAvailableOffline(item: FilesViewItem) { + func makeAssetAvailableOffline(item: FilesViewItem) { Task { do { try await useCases.makeAssetAvailableOffline.invoke(nodeID: item.id) @@ -693,7 +536,7 @@ package final class FilesViewModel: ObservableObject { } } - private func removeAssetAvailableOffline(item: FilesViewItem) { + func removeAssetAvailableOffline(item: FilesViewItem) { Task { do { try await useCases.removeAssetAvailableOffline.invoke(nodeID: item.id) From 0675d331f4e6dd107dbf77ae4fb332b9ff95a089 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Mon, 27 Apr 2026 10:18:37 +0200 Subject: [PATCH 56/62] moved WireDriveNode-to-FilesViewItem mapping function to FilesViewItem --- .../FilesViewModel+make_ViewModels.swift | 2 +- .../Components/Files/FilesViewModel.swift | 36 +++---------------- .../Components/Files/Item/FilesViewItem.swift | 28 +++++++++++++++ 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift index f45da7232ff..203374f9b9f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift @@ -71,7 +71,7 @@ extension FilesViewModel { onNodeCreated: { [weak self] createdNode in guard let self else { return } if case .file = target { - isEditing = makeFileViewItem(node: createdNode) + isEditing = FilesViewItem.fromNode(createdNode) } Task { await reload() diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index f3ade3e596c..842c54aa8b6 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -17,7 +17,7 @@ // package import Combine -import UniformTypeIdentifiers +import Foundation import WireLogging package import WireMessagingDomain @@ -31,7 +31,6 @@ package final class FilesViewModel: ObservableObject { private typealias LoadItemsTask = Task<(items: [FilesViewItem], isLastPage: Bool), any Error> private enum Constants { - /// How close to the end of the list before loading more items. static let loadMoreThreshold = 5 } @@ -69,7 +68,6 @@ package final class FilesViewModel: ObservableObject { } enum State: Equatable { - case loading case received(items: [FilesViewItem]) case pending // drive is not ready yet @@ -174,6 +172,8 @@ package final class FilesViewModel: ObservableObject { .first(where: \.isSelfUser)?.id } + //MARK: init + package init( useCases: UseCases, title: String? = nil, @@ -217,7 +217,6 @@ package final class FilesViewModel: ObservableObject { /// /// Cancels any ongoing load operation and starts a new one. /// When `refreshing` is `true`, the current state is preserved since loading is managed by the system. - func reload(refreshing: Bool = false) async { guard networkMonitor.currentStatus != nil else { return } @@ -425,7 +424,7 @@ package final class FilesViewModel: ObservableObject { hasMore = false } - private nonisolated func fetchItems( + private func fetchItems( offset: Int ) async throws -> (items: [FilesViewItem], isLastPage: Bool) { let (nodes, isLastPage) = try await useCases.fetchNodes.invoke( @@ -436,36 +435,11 @@ package final class FilesViewModel: ObservableObject { offset: offset ) - let items: [FilesViewItem] = nodes.compactMap(makeFileViewItem(node:)) + let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) try Task.checkCancellation() return (items, isLastPage) } - - nonisolated func makeFileViewItem(node: WireDriveNode) -> FilesViewItem? { - guard let eTag = node.eTag else { return nil } - - let url = URL(string: node.path) - let kind: FilesViewItem.Kind = node.type == .collection ? .folder : .file - return FilesViewItem( - id: node.id, - eTag: eTag, - kind: kind, - name: url?.lastPathComponent ?? node.path, - filePath: node.path, - ownedBy: node.ownerUserName, - modifiedAt: node.modified, - icon: kind == .folder ? .folder : .make( - type: node.mimeType.map { UTType(mimeType: $0) } ?? nil, - fileExtension: url?.pathExtension - ), - tags: node.tags, - isEditable: node.isEditable, - publicLinkID: node.publicLinkID?.string, - conversationName: node.conversation?.name, - size: node.size - ) - } func deleteItem(_ asset: FilesViewItem, permanently: Bool) async { guard state.isLoaded else { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift index 64f0232f3f7..3f8059853bd 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift @@ -18,6 +18,7 @@ package import Foundation import WireMessagingDomain +import UniformTypeIdentifiers /// An item in the `FilesView`. package struct FilesViewItem: Identifiable, Hashable { @@ -75,3 +76,30 @@ package struct FilesViewItem: Identifiable, Hashable { /// The size of of this item let size: UInt64? } + +extension FilesViewItem { + static func fromNode(_ node: WireDriveNode) -> FilesViewItem? { + guard let eTag = node.eTag else { return nil } + + let url = URL(string: node.path) + let kind: FilesViewItem.Kind = node.type == .collection ? .folder : .file + return FilesViewItem( + id: node.id, + eTag: eTag, + kind: kind, + name: url?.lastPathComponent ?? node.path, + filePath: node.path, + ownedBy: node.ownerUserName, + modifiedAt: node.modified, + icon: kind == .folder ? .folder : .make( + type: node.mimeType.map { UTType(mimeType: $0) } ?? nil, + fileExtension: url?.pathExtension + ), + tags: node.tags, + isEditable: node.isEditable, + publicLinkID: node.publicLinkID?.string, + conversationName: node.conversation?.name, + size: node.size + ) + } +} From 613e71eab0af380ada732516986db3dce4733ab9 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Mon, 27 Apr 2026 10:44:51 +0200 Subject: [PATCH 57/62] removed fileCache: any FileCache (not needed) --- .../Sources/WireMessagingAssembly/WireMessagingFactory.swift | 1 - .../WireDrive/Components/Files/FilesPreviewHelpers.swift | 1 - .../WireDrive/Components/Files/FilesViewContainer.swift | 1 - .../Components/Files/FilesViewModel+make_ViewModels.swift | 4 ++-- .../WireDrive/Components/Files/FilesViewModel.swift | 3 --- .../WireDrive/Components/Files/RecycleBinContainer.swift | 1 - 6 files changed, 2 insertions(+), 9 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index 219ae3df432..c4d4232cc16 100644 --- a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift +++ b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift @@ -230,7 +230,6 @@ public extension WireMessagingFactory { isCellsStatePending: false, localAssetRepository: localAssetRepository, nodesRepository: nodesAPI, - fileCache: fileCache, isBrowsing: true ) ) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index 2dff76a4a8a..e49642f2097 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift @@ -115,7 +115,6 @@ extension FilesViewModel { isCellsStatePending: false, localAssetRepository: localAssetRepository, nodesRepository: previewNodesRepository(), - fileCache: cache, cellName: "2b7d1f2c-74bf-4256-a746-8112e006dcd6", isBrowsing: isBrowsing ) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index 30bcbb06ad9..d488550fceb 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -170,7 +170,6 @@ package struct FilesViewContainer: View { isCellsStatePending: isCellsStatePending, localAssetRepository: localAssetRepository, nodesRepository: nodesRepository, - fileCache: fileCache, cellName: cellName, isBrowsing: false, isRecycleBin: false, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift index 203374f9b9f..bb83411f2fe 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift @@ -64,7 +64,7 @@ extension FilesViewModel { // When navigation path is empty, file/folder is created at the root path (cell name) let path = navigationPath.last?.filePath ?? cellName ?? "" - return CreateFileViewModel( + return .init( creationTarget: target, path: path, createFileUseCase: useCases.createFile, @@ -162,7 +162,7 @@ extension FilesViewModel { } func filesSortingViewModel() -> FilesSortingViewModel { - FilesSortingViewModel( + .init( isBrowsing: isBrowsing, subfolderName: navigationPath.last?.name ) { [weak self] sortingSelection in diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 842c54aa8b6..92f9301dad4 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -93,7 +93,6 @@ package final class FilesViewModel: ObservableObject { } private let setNavigation: ([FilesViewItem]) -> Void - private let fileCache: any FileCache private var subscriptions = Set() let cellName: String? // nil when browsing all files @@ -182,7 +181,6 @@ package final class FilesViewModel: ObservableObject { isCellsStatePending: Bool, localAssetRepository: any WireDriveLocalAssetRepositoryProtocol, nodesRepository: any WireDriveNodesRepositoryProtocol, - fileCache: any FileCache, cellName: String? = nil, isBrowsing: Bool, isRecycleBin: Bool = false, @@ -195,7 +193,6 @@ package final class FilesViewModel: ObservableObject { self.setNavigation = setNavigation self.localAssetRepository = localAssetRepository self.nodesRepository = nodesRepository - self.fileCache = fileCache self.cellName = cellName self.state = isCellsStatePending ? .pending : .loading self.isBrowsing = isBrowsing diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index af4c0d8c85f..8f437faa51f 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift @@ -131,7 +131,6 @@ package struct RecycleBinContainer: View { isCellsStatePending: isCellsStatePending, localAssetRepository: localAssetRepository, nodesRepository: nodesRepository, - fileCache: fileCache, cellName: cellName, isBrowsing: false, isRecycleBin: true From 0750a76df320c506b56e067b2da868e3ea167c46 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Mon, 27 Apr 2026 11:36:18 +0200 Subject: [PATCH 58/62] cleanup, reordering, grouping --- .../Components/Files/FilesViewModel.swift | 334 +++++++++--------- 1 file changed, 173 insertions(+), 161 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 92f9301dad4..0d8c96bee2b 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -94,31 +94,16 @@ package final class FilesViewModel: ObservableObject { private let setNavigation: ([FilesViewItem]) -> Void private var subscriptions = Set() - let cellName: String? // nil when browsing all files let localAssetRepository: any WireDriveLocalAssetRepositoryProtocol let nodesRepository: any WireDriveNodesRepositoryProtocol let navigationPath: [FilesViewItem] var sortingSelection: FilesSortingViewModel.SortingSelection = .default - let useCases: UseCases let isBrowsing: Bool let isRecycleBin: Bool let triggerReload: PassthroughSubject let title: String? - - var showSearchBar: Bool { - guard !isOffline else { - return false - } - - return switch state { - case .loading, .received: - true - case .pending, .error: - false - } - } var navigationTitle: String { if let title { @@ -132,21 +117,10 @@ package final class FilesViewModel: ObservableObject { } } - /// Whether the view model is currently loading items. - var isLoading: Bool { - loadMoreTask != nil - } - - var networkStatus: NetworkMonitor.NetworkStatus? { - networkMonitor.currentStatus - } - - var isOffline: Bool { - networkMonitor.currentStatus == .disconnected - } - - var shouldShowOfflineBar: Bool { - isOffline && !state.items.isEmpty + private var selfUserID: String? { + conversations + .flatMap(\.participants) + .first(where: \.isSelfUser)?.id } @Published var hasMore = true @@ -156,20 +130,11 @@ package final class FilesViewModel: ObservableObject { @Published var viewingURL: URL? @Published var state: State @Published var sheetNavigation: SheetNavigation? - @Published var createView: CreateFileView? - @Published var fileRenameView: FileRenameView? @Published var isEditing: FilesViewItem? @Published var templates: [WireDriveFileTemplate] = [] @Published var conversations: [WireDriveConversation] = [] @Published var filtersSelection: FilesFilteringViewModel.FiltersSelection = .empty - - @Published private var networkMonitor: NetworkMonitor - - private var selfUserID: String? { - conversations - .flatMap(\.participants) - .first(where: \.isSelfUser)?.id - } + @Published var networkMonitor: NetworkMonitor //MARK: init @@ -200,13 +165,46 @@ package final class FilesViewModel: ObservableObject { self.triggerReload = triggerReload self.networkMonitor = networkMonitor } + + // MARK: setup func setup() async { - await fetchConversations() - bindSearch() + fetchConversations() fetchTemplates() + bindSearch() Task { await reload() } } + + private func fetchTemplates() { + Task { + templates = try await useCases.getFileTemplates.invoke() + } + } + + private func fetchConversations() { + Task { + let allDriveConversations = await useCases.getDriveConversations.invoke() + + if let cellName { + conversations = allDriveConversations.filter { $0.id == cellName } + } else { + conversations = allDriveConversations + } + } + } + + private func bindSearch() { + $searchText + .removeDuplicates() + .dropFirst() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + Task { await self?.reload() } + } + .store(in: &subscriptions) + } + + // MARK: item list loading /// Reloads the items, clearing any previously loaded items. /// - Parameters: @@ -237,18 +235,96 @@ package final class FilesViewModel: ObservableObject { await loadMore() } } + + /// Whether the view model is currently loading items. + var isLoading: Bool { + loadMoreTask != nil + } + + private func cancelLoad() { + loadMoreTask?.cancel() + loadMoreTask = nil + } + + private func loadMore(refreshing: Bool = false) async { + if isOffline { + await loadOfflineFiles() + } else { + await loadOnlineFiles(refreshing: refreshing) + } + } - /// If item is a folder, navigates into it. If it's a file, downloads the related asset if necessary or views it or - /// cancels the download. - func performPrimaryAction(item: FilesViewItem) async { - switch item.kind { - case .file: - await viewAsset(item: item) - case .folder: - openFolder(item: item) + private func loadOnlineFiles(refreshing: Bool) async { + guard loadMoreTask == nil else { return } + + let offset = refreshing ? 0 : state.items.count + let task = Task { try await fetchItems(offset: offset) } + + loadMoreTask = task + do { + let (newItems, isLastPage) = try await task.value + let receivedItems = refreshing ? newItems : state.items + newItems + state = .received(items: receivedItems.latestModified()) + hasMore = !isLastPage + } catch is CancellationError { + return // developer-driven error, discard + } catch { + if state.items.isEmpty { + state = .error(isConnectionError: error.isNoInternetError) + } else { + if error.isNoInternetError { + // no-op, offline bar is dynamically shown/hidden on top of the list + } else { + alert = .unknownError + } + } + hasMore = state.items.isEmpty ? true : hasMore + } + loadMoreTask = nil + } + + private func loadOfflineFiles() async { + guard !isRecycleBin else { + return state = .received(items: []) + } + + do { + let actionInput = LoadOfflineAvailableFilesUIAction.Input( + conversationName: cellName != nil ? conversations.first?.name : nil, + assetsPath: navigationPath.last?.filePath, + getAsset: useCases.getAsset, + getOfflineAvailableAssets: useCases.getOfflineAvailableAssets + ) + + let items = try await LoadOfflineAvailableFilesUIAction(input: actionInput)() + + state = .received(items: items) + } catch { + alert = .unknownError + WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") } + hasMore = false } + private func fetchItems( + offset: Int + ) async throws -> (items: [FilesViewItem], isLastPage: Bool) { + let (nodes, isLastPage) = try await useCases.fetchNodes.invoke( + searchTerm: searchText.isEmpty ? nil : searchText, + metafilter: filtersSelection.toDomainModel(selfUserID: selfUserID), + sortField: sortingSelection.sortingKey?.sortField, + sortDirDesc: sortingSelection.sortingOrder == .descending, + offset: offset + ) + + let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) + + try Task.checkCancellation() + return (items, isLastPage) + } + + // MARK: folders + var folderMenuOptions: [FolderMenuOption] { var options: [FolderMenuOption] = navigationPath.reversed().map { .folder(nodeID: $0.id, title: $0.name) } options.append(.root) @@ -274,34 +350,20 @@ package final class FilesViewModel: ObservableObject { var isInFolder: Bool { !navigationPath.isEmpty } - - private func fetchTemplates() { - Task { - templates = try await useCases.getFileTemplates.invoke() - } - } - - private func fetchConversations() async { - let allDriveConversations = await useCases.getDriveConversations.invoke() - - if let cellName { - conversations = allDriveConversations.filter { $0.id == cellName } - } else { - conversations = allDriveConversations + + // MARK: open file/folder + + /// If item is a folder, navigates into it. If it's a file, downloads the related asset if necessary or views it or + /// cancels the download. + func performPrimaryAction(item: FilesViewItem) async { + switch item.kind { + case .file: + await viewAsset(item: item) + case .folder: + openFolder(item: item) } } - private func bindSearch() { - $searchText - .removeDuplicates() - .dropFirst() - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - Task { await self?.reload() } - } - .store(in: &subscriptions) - } - /// Navigates to the folder represented by the given item. private func openFolder(item: FilesViewItem) { precondition(item.kind == .folder) @@ -351,92 +413,13 @@ package final class FilesViewModel: ObservableObject { } } + //TODO: remove func onCreate(target: WireDriveCreateFileUseCase.Target) { guard cellName != nil else { return } sheetNavigation = .create(target: target) } - private func cancelLoad() { - loadMoreTask?.cancel() - loadMoreTask = nil - } - - private func loadMore(refreshing: Bool = false) async { - if isOffline { - await loadOfflineFiles() - } else { - await loadOnlineFiles(refreshing: refreshing) - } - } - - private func loadOnlineFiles(refreshing: Bool) async { - guard loadMoreTask == nil else { return } - - let offset = refreshing ? 0 : state.items.count - let task = Task { try await fetchItems(offset: offset) } - - loadMoreTask = task - do { - let (newItems, isLastPage) = try await task.value - let receivedItems = refreshing ? newItems : state.items + newItems - state = .received(items: receivedItems.latestModified()) - hasMore = !isLastPage - } catch is CancellationError { - return // developer-driven error, discard - } catch { - if state.items.isEmpty { - state = .error(isConnectionError: error.isNoInternetError) - } else { - if error.isNoInternetError { - // no-op, offline bar is dynamically shown/hidden on top of the list - } else { - alert = .unknownError - } - } - hasMore = state.items.isEmpty ? true : hasMore - } - loadMoreTask = nil - } - - private func loadOfflineFiles() async { - guard !isRecycleBin else { - return state = .received(items: []) - } - - do { - let actionInput = LoadOfflineAvailableFilesUIAction.Input( - conversationName: cellName != nil ? conversations.first?.name : nil, - assetsPath: navigationPath.last?.filePath, - getAsset: useCases.getAsset, - getOfflineAvailableAssets: useCases.getOfflineAvailableAssets - ) - - let items = try await LoadOfflineAvailableFilesUIAction(input: actionInput)() - - state = .received(items: items) - } catch { - alert = .unknownError - WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") - } - hasMore = false - } - - private func fetchItems( - offset: Int - ) async throws -> (items: [FilesViewItem], isLastPage: Bool) { - let (nodes, isLastPage) = try await useCases.fetchNodes.invoke( - searchTerm: searchText.isEmpty ? nil : searchText, - metafilter: filtersSelection.toDomainModel(selfUserID: selfUserID), - sortField: sortingSelection.sortingKey?.sortField, - sortDirDesc: sortingSelection.sortingOrder == .descending, - offset: offset - ) - - let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) - - try Task.checkCancellation() - return (items, isLastPage) - } + // MARK: recycle bin func deleteItem(_ asset: FilesViewItem, permanently: Bool) async { guard state.isLoaded else { @@ -483,19 +466,48 @@ package final class FilesViewModel: ObservableObject { state = .received(items: currentItems.latestModified()) } } + + // MARK: search + + var showSearchBar: Bool { + guard !isOffline else { + return false + } - func resetFilters() { - filtersSelection = .empty - sortingSelection = .default + return switch state { + case .loading, .received: + true + case .pending, .error: + false + } } - + + // MARK: filters + func onUpdate(of filters: FilesFilteringViewModel.FiltersSelection) { guard filters != filtersSelection else { return } filtersSelection = filters Task { await reload() } } - // MARK: - Offline mode + func resetFilters() { + filtersSelection = .empty + sortingSelection = .default + } + + // MARK: offline mode + + var networkStatus: NetworkMonitor.NetworkStatus? { + networkMonitor.currentStatus + } + + var isOffline: Bool { + networkMonitor.currentStatus == .disconnected + } + + var shouldShowOfflineBar: Bool { + isOffline && !state.items.isEmpty + } func makeAssetAvailableOffline(item: FilesViewItem) { Task { From 9c072652a4e9739f671b8715318528f5f0970bcf Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Mon, 27 Apr 2026 11:42:27 +0200 Subject: [PATCH 59/62] removing a TODO --- .../WireDrive/Components/Files/FilesViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 0d8c96bee2b..8a2acd467c8 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -413,7 +413,6 @@ package final class FilesViewModel: ObservableObject { } } - //TODO: remove func onCreate(target: WireDriveCreateFileUseCase.Target) { guard cellName != nil else { return } sheetNavigation = .create(target: target) From afa31e5676d5d618cb5ea2747d0b167f18597bd4 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Tue, 28 Apr 2026 12:04:20 +0200 Subject: [PATCH 60/62] extracted logic of paginated files loading into dedicated components --- .../Components/Common/FilesListLoader.swift | 115 ++++++++ .../Common/IncrementalListLoader.swift | 138 +++++++++ .../Components/Files/FilesContentView.swift | 4 +- .../Components/Files/FilesViewModel.swift | 270 ++++-------------- .../Components/Files/Item/FilesViewItem.swift | 2 +- 5 files changed, 319 insertions(+), 210 deletions(-) create mode 100644 WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift create mode 100644 WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift new file mode 100644 index 00000000000..1cee278cf2e --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift @@ -0,0 +1,115 @@ +// +// 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 Foundation +import Combine +import Observation +import WireLogging + +/// Loads and manages a paginated list of `FilesViewItem` elements. +@MainActor +class FilesListLoader: Observable, ObservableObject { + typealias Loader = IncrementalListLoader + + @Published private(set) var networkMonitor: NetworkMonitor + @Published private(set) var loader: Loader + + var onFetchOnlineFiles: ((Int) async throws -> (items: [FilesViewItem], isLastPage: Bool))? + var onFetchOfflineFiles: (() async throws -> [FilesViewItem])? + var onErrorToPresent: ((any Error) -> Void)? + + init(networkMonitor: NetworkMonitor = .shared) { + self.networkMonitor = networkMonitor + + loader = .init() + loader.onFetch = { [weak self] offset in + guard let self else { return (items: [], isLastPage: true) } + + if isOffline { + let items = try await onFetchOfflineFiles?() ?? [] + return (items: items, isLastPage: true) + } else { + let (items, isLastPage) = try await onFetchOnlineFiles?(offset) ?? ([], true) + return (items: items.latestModified(), isLastPage: isLastPage) + } + } + loader.onError = { [weak self] error in + guard let self else { return } + + if isOffline { + WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") + onErrorToPresent?(error) + } else { + if loader.state.items.isEmpty { + loader.state = .error(isConnectionError: error.isNoInternetError) + } else { + if !error.isNoInternetError { + WireLogger.wireDrive.error("Error fetching online files:\n\(error)") + onErrorToPresent?(error) + } + } + } + } + } + + private var isOffline: Bool { + networkMonitor.currentStatus == .disconnected + } + + /// Removes an item from the list, then performs an async action. + /// If the action fails, restores the list to how it was before removing the item. + func removeItem(_ item: FilesViewItem, withAction action: @escaping () async throws -> ()) async { + let items = loader.state.items + let changedItems = items.filter { $0.id != item.id } + loader.state = .received(items: changedItems) + + do { + try await action() + } catch { + guard loader.state.isLoaded else { return } + + // set items to how they were before the change: + loader.state = .received(items: items) + } + } +} + +private extension Collection { + /// Removes items with duplicate IDs keeping the latest modified if known, otherwise the first. + func latestModified() -> [FilesViewItem] { + var latestByID: [UUID: FilesViewItem] = [:] + for item in self { + if let existing = latestByID[item.id] { + let existingDate = existing.modifiedAt ?? .distantPast + let newDate = item.modifiedAt ?? .distantPast + if newDate > existingDate { + latestByID[item.id] = item + } + } else { + latestByID[item.id] = item + } + } + + var results: [FilesViewItem] = [] + for item in self where item == latestByID[item.id] { + results.append(item) + } + + return results + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift new file mode 100644 index 00000000000..90ef16eaa74 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift @@ -0,0 +1,138 @@ +// +// 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 Combine +import Observation + +/// A generic loader for paginated lists of items. +@MainActor +class IncrementalListLoader: Observable, ObservableObject { + private typealias LoadTask = Task<(items: [Item], isLastPage: Bool), any Error> + typealias ItemsData = (items: [Item], isLastPage: Bool) + + enum State: Hashable { + case loading + case received(items: [Item]) + case pending + case error(isConnectionError: Bool) + + var items: [Item] { + switch self { + case let .received(items): + items + default: + [] + } + } + + var isLoaded: Bool { + switch self { + case .loading, .pending, .error: + false + case .received: + true + } + } + } + + /// How close to the end of the list before loading more items. + var loadMoreThreshold = 5 + + @Published var state: State = .pending + @Published var hasMore = true + + private var loadTask: LoadTask? + + var onFetch: ((Int) async throws -> ItemsData)? + var onError: ((any Error) -> Void)? + + /// Reloads the items, clearing any previously loaded items. + /// - Parameters: + /// - refreshing: Whether the reload was triggered by a pull-to-refresh action. + /// + /// Cancels any ongoing load operation and starts a new one. + /// When `refreshing` is `true`, the current state is preserved since loading is managed by the system. + func reload(refreshing: Bool = false) async { + //TODO: guard networkMonitor.currentStatus != nil else { return } + + cancelLoad() + state = refreshing ? state : .loading + hasMore = !refreshing + + await loadMore(refreshing: refreshing) + } + + /// Loads more items if available and `index` is towards the end of the list. + /// + /// This method checks if the `index` is within the threshold for loading more items. For example given a threshold + /// of 5, when 10 items are loaded, it will load more when the index is 5 or above - i.e. when one of the last 5 + /// items is being displayed. + /// + /// - Parameter index: The index of the item which requested load more. + func loadMoreIfNeeded(index: Int) async { + let remaining = state.items.count - index - 1 + if remaining < loadMoreThreshold, hasMore { + await loadMore() + } + } + + var isLoading: Bool { + loadTask != nil + } +} + +private extension IncrementalListLoader { + func cancelLoad() { + loadTask?.cancel() + loadTask = nil + } + + func loadMore(refreshing: Bool = false) async { + guard loadTask == nil else { return } + + let offset = refreshing ? 0 : state.items.count + let task = Task { + try await fetchItems(offset: offset) + } + + loadTask = task + defer { loadTask = nil } + + do { + let (newItems, isLastPage) = try await task.value + let receivedItems = refreshing ? newItems : state.items + newItems + state = .received(items: receivedItems) + hasMore = !isLastPage + } catch is CancellationError { + return // developer-driven error, discard + } catch { + hasMore = state.items.isEmpty ? true : hasMore + onError?(error) + } + } + + func fetchItems(offset: Int) async throws -> ItemsData { + if let onFetch { + let itemsData = try await onFetch(offset) + try Task.checkCancellation() + return itemsData + } else { + return (items: [], isLastPage: true) + } + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 5af155977bf..3b1e26bff6a 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift @@ -205,7 +205,7 @@ private extension FilesContentView { // workaround: when filtering by conversation, BE returns sometimes empty payload with hasMore flag set to true // which wrongly displays the load more row on an empty state screen so we need here to explicitly check that // the items are empty. - let hasMore = viewModel.hasMore + let hasMore = viewModel.filesListLoader.loader.hasMore let isEmptyItems = viewModel.state.items.isEmpty let isOffline = viewModel.isOffline @@ -223,7 +223,7 @@ private extension FilesContentView { } var loadMoreRow: some View { - LoadMoreView(isLoading: viewModel.isLoading, onLoadMore: loadMoreTask) + LoadMoreView(isLoading: viewModel.filesListLoader.loader.isLoading, onLoadMore: loadMoreTask) } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 8a2acd467c8..012ee7a0c4b 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -28,13 +28,6 @@ private typealias Accessibility = L10n.Accessibility.Conversation.WireCells @MainActor package final class FilesViewModel: ObservableObject { - private typealias LoadItemsTask = Task<(items: [FilesViewItem], isLastPage: Bool), any Error> - - private enum Constants { - /// How close to the end of the list before loading more items. - static let loadMoreThreshold = 5 - } - enum SheetNavigation: Identifiable { case create(target: WireDriveCreateFileUseCase.Target) case editTags(fileItem: FilesViewItem) @@ -67,31 +60,6 @@ package final class FilesViewModel: ObservableObject { case root } - enum State: Equatable { - case loading - case received(items: [FilesViewItem]) - case pending // drive is not ready yet - case error(isConnectionError: Bool) - - var items: [FilesViewItem] { - switch self { - case let .received(items): - items - default: - [] - } - } - - var isLoaded: Bool { - switch self { - case .loading, .pending, .error: - false - case .received: - true - } - } - } - private let setNavigation: ([FilesViewItem]) -> Void private var subscriptions = Set() let cellName: String? // nil when browsing all files @@ -123,18 +91,20 @@ package final class FilesViewModel: ObservableObject { .first(where: \.isSelfUser)?.id } - @Published var hasMore = true - @Published private var loadMoreTask: LoadItemsTask? @Published var searchText = "" @Published var alert: AlertModel? @Published var viewingURL: URL? - @Published var state: State @Published var sheetNavigation: SheetNavigation? @Published var isEditing: FilesViewItem? @Published var templates: [WireDriveFileTemplate] = [] @Published var conversations: [WireDriveConversation] = [] @Published var filtersSelection: FilesFilteringViewModel.FiltersSelection = .empty @Published var networkMonitor: NetworkMonitor + @Published var filesListLoader: FilesListLoader + + var state: FilesListLoader.Loader.State { + filesListLoader.loader.state + } //MARK: init @@ -159,40 +129,59 @@ package final class FilesViewModel: ObservableObject { self.localAssetRepository = localAssetRepository self.nodesRepository = nodesRepository self.cellName = cellName - self.state = isCellsStatePending ? .pending : .loading self.isBrowsing = isBrowsing self.isRecycleBin = isRecycleBin self.triggerReload = triggerReload self.networkMonitor = networkMonitor + self.filesListLoader = .init(networkMonitor: networkMonitor) + self.filesListLoader.loader.state = isCellsStatePending ? .pending : .loading } // MARK: setup func setup() async { + setupFilesLoader() fetchConversations() fetchTemplates() bindSearch() Task { await reload() } } - private func fetchTemplates() { - Task { - templates = try await useCases.getFileTemplates.invoke() - } - } + func setupFilesLoader() { + filesListLoader.onFetchOnlineFiles = { [weak self] offset in + guard let self else { return (items: [], isLastPage: true) } + + let (nodes, isLastPage) = try await useCases.fetchNodes.invoke( + searchTerm: searchText.isEmpty ? nil : searchText, + metafilter: filtersSelection.toDomainModel(selfUserID: selfUserID), + sortField: sortingSelection.sortingKey?.sortField, + sortDirDesc: sortingSelection.sortingOrder == .descending, + offset: offset + ) - private func fetchConversations() { - Task { - let allDriveConversations = await useCases.getDriveConversations.invoke() + let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) - if let cellName { - conversations = allDriveConversations.filter { $0.id == cellName } - } else { - conversations = allDriveConversations - } + return (items, isLastPage) } - } + + filesListLoader.onFetchOfflineFiles = { [weak self] in + guard let self else { return [] } + + let actionInput = LoadOfflineAvailableFilesUIAction.Input( + conversationName: cellName != nil ? conversations.first?.name : nil, + assetsPath: navigationPath.last?.filePath, + getAsset: useCases.getAsset, + getOfflineAvailableAssets: useCases.getOfflineAvailableAssets + ) + return try await LoadOfflineAvailableFilesUIAction(input: actionInput)() + } + + filesListLoader.onErrorToPresent = { [weak self] _ in + self?.alert = .unknownError + } + } + private func bindSearch() { $searchText .removeDuplicates() @@ -204,123 +193,36 @@ package final class FilesViewModel: ObservableObject { .store(in: &subscriptions) } - // MARK: item list loading - - /// Reloads the items, clearing any previously loaded items. - /// - Parameters: - /// - refreshing: Whether the reload was triggered by a pull-to-refresh action. - /// - /// Cancels any ongoing load operation and starts a new one. - /// When `refreshing` is `true`, the current state is preserved since loading is managed by the system. - func reload(refreshing: Bool = false) async { - guard networkMonitor.currentStatus != nil else { return } - - cancelLoad() - state = refreshing ? state : .loading - hasMore = !refreshing - - await loadMore(refreshing: refreshing) - } - - /// Loads more items if available and `index` is towards the end of the list. - /// - /// This method checks if the `index` is within the threshold for loading more items. For example given a threshold - /// of 5, when 10 items are loaded, it will load more when the index is 5 or above - i.e. when one of the last 5 - /// items is being displayed. - /// - /// - Parameter index: The index of the item which requested load more. - func loadMoreIfNeeded(index: Int) async { - let remaining = state.items.count - index - 1 - if remaining < Constants.loadMoreThreshold, hasMore { - await loadMore() - } - } + // MARK: fetch general data - /// Whether the view model is currently loading items. - var isLoading: Bool { - loadMoreTask != nil - } - - private func cancelLoad() { - loadMoreTask?.cancel() - loadMoreTask = nil - } - - private func loadMore(refreshing: Bool = false) async { - if isOffline { - await loadOfflineFiles() - } else { - await loadOnlineFiles(refreshing: refreshing) + private func fetchTemplates() { + Task { + templates = try await useCases.getFileTemplates.invoke() } } - private func loadOnlineFiles(refreshing: Bool) async { - guard loadMoreTask == nil else { return } - - let offset = refreshing ? 0 : state.items.count - let task = Task { try await fetchItems(offset: offset) } - - loadMoreTask = task - do { - let (newItems, isLastPage) = try await task.value - let receivedItems = refreshing ? newItems : state.items + newItems - state = .received(items: receivedItems.latestModified()) - hasMore = !isLastPage - } catch is CancellationError { - return // developer-driven error, discard - } catch { - if state.items.isEmpty { - state = .error(isConnectionError: error.isNoInternetError) + private func fetchConversations() { + Task { + let allDriveConversations = await useCases.getDriveConversations.invoke() + + if let cellName { + conversations = allDriveConversations.filter { $0.id == cellName } } else { - if error.isNoInternetError { - // no-op, offline bar is dynamically shown/hidden on top of the list - } else { - alert = .unknownError - } + conversations = allDriveConversations } - hasMore = state.items.isEmpty ? true : hasMore } - loadMoreTask = nil } - private func loadOfflineFiles() async { - guard !isRecycleBin else { - return state = .received(items: []) - } - - do { - let actionInput = LoadOfflineAvailableFilesUIAction.Input( - conversationName: cellName != nil ? conversations.first?.name : nil, - assetsPath: navigationPath.last?.filePath, - getAsset: useCases.getAsset, - getOfflineAvailableAssets: useCases.getOfflineAvailableAssets - ) - - let items = try await LoadOfflineAvailableFilesUIAction(input: actionInput)() + // MARK: load files - state = .received(items: items) - } catch { - alert = .unknownError - WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") - } - hasMore = false + func reload(refreshing: Bool = false) async { + guard networkMonitor.currentStatus != nil else { return } + + await filesListLoader.loader.reload(refreshing: refreshing) } - private func fetchItems( - offset: Int - ) async throws -> (items: [FilesViewItem], isLastPage: Bool) { - let (nodes, isLastPage) = try await useCases.fetchNodes.invoke( - searchTerm: searchText.isEmpty ? nil : searchText, - metafilter: filtersSelection.toDomainModel(selfUserID: selfUserID), - sortField: sortingSelection.sortingKey?.sortField, - sortDirDesc: sortingSelection.sortingOrder == .descending, - offset: offset - ) - - let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) - - try Task.checkCancellation() - return (items, isLastPage) + func loadMoreIfNeeded(index: Int) async { + await filesListLoader.loader.loadMoreIfNeeded(index: index) } // MARK: folders @@ -426,18 +328,8 @@ package final class FilesViewModel: ObservableObject { return } - var currentItems = state.items - currentItems.removeAll { $0.id == asset.id } - state = .received(items: currentItems.latestModified()) - - do { - try await useCases.deleteNodes.invoke(nodeIDs: [asset.id], deletePermanently: permanently) - } catch { - guard state.isLoaded else { return } - - var currentItems = state.items - currentItems.append(asset) - state = .received(items: currentItems.latestModified()) + await filesListLoader.removeItem(asset) { [weak self] in + try await self?.useCases.deleteNodes.invoke(nodeIDs: [asset.id], deletePermanently: permanently) } } @@ -446,23 +338,12 @@ package final class FilesViewModel: ObservableObject { WireLogger.wireDrive.error("Attempt to restore asset while not visible", attributes: .safePublic) return } - - var currentItems = state.items - currentItems.removeAll { $0.id == asset.id } - state = .received(items: currentItems.latestModified()) - - let nodeIdToRestore = navigationPath.last?.recycleBinTopFolderId ?? asset.id - - do { + + await filesListLoader.removeItem(asset) { [weak self] in + guard let self else { return } + let nodeIdToRestore = navigationPath.last?.recycleBinTopFolderId ?? asset.id try await useCases.restoreNodes.invoke(nodeIDs: [nodeIdToRestore]) - setNavigation([]) - } catch { - guard state.isLoaded else { return } - - var currentItems = state.items - currentItems.append(asset) - state = .received(items: currentItems.latestModified()) } } @@ -533,28 +414,3 @@ package final class FilesViewModel: ObservableObject { } } } - -private extension Collection { - /// Removes items with duplicate IDs keeping the latest modified if known, otherwise the first. - func latestModified() -> [FilesViewItem] { - var latestByID: [UUID: FilesViewItem] = [:] - for item in self { - if let existing = latestByID[item.id] { - let existingDate = existing.modifiedAt ?? .distantPast - let newDate = item.modifiedAt ?? .distantPast - if newDate > existingDate { - latestByID[item.id] = item - } - } else { - latestByID[item.id] = item - } - } - - var results: [FilesViewItem] = [] - for item in self where item == latestByID[item.id] { - results.append(item) - } - - return results - } -} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift index 3f8059853bd..03d53cc1bcc 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift @@ -21,7 +21,7 @@ import WireMessagingDomain import UniformTypeIdentifiers /// An item in the `FilesView`. -package struct FilesViewItem: Identifiable, Hashable { +package struct FilesViewItem: Identifiable, Hashable, Sendable { /// The kind of item enum Kind { From c8b53670d825c5baa9900dc974734434f954eb6b Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Wed, 29 Apr 2026 11:17:34 +0200 Subject: [PATCH 61/62] lint&format --- .../UseCases/WireDriveCreateFileUseCase.swift | 6 +-- .../Components/Common/FilesListLoader.swift | 20 +++---- .../Common/IncrementalListLoader.swift | 26 +++++---- .../FileVersioningViewModel.swift | 2 +- .../Files/FilesViewModel+UseCases.swift | 6 +-- .../FilesViewModel+make_ViewModels.swift | 6 +-- .../Components/Files/FilesViewModel.swift | 54 +++++++++---------- .../Files/Item/FilesItemViewModel.swift | 2 +- .../Components/Files/Item/FilesViewItem.swift | 2 +- .../MoveToFolderPageViewModel.swift | 4 +- .../ShareLink/ShareLinkView+ViewModel.swift | 1 + 11 files changed, 63 insertions(+), 66 deletions(-) diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift index 81e2932ca0f..53e5e1552f1 100644 --- a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift +++ b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift @@ -30,13 +30,13 @@ package struct WireDriveCreateFileUseCase: WireDriveCreateFileUseCaseProtocol { package enum Target: Equatable, Identifiable { case folder case file(WireDriveFileTemplate) - + package var id: String { switch self { case .folder: - return "folder" + "folder" case let .file(template): - return "file:\(template.id)" + "file:\(template.id)" } } } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift index 1cee278cf2e..84ea546f516 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift @@ -16,8 +16,8 @@ // along with this program. If not, see http://www.gnu.org/licenses/. // -import Foundation import Combine +import Foundation import Observation import WireLogging @@ -25,21 +25,21 @@ import WireLogging @MainActor class FilesListLoader: Observable, ObservableObject { typealias Loader = IncrementalListLoader - + @Published private(set) var networkMonitor: NetworkMonitor @Published private(set) var loader: Loader - + var onFetchOnlineFiles: ((Int) async throws -> (items: [FilesViewItem], isLastPage: Bool))? var onFetchOfflineFiles: (() async throws -> [FilesViewItem])? var onErrorToPresent: ((any Error) -> Void)? init(networkMonitor: NetworkMonitor = .shared) { self.networkMonitor = networkMonitor - - loader = .init() + + self.loader = .init() loader.onFetch = { [weak self] offset in guard let self else { return (items: [], isLastPage: true) } - + if isOffline { let items = try await onFetchOfflineFiles?() ?? [] return (items: items, isLastPage: true) @@ -50,7 +50,7 @@ class FilesListLoader: Observable, ObservableObject { } loader.onError = { [weak self] error in guard let self else { return } - + if isOffline { WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") onErrorToPresent?(error) @@ -66,14 +66,14 @@ class FilesListLoader: Observable, ObservableObject { } } } - + private var isOffline: Bool { networkMonitor.currentStatus == .disconnected } - + /// Removes an item from the list, then performs an async action. /// If the action fails, restores the list to how it was before removing the item. - func removeItem(_ item: FilesViewItem, withAction action: @escaping () async throws -> ()) async { + func removeItem(_ item: FilesViewItem, withAction action: @escaping () async throws -> Void) async { let items = loader.state.items let changedItems = items.filter { $0.id != item.id } loader.state = .received(items: changedItems) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift index 90ef16eaa74..45d5d47c4bd 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift @@ -24,13 +24,13 @@ import Observation class IncrementalListLoader: Observable, ObservableObject { private typealias LoadTask = Task<(items: [Item], isLastPage: Bool), any Error> typealias ItemsData = (items: [Item], isLastPage: Bool) - + enum State: Hashable { case loading case received(items: [Item]) case pending case error(isConnectionError: Bool) - + var items: [Item] { switch self { case let .received(items): @@ -39,7 +39,7 @@ class IncrementalListLoader: Observabl [] } } - + var isLoaded: Bool { switch self { case .loading, .pending, .error: @@ -49,18 +49,18 @@ class IncrementalListLoader: Observabl } } } - + /// How close to the end of the list before loading more items. var loadMoreThreshold = 5 - + @Published var state: State = .pending @Published var hasMore = true - + private var loadTask: LoadTask? - + var onFetch: ((Int) async throws -> ItemsData)? var onError: ((any Error) -> Void)? - + /// Reloads the items, clearing any previously loaded items. /// - Parameters: /// - refreshing: Whether the reload was triggered by a pull-to-refresh action. @@ -68,15 +68,13 @@ class IncrementalListLoader: Observabl /// Cancels any ongoing load operation and starts a new one. /// When `refreshing` is `true`, the current state is preserved since loading is managed by the system. func reload(refreshing: Bool = false) async { - //TODO: guard networkMonitor.currentStatus != nil else { return } - cancelLoad() state = refreshing ? state : .loading hasMore = !refreshing - + await loadMore(refreshing: refreshing) } - + /// Loads more items if available and `index` is towards the end of the list. /// /// This method checks if the `index` is within the threshold for loading more items. For example given a threshold @@ -90,7 +88,7 @@ class IncrementalListLoader: Observabl await loadMore() } } - + var isLoading: Bool { loadTask != nil } @@ -112,7 +110,7 @@ private extension IncrementalListLoader { loadTask = task defer { loadTask = nil } - + do { let (newItems, isLastPage) = try await task.value let receivedItems = refreshing ? newItems : state.items + newItems diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift index f98f9fc7ba3..fd2089ea737 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift @@ -158,7 +158,7 @@ final class FileVersioningViewModel: ObservableObject { } viewingURL = try await getAssetUseCase.invoke(nodeID: nodeID, eTag: eTag) - + onVersionRestored() } catch { alert = AlertModel( diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift index 92378cc6787..fdecd6850ad 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+UseCases.swift @@ -18,8 +18,8 @@ package import WireMessagingDomain -extension FilesViewModel { - package struct UseCases { +package extension FilesViewModel { + struct UseCases { let fetchNodes: WireDriveFetchNodesPageUseCase let deleteNodes: WireDriveDeleteNodesUseCase let restoreNodes: WireDriveRestoreNodesUseCase @@ -41,7 +41,7 @@ extension FilesViewModel { let makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase - + package init( fetchNodes: WireDriveFetchNodesPageUseCase, deleteNodes: WireDriveDeleteNodesUseCase, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift index bb83411f2fe..10835434c0d 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift @@ -59,11 +59,11 @@ extension FilesViewModel { isInRecycleBin: isRecycleBin, ) } - + func createFileViewModel(target: WireDriveCreateFileUseCase.Target) -> CreateFileViewModel { // When navigation path is empty, file/folder is created at the root path (cell name) let path = navigationPath.last?.filePath ?? cellName ?? "" - + return .init( creationTarget: target, path: path, @@ -107,7 +107,7 @@ extension FilesViewModel { getEditingURLUseCase: useCases.getEditingURL ) } - + func fileRenameViewModel(item: FilesViewItem) -> FileRenameViewModel { .init( renameNodeUseCase: useCases.renameNode, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 012ee7a0c4b..3d14c5822f4 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -101,13 +101,13 @@ package final class FilesViewModel: ObservableObject { @Published var filtersSelection: FilesFilteringViewModel.FiltersSelection = .empty @Published var networkMonitor: NetworkMonitor @Published var filesListLoader: FilesListLoader - + var state: FilesListLoader.Loader.State { filesListLoader.loader.state } - //MARK: init - + // MARK: init + package init( useCases: UseCases, title: String? = nil, @@ -134,9 +134,9 @@ package final class FilesViewModel: ObservableObject { self.triggerReload = triggerReload self.networkMonitor = networkMonitor self.filesListLoader = .init(networkMonitor: networkMonitor) - self.filesListLoader.loader.state = isCellsStatePending ? .pending : .loading + filesListLoader.loader.state = isCellsStatePending ? .pending : .loading } - + // MARK: setup func setup() async { @@ -146,11 +146,11 @@ package final class FilesViewModel: ObservableObject { bindSearch() Task { await reload() } } - + func setupFilesLoader() { filesListLoader.onFetchOnlineFiles = { [weak self] offset in guard let self else { return (items: [], isLastPage: true) } - + let (nodes, isLastPage) = try await useCases.fetchNodes.invoke( searchTerm: searchText.isEmpty ? nil : searchText, metafilter: filtersSelection.toDomainModel(selfUserID: selfUserID), @@ -160,13 +160,13 @@ package final class FilesViewModel: ObservableObject { ) let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) - + return (items, isLastPage) } - + filesListLoader.onFetchOfflineFiles = { [weak self] in guard let self else { return [] } - + let actionInput = LoadOfflineAvailableFilesUIAction.Input( conversationName: cellName != nil ? conversations.first?.name : nil, assetsPath: navigationPath.last?.filePath, @@ -176,12 +176,12 @@ package final class FilesViewModel: ObservableObject { return try await LoadOfflineAvailableFilesUIAction(input: actionInput)() } - + filesListLoader.onErrorToPresent = { [weak self] _ in self?.alert = .unknownError } } - + private func bindSearch() { $searchText .removeDuplicates() @@ -192,9 +192,9 @@ package final class FilesViewModel: ObservableObject { } .store(in: &subscriptions) } - + // MARK: fetch general data - + private func fetchTemplates() { Task { templates = try await useCases.getFileTemplates.invoke() @@ -204,7 +204,7 @@ package final class FilesViewModel: ObservableObject { private func fetchConversations() { Task { let allDriveConversations = await useCases.getDriveConversations.invoke() - + if let cellName { conversations = allDriveConversations.filter { $0.id == cellName } } else { @@ -217,14 +217,14 @@ package final class FilesViewModel: ObservableObject { func reload(refreshing: Bool = false) async { guard networkMonitor.currentStatus != nil else { return } - + await filesListLoader.loader.reload(refreshing: refreshing) } func loadMoreIfNeeded(index: Int) async { await filesListLoader.loader.loadMoreIfNeeded(index: index) } - + // MARK: folders var folderMenuOptions: [FolderMenuOption] { @@ -252,9 +252,9 @@ package final class FilesViewModel: ObservableObject { var isInFolder: Bool { !navigationPath.isEmpty } - + // MARK: open file/folder - + /// If item is a folder, navigates into it. If it's a file, downloads the related asset if necessary or views it or /// cancels the download. func performPrimaryAction(item: FilesViewItem) async { @@ -319,9 +319,9 @@ package final class FilesViewModel: ObservableObject { guard cellName != nil else { return } sheetNavigation = .create(target: target) } - + // MARK: recycle bin - + func deleteItem(_ asset: FilesViewItem, permanently: Bool) async { guard state.isLoaded else { WireLogger.wireDrive.error("Attempt to delete asset while not visible", attributes: .safePublic) @@ -338,7 +338,7 @@ package final class FilesViewModel: ObservableObject { WireLogger.wireDrive.error("Attempt to restore asset while not visible", attributes: .safePublic) return } - + await filesListLoader.removeItem(asset) { [weak self] in guard let self else { return } let nodeIdToRestore = navigationPath.last?.recycleBinTopFolderId ?? asset.id @@ -346,9 +346,9 @@ package final class FilesViewModel: ObservableObject { setNavigation([]) } } - + // MARK: search - + var showSearchBar: Bool { guard !isOffline else { return false @@ -361,9 +361,9 @@ package final class FilesViewModel: ObservableObject { false } } - + // MARK: filters - + func onUpdate(of filters: FilesFilteringViewModel.FiltersSelection) { guard filters != filtersSelection else { return } filtersSelection = filters @@ -376,7 +376,7 @@ package final class FilesViewModel: ObservableObject { } // MARK: offline mode - + var networkStatus: NetworkMonitor.NetworkStatus? { networkMonitor.currentStatus } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index bfc36546e44..7ea9b3c4586 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -119,7 +119,7 @@ final class FilesItemViewModel: ObservableObject { self.fileTracker = .init() fileTracker.onSmallFileLoaded = { [weak self] in guard let asset = self?.asset, !asset.isAvailableOffline else { return } - self?.performAction(.primaryAction) //TODO: this is also called when a file is downloaded after restoring a file version. this needs to be fixed. + self?.performAction(.primaryAction) } localAssetRepository.observeAsset(nodeID: nodeID).sink { [weak self] asset in diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift index 03d53cc1bcc..ec59b8fe749 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.swift @@ -17,8 +17,8 @@ // package import Foundation -import WireMessagingDomain import UniformTypeIdentifiers +import WireMessagingDomain /// An item in the `FilesView`. package struct FilesViewItem: Identifiable, Hashable, Sendable { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift index ee969b3be93..b1a834c3219 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift @@ -256,7 +256,7 @@ final class MoveToFolderPageViewModel: MoveToFolderPageViewModelProtocol { } private func makeCreateFileViewModel() -> CreateFileViewModel { - let viewModel = CreateFileViewModel( + .init( creationTarget: .folder, path: containerPath, createFileUseCase: createFileUseCase, @@ -264,8 +264,6 @@ final class MoveToFolderPageViewModel: MoveToFolderPageViewModelProtocol { Task { await self?.reload() } } ) - - return viewModel } /// Returns the title for a given path. diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift index a02cba9947a..2746c6645ba 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift @@ -81,6 +81,7 @@ extension ShareLinkView { onLinkStateChanged(publicLinkState) } } + @Published var isPresentingError = false let onLinkStateChanged: (PublicLinkState) -> Void From 6df21c245a1d385d08c4c5d522236556f34cc5b1 Mon Sep 17 00:00:00 2001 From: Wilhelm Oks Date: Wed, 29 Apr 2026 11:25:13 +0200 Subject: [PATCH 62/62] lint&format fetchTemplates --- .../WireDrive/Components/Files/FilesViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift index 3d14c5822f4..117d423da49 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -197,7 +197,11 @@ package final class FilesViewModel: ObservableObject { private func fetchTemplates() { Task { - templates = try await useCases.getFileTemplates.invoke() + do { + templates = try await useCases.getFileTemplates.invoke() + } catch { + WireLogger.wireDrive.error("Failed to fetch templates: \(error)", attributes: .safePublic) + } } }