// // Copyright 2026 Novaloop AG // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // import AsyncAlgorithms import Combine import SwiftUI typealias HealthChatMemberDirectoryScreenViewModelType = StateStoreViewModelV2 class HealthChatMemberDirectoryScreenViewModel: HealthChatMemberDirectoryScreenViewModelType, HealthChatMemberDirectoryScreenViewModelProtocol { private let apiService: HealthChatAPIServiceProtocol private let clientProxy: ClientProxyProtocol private let userIndicatorController: UserIndicatorControllerProtocol private var searchQueryObservationTask: Task? // periphery:ignore - auto cancels when reassigned @CancellableTask private var fetchTask: Task? private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { actionsSubject.eraseToAnyPublisher() } init(apiService: HealthChatAPIServiceProtocol, clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) { self.apiService = apiService self.clientProxy = clientProxy self.userIndicatorController = userIndicatorController super.init(initialViewState: HealthChatMemberDirectoryScreenViewState(), mediaProvider: mediaProvider) fetchMembers() // The stream replays the current query, which the initial fetch above already covers. let searchQueryStream = context.observe(\.viewState.bindings.searchQuery) .debounce(for: .milliseconds(300)) .removeDuplicates() .dropFirst() searchQueryObservationTask = Task { [weak self] in for await _ in searchQueryStream { self?.fetchMembers() } } } isolated deinit { searchQueryObservationTask?.cancel() } // MARK: - Public override func process(viewAction: HealthChatMemberDirectoryScreenViewAction) { switch viewAction { case .retry: state.content = .loading fetchMembers() case .selectMember(let member): actionsSubject.send(.selectMember(userID: member.userID)) case .startChat(let member): Task { await startChat(with: member) } } } // MARK: - Private private func fetchMembers() { fetchTask = Task { [weak self] in guard let self else { return } let searchQuery = state.bindings.searchQuery let result = await apiService.members(searchQuery: searchQuery.isEmpty ? nil : searchQuery) guard !Task.isCancelled else { return } switch result { case .success(let response) where !response.isApproved: state.content = .pendingApproval(message: response.pendingMessage ?? HealthChatL10n.screenMemberDirectoryPendingApproval) case .success(let response): state.content = .members(response.users) case .failure: state.content = .failure } } } private func startChat(with member: HealthChatMember) async { switch clientProxy.directRoomForUserID(member.userID) { case .success(let roomID?): actionsSubject.send(.presentRoom(roomID: roomID)) case .success(nil): showLoadingIndicator() defer { hideLoadingIndicator() } switch await clientProxy.createDirectRoom(with: member.userID, expectedRoomName: member.displayName) { case .success(let roomID): actionsSubject.send(.presentRoom(roomID: roomID)) case .failure: state.bindings.alertInfo = .init(id: .failedStartingChat, title: L10n.commonError, message: L10n.screenStartChatErrorStartingChat) } case .failure: state.bindings.alertInfo = .init(id: .failedStartingChat, title: L10n.commonError, message: L10n.screenStartChatErrorStartingChat) } } // MARK: User Indicators private var loadingIndicatorIdentifier: String { "\(Self.self)-Loading" } private func showLoadingIndicator() { userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: false), title: L10n.commonLoading, persistent: true), delay: .milliseconds(100)) } private func hideLoadingIndicator() { userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier) } }