diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 233f4d670..8503ba4ed 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -313,33 +313,34 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol return } + // Combined so that the mode and the rooms are always updated from the same pair of + // values: the state can report loaded before the first summaries have published and + // flipping to .rooms then would flash an empty list. roomSummaryProvider.statePublisher + .combineLatest(roomSummaryProvider.roomListPublisher) .receive(on: DispatchQueue.main) - .sink { [weak self] state in + .sink { [weak self] state, rooms in guard let self else { return } - updateRoomListMode(with: state) - } - .store(in: &cancellables) - - roomSummaryProvider.roomListPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - self?.updateRooms() + updateRooms() + updateRoomListMode(with: state, hasRooms: !rooms.isEmpty) } .store(in: &cancellables) } - private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState) { + private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState, hasRooms: Bool) { let isLoadingData = !roomSummaryProviderState.isLoaded let hasNoRooms = roomSummaryProviderState.isLoaded && roomSummaryProviderState.totalNumberOfRooms == 0 + // Only the initial transition needs holding back until the rooms have published, an + // empty list later on is a filter or a search result. + let isInitialLoad = state.roomListMode == .skeletons var roomListMode = state.roomListMode if isLoadingData { roomListMode = .skeletons } else if hasNoRooms { roomListMode = .empty - } else { + } else if hasRooms || !isInitialLoad { roomListMode = .rooms } diff --git a/UnitTests/Sources/HomeScreenViewModelTests.swift b/UnitTests/Sources/HomeScreenViewModelTests.swift index 83ba8241e..2d038efda 100644 --- a/UnitTests/Sources/HomeScreenViewModelTests.swift +++ b/UnitTests/Sources/HomeScreenViewModelTests.swift @@ -384,7 +384,52 @@ final class HomeScreenViewModelTests { enum InviteType { case rooms, spaces } - private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, invites: InviteType? = nil) { + @Test + func roomListModeWaitsForTheRoomsToPublish() async throws { + let (roomListSubject, stateSubject) = setupViewModelWithManualProvider() + + #expect(context.viewState.roomListMode == .skeletons) + + // The provider reports loaded before the first summaries have been published. + stateSubject.send(.loaded(totalNumberOfRooms: 8)) + try await Task.sleep(for: .milliseconds(100)) + #expect(context.viewState.roomListMode == .skeletons) + + let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .rooms } + roomListSubject.send(.mockRooms) + try await deferred.fulfill() + } + + @Test + func roomListModeDoesntReturnToSkeletonsWhenTheRoomsAreFilteredOut() async throws { + let (roomListSubject, stateSubject) = setupViewModelWithManualProvider() + + stateSubject.send(.loaded(totalNumberOfRooms: 8)) + let deferred = deferFulfillment(context.$viewState) { $0.roomListMode == .rooms } + roomListSubject.send(.mockRooms) + try await deferred.fulfill() + + // A filter or a search without matches empties the list, the mode must not regress. + roomListSubject.send([]) + stateSubject.send(.loaded(totalNumberOfRooms: 9)) + try await Task.sleep(for: .milliseconds(100)) + #expect(context.viewState.roomListMode == .rooms) + } + + private func setupViewModelWithManualProvider() -> (CurrentValueSubject<[RoomSummary], Never>, CurrentValueSubject) { + let roomListSubject = CurrentValueSubject<[RoomSummary], Never>([]) + let stateSubject = CurrentValueSubject(.notLoaded) + + let provider = RoomSummaryProviderMock() + provider.roomListPublisher = roomListSubject.asCurrentValuePublisher() + provider.statePublisher = stateSubject.asCurrentValuePublisher() + + setupViewModel(roomSummaryProvider: provider) + + return (roomListSubject, stateSubject) + } + + private func setupViewModel(securityStatePublisher: CurrentValuePublisher? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) { cancellables.removeAll() var rooms: [RoomSummary] = .mockRooms @@ -398,10 +443,10 @@ final class HomeScreenViewModelTests { break } - roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms))) + self.roomSummaryProvider = roomSummaryProvider ?? RoomSummaryProviderMock(.init(state: .loaded(rooms))) clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", - roomSummaryProvider: roomSummaryProvider)) + roomSummaryProvider: self.roomSummaryProvider)) clientProxy.joinRoomViaReturnValue = .success(()) clientProxy.joinRoomAliasReturnValue = .success(())