diff --git a/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift b/WireMessaging/Sources/WireMessagingAssembly/WireMessagingFactory.swift index be5f3ab0ed8..c4d4232cc16 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()) ) } @@ -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 ), @@ -229,11 +230,10 @@ public extension WireMessagingFactory { isCellsStatePending: false, localAssetRepository: localAssetRepository, nodesRepository: nodesAPI, - fileCache: fileCache, - isBrowsing: true, - accentColorProvider: accentColorProvider + isBrowsing: true ) - ).environment(\.wireAccentColor, accentColorProvider()) + ) + .environment(\.wireAccentColor, accentColorProvider()) ) } diff --git a/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift b/WireMessaging/Sources/WireMessagingDomain/WireDrive/UseCases/WireDriveCreateFileUseCase.swift index 5b42fcfc991..53e5e1552f1 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: + "folder" + case let .file(template): + "file:\(template.id)" + } + } } private let nodesRepository: any WireDriveNodesRepositoryProtocol 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/Common/FilesListLoader.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/FilesListLoader.swift new file mode 100644 index 00000000000..84ea546f516 --- /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 Combine +import Foundation +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 + + 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) + } 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 -> Void) 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..45d5d47c4bd --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Common/IncrementalListLoader.swift @@ -0,0 +1,136 @@ +// +// 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 { + 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/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/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..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, diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FileVersioning/FileVersioningViewModel.swift index 067c5debe34..056ab487080 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 { @@ -166,6 +163,7 @@ 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/FilesBrowserView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift index 04768379a15..be61c9bb868 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): + ShareLinkView(viewModel: viewModel.shareLinkViewModel(item: item)) default: EmptyView() } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesContentView.swift index 1955b91d265..3b1e26bff6a 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) @@ -114,9 +114,6 @@ package struct FilesContentView: View { ) .sheet( item: $viewModel.sheetNavigation, - onDismiss: { - Task { await viewModel.onSheetDismissed() } - }, content: { navigationItem in sheetContent(navigationItem) } @@ -208,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 @@ -226,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/FilesPreviewHelpers.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesPreviewHelpers.swift index b9a1ea8a523..e49642f2097 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 ), @@ -112,10 +115,8 @@ extension FilesViewModel { isCellsStatePending: false, localAssetRepository: localAssetRepository, nodesRepository: previewNodesRepository(), - fileCache: cache, cellName: "2b7d1f2c-74bf-4256-a746-8112e006dcd6", - isBrowsing: isBrowsing, - accentColorProvider: { .default } + isBrowsing: isBrowsing ) } } @@ -139,7 +140,8 @@ extension FileRenameViewModel { filename: "foo.jpg", filepath: "5b189264-4300-4f21-8dca-7acd2b1925c7@wire.com/Image PNG-TEST3.png" ), - kind: kind + kind: kind, + onRenamed: {} ) } } @@ -190,7 +192,6 @@ extension FileVersionItemViewModel { title: "5:46AM", subtitle: "Deniz Agha ยท 13MB" ), - accentColor: .default, onRestore: { _ in } ) } @@ -238,7 +239,7 @@ extension FileVersioningViewModel { localAssetRepository: localAssetsRepository, fileCache: MockFileCache() ), - accentColorProvider: { .default } + onVersionRestored: {} ) } } @@ -266,6 +267,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 } @@ -436,7 +459,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/FilesView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift index 4d16b31c8e8..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)) } ) } @@ -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): + CreateFileView(viewModel: viewModel.createFileViewModel(target: target)) + case let .editTags(fileItem: item): TagsEditView( - fileItem: fileItem, + fileItem: item, useCases: .init( updateTags: viewModel.useCases.updateTags, getSuggestions: viewModel.useCases.getTagSuggestions @@ -193,22 +194,21 @@ 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): + ShareLinkView(viewModel: viewModel.shareLinkViewModel(item: item)) + case let .renameFile(item): + FileRenameView(viewModel: viewModel.fileRenameViewModel(item: item)) + case let .versionHistory(item): + FileVersioningView(viewModel: viewModel.fileVersioningViewModel(item: item)) + case let .moveToFolder(item): + MoveToFolderView(viewModel: viewModel.moveToFolderViewModel(item: item)) } } } -private extension FilesViewModel.FolderMenuOption { +// MARK: - folder menu title +private extension FilesViewModel.FolderMenuOption { var title: String { switch self { case let .folder(_, title): @@ -217,9 +217,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/FilesViewContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift index a65aa54a73f..1ce071c90fd 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewContainer.swift @@ -18,7 +18,7 @@ import Combine import SwiftUI -package import WireFoundation +import WireFoundation package import WireMessagingDomain package import WireMessagingData @@ -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 ) } } @@ -155,6 +151,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 ), @@ -173,12 +170,10 @@ package struct FilesViewContainer: View { isCellsStatePending: isCellsStatePending, localAssetRepository: localAssetRepository, nodesRepository: nodesRepository, - fileCache: fileCache, cellName: cellName, isBrowsing: false, isRecycleBin: false, - triggerReload: triggerReloadFiles, - accentColorProvider: accentColorProvider + triggerReload: triggerReloadFiles ) } } 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..fdecd6850ad --- /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 + +package extension FilesViewModel { + 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+make_ViewModels.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel+make_ViewModels.swift new file mode 100644 index 00000000000..10835434c0d --- /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 .init( + creationTarget: target, + path: path, + createFileUseCase: useCases.createFile, + onNodeCreated: { [weak self] createdNode in + guard let self else { return } + if case .file = target { + isEditing = FilesViewItem.fromNode(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 { + .init( + 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 5f02c2fb828..078d98e30b1 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift @@ -17,108 +17,39 @@ // package import Combine -package import Foundation -import SwiftUI -import UniformTypeIdentifiers -package import WireFoundation +import Foundation 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 -@MainActor /// View model for the `FilesView`. +@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) - 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))" } } } @@ -129,129 +60,19 @@ 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 - } - } - } - - 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, - 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.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 makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase - let removeAssetAvailableOffline: WireDriveRemoveAssetAvailableOfflineUseCase - let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase - } - 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 let accentColorProvider: () -> WireAccentColor - private var sortingSelection: FilesSortingViewModel.SortingSelection = .default - private var failedItemActions: [FilesViewItem.ID: FilesItemViewModel.ItemAction] = [:] - + 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 - var shouldReload: Bool = false let title: String? - var showSearchBar: Bool { - guard !isOffline else { - return false - } - - return switch state { - case .loading, .received: - true - case .pending, .error: - false - } - } + var failedItemActions: [FilesViewItem.ID: FilesItemViewModel.ItemAction] = [:] var navigationTitle: String { if let title { @@ -265,45 +86,29 @@ 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 - @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 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 var networkMonitor: NetworkMonitor + @Published var filesListLoader: FilesListLoader - @Published private var networkMonitor: NetworkMonitor - - private var selfUserID: String? { - conversations - .flatMap(\.participants) - .first(where: \.isSelfUser)?.id + var state: FilesListLoader.Loader.State { + filesListLoader.loader.state } + // MARK: init + package init( useCases: UseCases, title: String? = nil, @@ -312,12 +117,10 @@ package final class FilesViewModel: ObservableObject { isCellsStatePending: Bool, localAssetRepository: any WireDriveLocalAssetRepositoryProtocol, nodesRepository: any WireDriveNodesRepositoryProtocol, - fileCache: any FileCache, cellName: String? = nil, isBrowsing: Bool, isRecycleBin: Bool = false, triggerReload: PassthroughSubject = .init(), - accentColorProvider: @escaping () -> WireAccentColor, networkMonitor: NetworkMonitor = .shared ) { self.useCases = useCases @@ -326,146 +129,109 @@ 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 self.isRecycleBin = isRecycleBin self.triggerReload = triggerReload - self.accentColorProvider = accentColorProvider self.networkMonitor = networkMonitor + self.filesListLoader = .init(networkMonitor: networkMonitor) + filesListLoader.loader.state = isCellsStatePending ? .pending : .loading } + // MARK: setup + func setup() async { - await fetchConversations() - bindSearch() + setupFilesLoader() + fetchConversations() fetchTemplates() + bindSearch() Task { await reload() } } - /// 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 setupFilesLoader() { + filesListLoader.onFetchOnlineFiles = { [weak self] offset in + guard let self else { return (items: [], isLastPage: true) } - func reload(refreshing: Bool = false) async { - guard networkMonitor.currentStatus != nil else { return } + 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 + ) - cancelLoad() - state = refreshing ? state : .loading - hasMore = !refreshing + let items: [FilesViewItem] = nodes.compactMap(FilesViewItem.fromNode(_:)) - await loadMore(refreshing: refreshing) - } + return (items, isLastPage) + } - /// 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() + 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)() } - } - /// Returns a `FilesItemViewModel` for the item at the given index. - func itemViewModel(index: Int) -> FilesItemViewModel { - FilesItemViewModel( - 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(view: makeFileRenameView(item: item)) - case .editTags: - sheetNavigation = .editTags(fileItem: item) - case .shareLink: - sheetNavigation = .shareLink(view: makeShareLinkView(item: item)) - case .moveToFolder: - sheetNavigation = .moveToFolder(fileItem: item) - case .showVersionHistory: - sheetNavigation = .versionHistory(view: makeFileVersioningView(item: item)) - case .edit: - isEditing = item - case .makeAvailableOffline: - makeAssetAvailableOffline(item: item) - case .removeAvailableOffline: - removeAssetAvailableOffline(item: item) - } - }, - isBrowsing: isBrowsing, - isInRecycleBin: isRecycleBin, - ) + filesListLoader.onErrorToPresent = { [weak self] _ in + self?.alert = .unknownError + } } - func moveToFolderView(item: FilesViewItem) -> some View { - let containerPath = item.filePath.components(separatedBy: "/").dropLast().joined(separator: "/") - let nodesRepository = nodesRepository - let assetRepository = localAssetRepository - let useCases = useCases - return MoveToFolderView( - viewModel: MoveToFolderViewModel( - containerPath: containerPath, - nodeID: item.id, - nodeName: item.name, - onFinish: { [weak self] in - self?.sheetNavigation = nil - Task { await self?.reload(refreshing: true) } - }, - nodesRepository: nodesRepository, - localAssetRepository: assetRepository, - moveNodeUseCase: WireDriveMoveNodeUseCase( - nodesRepository: nodesRepository, - localAssetRepository: assetRepository - ), - createFileUseCase: useCases.createFile - ) - ) + private func bindSearch() { + $searchText + .removeDuplicates() + .dropFirst() + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + Task { await self?.reload() } + } + .store(in: &subscriptions) } - func editFileView(item: FilesViewItem) -> some View { - let getEditingURLUseCase = useCases.getEditingURL - return EditFileView( - viewModel: EditFileViewModel( - nodeID: item.id, - fileName: item.name, - getEditingURLUseCase: getEditingURLUseCase - ) - ) + // MARK: fetch general data + + private func fetchTemplates() { + Task { + do { + templates = try await useCases.getFileTemplates.invoke() + } catch { + WireLogger.wireDrive.error("Failed to fetch templates: \(error)", attributes: .safePublic) + } + } } - /// 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: - if failedItemActions[item.id] == .makeAvailableOffline { - makeAssetAvailableOffline(item: item) + private func fetchConversations() { + Task { + let allDriveConversations = await useCases.getDriveConversations.invoke() + + if let cellName { + conversations = allDriveConversations.filter { $0.id == cellName } } else { - await viewAsset(item: item) + conversations = allDriveConversations } - case .folder: - openFolder(item: item) } } + // MARK: load files + + 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] { var options: [FolderMenuOption] = navigationPath.reversed().map { .folder(nodeID: $0.id, title: $0.name) } options.append(.root) @@ -488,65 +254,25 @@ package final class FilesViewModel: ObservableObject { setNavigation(newPath) } - func onSheetDismissed() async { - if shouldReload { - await reload() - shouldReload = false - } - } - var isInFolder: Bool { !navigationPath.isEmpty } - // MARK: - Private - - 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 - } - } + // MARK: open file/folder - private func bindSearch() { - $searchText - .removeDuplicates() - .dropFirst() - .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) - .sink { [weak self] _ in - Task { await self?.reload() } + /// 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: + if failedItemActions[item.id] == .makeAvailableOffline { + makeAssetAvailableOffline(item: item) + } else { + await viewAsset(item: item) } - .store(in: &subscriptions) + case .folder: + openFolder(item: item) + } } /// Navigates to the folder represented by the given item. @@ -599,383 +325,80 @@ 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 - ) - - // to know whether we need to reload nodes. - viewModel.$createdNode - .compactMap(\.self) - .sink { [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 - ) - - sheetNavigation = .create(view: createFileView) - } - - // MARK: - Private - - private func cancelLoad() { - loadMoreTask?.cancel() - loadMoreTask = nil + guard cellName != nil else { return } + sheetNavigation = .create(target: target) } - 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) } + // MARK: recycle bin - loadMoreTask = task - do { - let (newItems, isLastPage) = try await task.value - let receivedItems = Self.processItems(refreshing ? newItems : state.items + newItems) - state = .received(items: receivedItems) - 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 (see `bindNetworkConnection()`) - } else { - alert = .unknownError - } - } - hasMore = state.items.isEmpty ? true : hasMore - } - loadMoreTask = nil - } - - 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, - 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) - } catch { - alert = .unknownError - WireLogger.wireDrive.error("Error fetching offline assets:\n\(error)") - } - hasMore = false - } - - private nonisolated 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(makeFileViewItem(node:)) - - try Task.checkCancellation() - return (items, isLastPage) - } - - private func deleteItem(_ asset: FilesViewItem, permanently: Bool) async { + 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: Self.processItems(currentItems)) - - 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: Self.processItems(currentItems)) + await filesListLoader.removeItem(asset) { [weak self] in + try await self?.useCases.deleteNodes.invoke(nodeIDs: [asset.id], deletePermanently: permanently) } } - private 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 - ) - } - - private func restoreItem(_ asset: FilesViewItem) async { + func restoreItem(_ asset: FilesViewItem) async { guard state.isLoaded else { 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: Self.processItems(currentItems)) - - 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: Self.processItems(currentItems)) } } - /// 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 - } - } + // MARK: search - var results: [FilesViewItem] = [] - for item in items where item == latestByID[item.id] { - results.append(item) + var showSearchBar: Bool { + guard !isOffline else { + return false } - return results - } - - private func makeFileRenameView( - item: FilesViewItem - ) -> FileRenameView { - let viewModel = FileRenameViewModel( - renameNodeUseCase: useCases.renameNode, - model: .init( - nodeID: item.id, - filename: item.name, - filepath: item.filePath, - ), - kind: item.kind - ) - - // 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) + return switch state { + case .loading, .received: + true + case .pending, .error: + false + } } - private func makeShareLinkView( - item: FilesViewItem - ) -> ShareLinkView { - - let viewModel = ShareLinkView.ViewModel( - 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()) - ) - ) - - viewModel.$publicLinkState - .sink { [weak self] state in - switch state { - case .enabled, .disabled: - self?.shouldReload = true - default: - break - } + // MARK: filters - }.store(in: &subscriptions) - - return ShareLinkView(viewModel: viewModel) + func onUpdate(of filters: FilesFilteringViewModel.FiltersSelection) { + guard filters != filtersSelection else { return } + filtersSelection = filters + Task { await reload() } } - 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, - eTag: item.eTag, - fetchNodeVersionsUseCase: useCases.fetchNodeVersions, - restoreNodeVersionUseCase: useCases.restoreNodeVersion, - getAssetUseCase: useCases.getAsset, - accentColorProvider: accentColorProvider - ) - - return FileVersioningView(viewModel: viewModel) + func resetFilters() { + filtersSelection = .empty + sortingSelection = .default } - // MARK: - Sorting & Filtering + // MARK: offline mode - func makeFilesSortingViewModel() -> FilesSortingViewModel { - FilesSortingViewModel( - isBrowsing: isBrowsing, - subfolderName: navigationPath.last?.name - ) { [weak self] sortingSelection in - self?.sortingSelection = sortingSelection - Task { await self?.reload() } - } + var networkStatus: NetworkMonitor.NetworkStatus? { + networkMonitor.currentStatus } - func resetFilters() { - filtersSelection = .empty - sortingSelection = .default + var isOffline: Bool { + networkMonitor.currentStatus == .disconnected } - func onUpdate(of filters: FilesFilteringViewModel.FiltersSelection) { - guard filters != filtersSelection else { return } - filtersSelection = filters - Task { await reload() } + var shouldShowOfflineBar: Bool { + isOffline && !state.items.isEmpty } - // MARK: - Offline mode - - private func makeAssetAvailableOffline(item: FilesViewItem) { + func makeAssetAvailableOffline(item: FilesViewItem) { Task { do { try await useCases.makeAssetAvailableOffline.invoke(nodeID: item.id) @@ -991,7 +414,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) @@ -1006,51 +429,3 @@ 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] { - var values = [T]() - for element in self { - try await values.append(transform(element)) - } - return values - } -} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemView.swift similarity index 97% rename from WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift rename to WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemView.swift index c142b44c5f2..6b60c712b91 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItemView.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemView.swift @@ -365,6 +365,20 @@ struct FilesItemView: View { ) } + #if DEBUG + switch viewModel.fileTracker.state { + case .loaded: + Divider() + + Button(role: .destructive) { + viewModel.deleteAsset() + } label: { + Label("[DEBUG ONLY] Delete asset from cache", systemImage: "trash") + } + default: + EmptyView() + } + #endif } @ViewBuilder diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift index 73f2053870e..7ea9b3c4586 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesItemViewModel.swift @@ -225,6 +225,16 @@ final class FilesItemViewModel: ObservableObject { await onItemAction(.restore, item) } + #if DEBUG + func deleteAsset() { + Task { + do { + try await localAssetRepository.deleteAsset(nodeID: nodeID) + } catch {} + } + } + #endif + private static func subtitle( selectedSortingKey: FilesSortingViewModel.SortingKey?, isBrowsing: Bool, 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..ec59b8fe749 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/Item/FilesViewItem.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/. +// + +package import Foundation +import UniformTypeIdentifiers +import WireMessagingDomain + +/// An item in the `FilesView`. +package struct FilesViewItem: Identifiable, Hashable, Sendable { + + /// 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? +} + +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 + ) + } +} diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift index 4e70be274d0..b1a834c3219 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/MoveToFolder/MoveToFolderPageViewModel.swift @@ -256,19 +256,14 @@ final class MoveToFolderPageViewModel: MoveToFolderPageViewModelProtocol { } private func makeCreateFileViewModel() -> CreateFileViewModel { - let viewModel = CreateFileViewModel( + .init( 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 + } + ) } /// Returns the title for a given path. diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/RecycleBinContainer.swift index 755cd1a1650..8f437faa51f 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 { @@ -115,6 +112,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 ), @@ -133,11 +131,9 @@ package struct RecycleBinContainer: View { isCellsStatePending: isCellsStatePending, localAssetRepository: localAssetRepository, nodesRepository: nodesRepository, - fileCache: fileCache, cellName: cellName, isBrowsing: false, - isRecycleBin: true, - accentColorProvider: accentColorProvider + isRecycleBin: true ) } } 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/Files/SortAndFilter/FilesSortingViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/SortAndFilter/FilesSortingViewModel.swift index 1e2e569a5f3..631d7a49be9 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 { diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift index 9b31b09057e..2746c6645ba 100644 --- a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/ShareLink/ShareLinkView+ViewModel.swift @@ -76,9 +76,16 @@ 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 +94,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 } + ) ) } diff --git a/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/UI Actions/LoadOfflineAvailableFilesUIAction.swift b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/UI Actions/LoadOfflineAvailableFilesUIAction.swift new file mode 100644 index 00000000000..0d4b32db7d0 --- /dev/null +++ b/WireMessaging/Sources/WireMessagingUI/WireDrive/Components/UI Actions/LoadOfflineAvailableFilesUIAction.swift @@ -0,0 +1,127 @@ +// +// 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 UniformTypeIdentifiers +import WireFoundation +import WireLogging +import WireMessagingDomain +import WireMessagingDomainSupport + +struct LoadOfflineAvailableFilesUIAction { + struct Input { + let conversationName: String? + let assetsPath: String? + let getAsset: WireDriveGetAssetUseCase + let getOfflineAvailableAssets: WireDriveFetchOfflineAvailableAssetsUseCase + } + + 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 + 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 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 + } 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 input.getAsset.invoke(nodeID: item.id, eTag: item.eTag) + let creationDate = (try? url?.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date() + return (item: item, creationDate: creationDate) + } + + return 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) + } + } + } +} + +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/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift index cf58fcffaf9..11b5beb36e7 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/ImplementationTests/WireDriveLocalAssetRepositoryTests.swift @@ -439,5 +439,4 @@ final class WireDriveLocalAssetRepositoryTests { ] ) } - } diff --git a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift index 6191b5f6ff8..56e4f495f84 100644 --- a/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift +++ b/WireMessaging/Tests/WireMessagingTests/WireDrive/UITests/Components/Files/FilesViewModelTests.swift @@ -90,6 +90,7 @@ final class FilesViewModelTests { updatePublicLinkExpiration: WireDriveUpdatePublicLinkExpirationUseCase(nodesAPI: nodesApi), updatePublicLinkPassword: WireDriveUpdatePublicLinkPasswordUseCase(nodesAPI: nodesApi), getDriveConversations: WireDriveGetConversationsUseCase(nodesAPI: nodesApi), + getFileTemplates: WireDriveFetchFileTemplatesUseCase(repository: nodesRepository), makeAssetAvailableOffline: WireDriveMakeAssetAvailableOfflineUseCase( localAssetRepository: localAssetRepository ), @@ -103,9 +104,7 @@ final class FilesViewModelTests { isCellsStatePending: false, localAssetRepository: localAssetRepository, nodesRepository: nodesRepository, - fileCache: fileCache, isBrowsing: false, - accentColorProvider: { .default }, networkMonitor: networkMonitor ) @@ -114,7 +113,7 @@ final class FilesViewModelTests { .refreshAssetMetadataNodeID_MockValue = (WireDriveNode.fixture(), WireDriveLocalAsset.fixture()) localAssetRepository.downloadAssetNodeIDIsAvailableOffline_MockMethod = { _, _ in } - sut.$state.dropFirst().sink { [weak self] state in + sut.filesListLoader.loader.$state.dropFirst().sink { [weak self] state in self?.itemsUpdates.append(state.items) }.store(in: &cancellables) } @@ -127,19 +126,19 @@ final class FilesViewModelTests { let page2 = (nodes: [WireDriveNode.fixture()], nextOffset: Int?.none) return request.offset == 0 ? page1 : page2 } - #expect(sut.hasMore == true) + #expect(sut.filesListLoader.loader.hasMore == true) // when await sut.reload() // then - #expect(sut.hasMore == true) + #expect(sut.filesListLoader.loader.hasMore == true) // when await sut.loadMoreIfNeeded(index: 0) // then - #expect(sut.hasMore == false) + #expect(sut.filesListLoader.loader.hasMore == false) } @Test @@ -147,25 +146,25 @@ final class FilesViewModelTests { // given nodesRepository.getNodes_MockMethod = { [sut] request in // Here we assert that loading is true when the methods under test are called. - #expect(sut.isLoading == true) + #expect(sut.filesListLoader.loader.isLoading == true) let page1 = (nodes: [WireDriveNode.fixture()], nextOffset: 1) let page2 = (nodes: [WireDriveNode.fixture()], nextOffset: Int?.none) return request.offset == 0 ? page1 : page2 } - #expect(sut.isLoading == false) + #expect(sut.filesListLoader.loader.isLoading == false) // when await sut.reload() // then - #expect(sut.isLoading == false) + #expect(sut.filesListLoader.loader.isLoading == false) // when await sut.loadMoreIfNeeded(index: 0) // then - #expect(sut.isLoading == false) + #expect(sut.filesListLoader.loader.isLoading == false) } @Test @@ -223,7 +222,7 @@ final class FilesViewModelTests { } await sut.reload() - #expect(sut.hasMore == true) + #expect(sut.filesListLoader.loader.hasMore == true) // when await sut.reload() @@ -437,7 +436,7 @@ final class FilesViewModelTests { // then let isConnectionError = error.isURLError(.notConnectedToInternet) || error.isURLError(.networkConnectionLost) #expect(sut.state == .error(isConnectionError: isConnectionError)) - #expect(sut.isLoading == false) + #expect(sut.filesListLoader.loader.isLoading == false) } @Test @@ -590,7 +589,7 @@ final class FilesViewModelTests { @Test func testOfflineBarIsHiddenWhenItemsAreEmpty() { networkMonitor.currentStatus = .disconnected - sut.state = .received(items: []) + sut.filesListLoader.loader.state = .received(items: []) #expect(sut.shouldShowOfflineBar == false) } @@ -602,7 +601,7 @@ final class FilesViewModelTests { networkMonitor.currentStatus = .disconnected - sut.state = .received(items: [ + sut.filesListLoader.loader.state = .received(items: [ FilesViewItem( id: nodeA.id, eTag: "eTag",