From 4fbfc87551c0582ed925c773833a5d4428edd5df Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 2 Jul 2026 13:25:56 +0300 Subject: [PATCH] Add a Messages tab to global search Split global search into Rooms and Messages tabs. The Messages tab lists matching events with the room's name and avatar, a sender-prefixed preview, rich media (image/video/audio/file) previews, and a loading indicator, then paginates as you scroll. Queries are debounced and the previous one is superseded so late results can't clobber a newer search. --- .../en.lproj/Untranslated.strings | 4 +- .../UserSessionFlowCoordinator.swift | 33 +- .../Generated/Strings+Untranslated.swift | 6 +- .../Mocks/Generated/GeneratedMocks.swift | 77 ++++ ElementX/Sources/Other/Avatars.swift | 7 +- ElementX/Sources/Other/SDKListener.swift | 6 + .../SearchScreenCoordinator.swift | 10 +- .../SearchScreen/SearchScreenModels.swift | 119 +++++- .../SearchScreen/SearchScreenViewModel.swift | 85 +++- .../SearchScreen/View/SearchScreen.swift | 389 ++++++++++++++++-- .../Services/Search/SearchServiceProxy.swift | 40 +- .../Search/SearchServiceProxyProtocol.swift | 22 +- .../Sources/SearchScreenViewModelTests.swift | 10 +- 13 files changed, 727 insertions(+), 81 deletions(-) diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 91af059f3..8ee776e7f 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -1,5 +1,7 @@ "screen_home_tab_search" = "Search"; -"screen_search_empty_state_message" = "Search for rooms"; +"screen_search_empty_state_message" = "Search for chats and messages"; +"screen_search_tab_messages" = "Messages"; +"screen_search_tab_rooms" = "Rooms"; "screen_search_empty_state_title" = "Start searching..."; "screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term."; "soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages."; diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index bc51a073f..ceae85c74 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -98,6 +98,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) { let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, + searchService: flowParameters.userSession.clientProxy.searchService, + clientProxy: flowParameters.userSession.clientProxy, mediaProvider: flowParameters.userSession.mediaProvider)) let searchStackCoordinator = NavigationStackCoordinator() searchStackCoordinator.setRootCoordinator(searchCoordinator) @@ -222,20 +224,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } + // swiftlint:disable:next function_body_length private func setupObservers() { - searchScreenCoordinator?.actionsPublisher - .sink { [weak self] action in - guard let self else { return } - switch action { - case .presentRoom(let roomID): - handleAppRoute(.room(roomID: roomID, via: []), animated: true) - case .cancel: - // Return to the tab the user came from, but never back into search. - navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats - } - } - .store(in: &cancellables) - chatsTabFlowCoordinator.actionsPublisher .sink { [weak self] action in guard let self else { return } @@ -334,6 +324,23 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { } } .store(in: &cancellables) + + searchScreenCoordinator?.actionsPublisher + .sink { [weak self] action in + guard let self else { return } + switch action { + case .presentRoom(let roomID, let eventID): + if let eventID { + handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), animated: true) + } else { + handleAppRoute(.room(roomID: roomID, via: []), animated: true) + } + case .cancel: + // Return to the tab the user came from, but never back into search. + navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats + } + } + .store(in: &cancellables) } // MARK: - Onboarding diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index a24c4b413..270470ff2 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -12,7 +12,7 @@ import Foundation internal nonisolated enum UntranslatedL10n { /// Search internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") } - /// Search for rooms + /// Search for chats and messages internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") } /// Start searching... internal static var screenSearchEmptyStateTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_title") } @@ -20,6 +20,10 @@ internal nonisolated enum UntranslatedL10n { internal static func screenSearchNoResultsMessage(_ p1: Any) -> String { return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1)) } + /// Messages + internal static var screenSearchTabMessages: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_messages") } + /// Rooms + internal static var screenSearchTabRooms: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_rooms") } /// Clear all data currently stored on this device? /// Sign in again to access your account data and messages. internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") } diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 6adea9cec..20f00408c 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -2354,6 +2354,11 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable { set(value) { underlyingSpaceService = value } } nonisolated(unsafe) var underlyingSpaceService: SpaceServiceProxyProtocol! + var searchService: SearchServiceProxyProtocol { + get { return underlyingSearchService } + set(value) { underlyingSearchService = value } + } + nonisolated(unsafe) var underlyingSearchService: SearchServiceProxyProtocol! var capabilities: HomeserverCapabilitiesProxyProtocol { get { return underlyingCapabilities } set(value) { underlyingCapabilities = value } @@ -11035,6 +11040,78 @@ nonisolated class RoomThreadListServiceProxyMock: RoomThreadListServiceProxyProt } } } +nonisolated class SearchServiceProxyMock: SearchServiceProxyProtocol, @unchecked Sendable { + var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { + get { return underlyingResultsPublisher } + set(value) { underlyingResultsPublisher = value } + } + nonisolated(unsafe) var underlyingResultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never>! + var paginationStatePublisher: CurrentValuePublisher { + get { return underlyingPaginationStatePublisher } + set(value) { underlyingPaginationStatePublisher = value } + } + nonisolated(unsafe) var underlyingPaginationStatePublisher: CurrentValuePublisher! + + //MARK: - setQuery + + private let setQueryCallsCountLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingCallsCount = 0 + var setQueryCallsCount: Int { + get { setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount } } + set { setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount = newValue } } + } + var setQueryCalled: Bool { + return setQueryCallsCount > 0 + } + private let setQueryReceivedQueryLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingReceivedQuery: String? + var setQueryReceivedQuery: String? { + get { setQueryReceivedQueryLock.withLock { setQueryUnderlyingReceivedQuery } } + set { setQueryReceivedQueryLock.withLock { setQueryUnderlyingReceivedQuery = newValue } } + } + private let setQueryReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingReceivedInvocations: [String] = [] + var setQueryReceivedInvocations: [String] { + get { setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations } } + set { setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations = newValue } } + } + + private let setQueryReturnValueLock = NSLock() + private nonisolated(unsafe) var setQueryUnderlyingReturnValue: Result! + var setQueryReturnValue: Result! { + get { setQueryReturnValueLock.withLock { setQueryUnderlyingReturnValue } } + set { setQueryReturnValueLock.withLock { setQueryUnderlyingReturnValue = newValue } } + } + nonisolated(unsafe) var setQueryClosure: ((String) async -> Result)? + + @concurrent func setQuery(_ query: String) async -> Result { + setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount += 1 } + setQueryReceivedQuery = query + setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations.append(query) } + if let setQueryClosure = setQueryClosure { + return await setQueryClosure(query) + } else { + return setQueryReturnValue + } + } + //MARK: - paginate + + private let paginateCallsCountLock = NSLock() + private nonisolated(unsafe) var paginateUnderlyingCallsCount = 0 + var paginateCallsCount: Int { + get { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount } } + set { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount = newValue } } + } + var paginateCalled: Bool { + return paginateCallsCount > 0 + } + nonisolated(unsafe) var paginateClosure: (() async -> Void)? + + @concurrent func paginate() async { + paginateCallsCountLock.withLock { paginateUnderlyingCallsCount += 1 } + await paginateClosure?() + } +} nonisolated class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sendable { var recoveryState: CurrentValuePublisher { get { return underlyingRecoveryState } diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 383344f6e..677436ebf 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -87,6 +87,7 @@ enum UserAvatarSizeOnScreen { case threadSummary case map case classicAppAccount + case globalSearch var value: CGFloat { switch self { @@ -107,7 +108,7 @@ enum UserAvatarSizeOnScreen { case .roomDetails: 44 case .inviteUsers, .knockingUserList, .sessionVerification, - .settings, .threadList, .mediaPreviewDetails: + .settings, .threadList, .mediaPreviewDetails, .globalSearch: 52 case .roomChangeRoles: 56 @@ -148,13 +149,13 @@ enum RoomAvatarSizeOnScreen { switch self { case .timeline, .leaveSpace, .notificationSettings, .roomDirectorySearch, .completionSuggestions, .authorizedSpaces, .createRoomSelectSpace, - .spaceFilters, .globalSearch: + .spaceFilters: 32 case .messageForwarding, .roomSelection, .spaceAddRooms: 36 case .chats, .spaces, .spaceSettings, - .spaceAddRoomsSelected: + .spaceAddRoomsSelected, .globalSearch: 52 case .joinRoom, .spaceHeader, .editSpaceDetails: 64 diff --git a/ElementX/Sources/Other/SDKListener.swift b/ElementX/Sources/Other/SDKListener.swift index d8f6f4165..2de4c8716 100644 --- a/ElementX/Sources/Other/SDKListener.swift +++ b/ElementX/Sources/Other/SDKListener.swift @@ -301,3 +301,9 @@ nonisolated extension SDKListener: SearchServiceResultsListener where T == [Sear onUpdateClosure(updates) } } + +nonisolated extension SDKListener: SearchServicePaginationStateListener where T == MatrixRustSDK.SearchServicePaginationState { + func onUpdate(paginationState: MatrixRustSDK.SearchServicePaginationState) { + onUpdateClosure(paginationState) + } +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index be5311da4..a5d1a52ea 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -10,11 +10,13 @@ import SwiftUI struct SearchScreenCoordinatorParameters { let roomSummaryProvider: RoomSummaryProviderProtocol + let searchService: SearchServiceProxyProtocol + let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol } enum SearchScreenCoordinatorAction { - case presentRoom(roomID: String) + case presentRoom(roomID: String, eventID: String?) case cancel } @@ -30,6 +32,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol { init(parameters: SearchScreenCoordinatorParameters) { viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, + searchService: parameters.searchService, + clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider) } @@ -37,8 +41,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol { viewModel.actionsPublisher.sink { [weak self] action in guard let self else { return } switch action { - case .presentRoom(let roomID): - actionsSubject.send(.presentRoom(roomID: roomID)) + case .presentRoom(let roomID, let eventID): + actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID)) case .cancel: actionsSubject.send(.cancel) } diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index 63a27d0f2..df673ea81 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -8,12 +8,31 @@ import Foundation enum SearchScreenViewModelAction { - case presentRoom(roomID: String) + case presentRoom(roomID: String, eventID: String?) case cancel } +enum SearchScreenMode: CaseIterable, Identifiable { + case rooms + case messages + + var id: Self { + self + } + + var title: String { + switch self { + case .rooms: UntranslatedL10n.screenSearchTabRooms + case .messages: UntranslatedL10n.screenSearchTabMessages + } + } +} + struct SearchScreenViewState: BindableState { var rooms = [SearchScreenRoom]() + var messages = [SearchScreenMessage]() + var isLoadingRooms = false + var isLoadingMessages = false var bindings: SearchScreenViewStateBindings var isSearching: Bool { @@ -23,11 +42,13 @@ struct SearchScreenViewState: BindableState { struct SearchScreenViewStateBindings { var searchQuery = "" + var searchMode: SearchScreenMode = .rooms } enum SearchScreenViewAction { case appeared case selectRoom(roomID: String) + case selectMessage(roomID: String, eventID: String) case reachedTop case reachedBottom case cancel @@ -39,3 +60,99 @@ struct SearchScreenRoom: Identifiable, Equatable { let description: String let avatar: RoomAvatar } + +struct SearchScreenMessage: Identifiable, Equatable { + let id: String + let roomID: String + let roomName: String + let roomAvatar: RoomAvatar + let senderName: String + let content: TimelineEventContent + let timestamp: Date + + init(_ result: SearchServiceResult, roomSummary: RoomSummary?, isOutgoing: Bool) { + id = result.eventID + roomID = result.roomID + roomName = roomSummary?.name ?? result.roomID + roomAvatar = roomSummary?.avatar ?? .room(id: result.roomID, name: roomSummary?.name, avatarURL: nil) + senderName = isOutgoing ? L10n.commonYou : result.sender.disambiguatedDisplayName ?? result.sender.id + content = result.content + timestamp = result.timestamp + } + + var preview: AttributedString? { + guard let messageBody else { return nil } + return AttributedString("\(senderName): ") + messageBody + } + + private var messageBody: AttributedString? { + switch content { + case .message(let content): + switch content { + case .text(let content): + content.formattedBody ?? AttributedString(content.body) + case .notice(let content): + content.formattedBody ?? AttributedString(content.body) + case .emote(let content): + content.formattedBody ?? AttributedString(content.body) + case .audio, .file, .image, .video: + nil + case .voice: + AttributedString(L10n.commonVoiceMessage) + case .location: + AttributedString(L10n.commonSharedLocation) + } + case .poll(let question): + AttributedString(question) + case .liveLocation: + AttributedString(L10n.commonSharedLiveLocation) + case .redacted: + AttributedString(L10n.commonMessageRemoved) + } + } + + var mediaPreview: SearchScreenMediaPreview? { + guard case .message(let content) = content else { return nil } + switch content { + case .file(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.fileSize), + kind: .file) + case .audio(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.fileSize), + kind: .audio) + case .image(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.imageInfo.fileSize), + kind: .image(thumbnail: content.thumbnailInfo ?? content.imageInfo, blurhash: content.blurhash)) + case .video(let content): + return .init(title: content.caption ?? content.filename, + details: mediaDetails(filename: content.filename, fileSize: content.videoInfo.fileSize), + kind: .video(thumbnail: content.thumbnailInfo, blurhash: content.blurhash)) + case .text, .notice, .emote, .voice, .location: + return nil + } + } + + private func mediaDetails(filename: String, fileSize: UInt?) -> String { + var details = filename.validatedFileExtension.uppercased() + if let fileSize { + details += " (\(fileSize.formatted(.byteCount(style: .file))))" + } + return details + } +} + +struct SearchScreenMediaPreview: Equatable { + enum Kind: Equatable { + case file + case audio + case image(thumbnail: ImageInfoProxy?, blurhash: String?) + case video(thumbnail: ImageInfoProxy?, blurhash: String?) + } + + let title: String + let details: String + let kind: Kind +} diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index 8e902af95..7d50ed9b7 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -13,7 +13,11 @@ typealias SearchScreenViewModelType = StateStoreViewModelV2? + private var loadingObservationTask: Task? + private var setQueryTask: Task? private let actionsSubject: PassthroughSubject = .init() var actionsPublisher: AnyPublisher { @@ -21,11 +25,16 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } init(roomSummaryProvider: RoomSummaryProviderProtocol, + searchService: SearchServiceProxyProtocol, + clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, - initialSearchQuery: String = "") { + initialSearchQuery: String = "", + initialSearchMode: SearchScreenMode = .rooms) { self.roomSummaryProvider = roomSummaryProvider + self.searchService = searchService + self.clientProxy = clientProxy - super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery)), + super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)), mediaProvider: mediaProvider) roomSummaryProvider.roomListPublisher @@ -35,18 +44,54 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } .store(in: &cancellables) + searchService.resultsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] results in + guard let self else { return } + // A previous query's fetch can complete after the field was cleared; drop its late results. + guard !state.bindings.searchQuery.isEmpty else { + state.messages = [] + return + } + state.messages = results.map { result in + SearchScreenMessage(result, + roomSummary: clientProxy.roomSummaryForIdentifier(result.roomID), + isOutgoing: result.sender.id == clientProxy.userID) + } + } + .store(in: &cancellables) + + searchService.paginationStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] paginationState in + self?.state.isLoadingMessages = paginationState == .loading + } + .store(in: &cancellables) + searchQueryObservationTask = Task { [weak self] in - guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } + guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates() else { return } for await searchQuery in stream { self?.updateFilter(for: searchQuery) } } + // Flip the loading indicator on the moment the user starts typing, ahead of the debounced + // query above, so the empty state doesn't flash while the first search is still pending. + loadingObservationTask = Task { [weak self] in + guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } + for await searchQuery in stream { + self?.state.isLoadingRooms = !searchQuery.isEmpty + self?.state.isLoadingMessages = !searchQuery.isEmpty + } + } + updateRooms(with: roomSummaryProvider.roomListPublisher.value) } isolated deinit { searchQueryObservationTask?.cancel() + loadingObservationTask?.cancel() + setQueryTask?.cancel() } // MARK: - Public @@ -56,15 +101,22 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro switch viewAction { case .appeared: - // The provider is shared, so other consumers may have changed its filter while we were off-screen. - // Re-apply ours on every appearance to keep the displayed results in sync with the query. updateFilter(for: state.bindings.searchQuery) case .selectRoom(let roomID): - actionsSubject.send(.presentRoom(roomID: roomID)) + actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil)) + case .selectMessage(let roomID, let eventID): + actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID)) case .reachedTop: - updateVisibleRange(edge: .top) + if state.bindings.searchMode == .rooms { + updateVisibleRange(edge: .top) + } case .reachedBottom: - updateVisibleRange(edge: .bottom) + switch state.bindings.searchMode { + case .rooms: + updateVisibleRange(edge: .bottom) + case .messages: + Task { await searchService.paginate() } + } case .cancel: actionsSubject.send(.cancel) } @@ -73,17 +125,24 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro // MARK: - Private private func updateFilter(for searchQuery: String) { + // Supersede any in-flight query so its results can't land after a newer one's. + setQueryTask?.cancel() + if searchQuery.isEmpty { roomSummaryProvider.setFilter(.excludeAll) + state.messages = [] } else { roomSummaryProvider.setFilter(.search(query: searchQuery)) + setQueryTask = Task { [weak self] in + await self?.searchService.setQuery(searchQuery) + } } } private func updateRooms(with summaries: [RoomSummary]) { + // The list has caught up with the current filter, so we're no longer waiting on results. + state.isLoadingRooms = false state.rooms = summaries.map { summary in - // Show the matrix identifier as the subtitle: the other member's user ID for DMs, - // otherwise the room's canonical alias. let identifier = if summary.isDirect { summary.heroes.first?.id ?? summary.canonicalAlias } else { @@ -97,12 +156,6 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } } - /// The actual range values don't matter as long as they contain the lower - /// or upper bounds. updateVisibleRange is a hybrid API that powers both - /// sliding sync visible range update and list paginations. - /// For lists other than the home screen one we don't care about visible ranges, - /// we just need the respective bounds to be there to trigger a next page load or - /// a reset to just one page. private func updateVisibleRange(edge: UIRectEdge) { switch edge { case .top: diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index b570e324a..23560c749 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine import Compound import GameController import SwiftUI @@ -14,11 +15,20 @@ struct SearchScreen: View { @Bindable var context: SearchScreenViewModel.Context @FocusState private var isSearchFieldFocused: Bool - /// The room highlighted for hardware keyboard selection (arrow keys + return). - @State private var selectedRoomID: String? + /// The result highlighted for hardware keyboard selection (arrow keys + return). Holds the id of a + /// room or a message depending on the active tab. + @State private var selectedID: String? /// The selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise. @State private var isHardwareKeyboardConnected = false + /// The ids of the results in the active tab, in display order. + private var selectableIDs: [String] { + switch context.viewState.bindings.searchMode { + case .rooms: context.viewState.rooms.map(\.id) + case .messages: context.viewState.messages.map(\.id) + } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { // Rendered as content rather than a navigation title so it stays visible while the search field is focused. @@ -30,10 +40,36 @@ struct SearchScreen: View { .padding(.horizontal, 16) .padding(.vertical, 8) - if context.viewState.rooms.isEmpty { - emptyState - } else { - roomList + Picker(L10n.actionSearch, selection: $context.searchMode) { + ForEach(SearchScreenMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.bottom, 8) + + switch context.viewState.bindings.searchMode { + case .rooms: + if context.viewState.rooms.isEmpty { + if context.viewState.isLoadingRooms { + loadingState + } else { + emptyState + } + } else { + roomList + } + case .messages: + if context.viewState.messages.isEmpty { + if context.viewState.isLoadingMessages { + loadingState + } else { + emptyState + } + } else { + messagesList + } } } .frame(maxHeight: .infinity, alignment: .top) @@ -41,10 +77,11 @@ struct SearchScreen: View { .searchable(text: $context.searchQuery, placement: .toolbarPrincipal) .searchFocused($isSearchFieldFocused) .autocorrectionDisabled(true) + .background(tabShortcuts) .onSubmit(of: .search) { // A software keyboard's submit/search button just dismisses; only a hardware return selects. - if isHardwareKeyboardConnected, let selectedRoomID { - context.send(viewAction: .selectRoom(roomID: selectedRoomID)) + if isHardwareKeyboardConnected, selectedID != nil { + selectCurrent() } else { isSearchFieldFocused = false } @@ -57,7 +94,7 @@ struct SearchScreen: View { .onAppear { context.send(viewAction: .appeared) isSearchFieldFocused = true - selectedRoomID = context.viewState.rooms.first?.id + selectedID = selectableIDs.first updateHardwareKeyboardConnected() } .onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in @@ -68,8 +105,12 @@ struct SearchScreen: View { } // Reset to the first result when the results change (e.g. a new query), keyed on the top result // so pagination and background updates that leave it untouched don't clobber the selection. - .onChange(of: context.viewState.rooms.first?.id) { - selectedRoomID = context.viewState.rooms.first?.id + .onChange(of: selectableIDs.first) { + selectedID = selectableIDs.first + } + // Reset the selection to the top when switching tabs. + .onChange(of: context.viewState.bindings.searchMode) { + selectedID = selectableIDs.first } } @@ -92,13 +133,19 @@ struct SearchScreen: View { } } + private var loadingState: some View { + ProgressView() + .frame(maxWidth: .infinity) + .padding(40) + } + private var roomList: some View { List { ForEach(context.viewState.rooms) { room in SearchScreenRoomCell(room: room, context: context, isLast: room == context.viewState.rooms.last, - isSelected: isHardwareKeyboardConnected && selectedRoomID == room.id) + isSelected: isHardwareKeyboardConnected && selectedID == room.id) .onAppear { if room == context.viewState.rooms.first { context.send(viewAction: .reachedTop) @@ -111,18 +158,54 @@ struct SearchScreen: View { .compoundList(.plain) } + private var messagesList: some View { + List { + ForEach(context.viewState.messages) { message in + SearchScreenMessageCell(message: message, + context: context, + isSelected: isHardwareKeyboardConnected && selectedID == message.id) + .onAppear { + if message == context.viewState.messages.last { + context.send(viewAction: .reachedBottom) + } + } + } + } + .compoundList(.plain) + } + + /// Hidden buttons that switch tabs via ⌘1/⌘2, the common shortcut for jumping to tab N. + private var tabShortcuts: some View { + ForEach(Array(SearchScreenMode.allCases.enumerated()), id: \.element) { index, mode in + Button("") { context.searchMode = mode } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)")), modifiers: .command) + .hidden() + } + } + private func moveSelection(backwards: Bool) { - let rooms = context.viewState.rooms - guard let selectedRoomID, - let currentIndex = rooms.firstIndex(where: { $0.id == selectedRoomID }) + let ids = selectableIDs + guard let selectedID, + let currentIndex = ids.firstIndex(of: selectedID) else { - selectedRoomID = rooms.first?.id + selectedID = ids.first return } let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1 - guard rooms.indices.contains(nextIndex) else { return } - self.selectedRoomID = rooms[nextIndex].id + guard ids.indices.contains(nextIndex) else { return } + self.selectedID = ids[nextIndex] + } + + private func selectCurrent() { + guard let selectedID else { return } + switch context.viewState.bindings.searchMode { + case .rooms: + context.send(viewAction: .selectRoom(roomID: selectedID)) + case .messages: + guard let message = context.viewState.messages.first(where: { $0.id == selectedID }) else { return } + context.send(viewAction: .selectMessage(roomID: message.roomID, eventID: message.id)) + } } private func updateHardwareKeyboardConnected() { @@ -142,12 +225,12 @@ private struct SearchScreenRoomCell: View { Button { context.send(viewAction: .selectRoom(roomID: room.id)) } label: { - HStack(spacing: 12) { + HStack(spacing: 16) { avatar - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { Text(room.title) - .font(.compound.bodyLG) + .font(.compound.bodyLGSemibold) .foregroundStyle(.compound.textPrimary) .lineLimit(1) @@ -161,7 +244,7 @@ private struct SearchScreenRoomCell: View { .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.vertical, 12) } .buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected)) .listRowInsets(.init()) @@ -192,6 +275,140 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle { } } +private struct SearchScreenMessageCell: View { + let message: SearchScreenMessage + let context: SearchScreenViewModel.Context + let isSelected: Bool + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + var body: some View { + Button { + context.send(viewAction: .selectMessage(roomID: message.roomID, eventID: message.id)) + } label: { + HStack(alignment: .top, spacing: 16) { + avatar + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(message.roomName) + .font(.compound.bodyLGSemibold) + .foregroundStyle(.compound.textPrimary) + .lineLimit(3) + + Spacer() + + Text(message.timestamp.formattedMinimal()) + .font(.compound.bodySM) + .foregroundStyle(.compound.textSecondary) + } + + if let mediaPreview = message.mediaPreview { + SearchScreenMediaPreviewView(preview: mediaPreview, mediaProvider: context.mediaProvider) + } else if let preview = message.preview { + Text(preview) + .font(.compound.bodyMD) + .foregroundStyle(.compound.textSecondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected)) + .listRowInsets(.init()) + .listRowSeparator(.hidden) + .rowDivider() + } + + @ViewBuilder + private var avatar: some View { + if dynamicTypeSize < .accessibility3 { + RoomAvatarImage(avatar: message.roomAvatar, + avatarSize: .room(on: .globalSearch), + mediaProvider: context.mediaProvider) + .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) + .accessibilityHidden(true) + } + } +} + +private struct SearchScreenMediaPreviewView: View { + let preview: SearchScreenMediaPreview + let mediaProvider: MediaProviderProtocol? + + private static let mediaSize: CGFloat = 36 + + var body: some View { + HStack(spacing: 8) { + media + .frame(width: Self.mediaSize, height: Self.mediaSize) + + VStack(alignment: .leading, spacing: 0) { + Text(preview.title) + .font(.compound.bodyLG) + .foregroundStyle(.compound.textPrimary) + .lineLimit(1) + Text(preview.details) + .font(.compound.bodySM) + .foregroundStyle(.compound.textSecondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.compound.bgSubtleSecondary, in: RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder + private var media: some View { + switch preview.kind { + case .file: + icon(\.attachment) + case .audio: + icon(\.audio) + case .image(let thumbnail, let blurhash): + thumbnailView(thumbnail, blurhash: blurhash, isVideo: false) + case .video(let thumbnail, let blurhash): + thumbnailView(thumbnail, blurhash: blurhash, isVideo: true) + } + } + + private func icon(_ icon: KeyPath) -> some View { + CompoundIcon(icon, size: .medium, relativeTo: .body) + .foregroundStyle(.compound.iconPrimary) + .frame(width: Self.mediaSize, height: Self.mediaSize) + .background(.compound.iconOnSolidPrimary, + in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + } + + private func thumbnailView(_ thumbnail: ImageInfoProxy?, blurhash: String?, isVideo: Bool) -> some View { + Color.compound.bgSubtlePrimary // Let the image aspect fill in place + .overlay { + if let thumbnail { + LoadableImage(mediaSource: thumbnail.source, + blurhash: blurhash, + size: thumbnail.size, + mediaProvider: mediaProvider) { + Color.compound.bgSubtlePrimary + } + .mediaGalleryTimelineAspectRatio(imageInfo: thumbnail) + } + } + .overlay { + if isVideo { + CompoundIcon(\.playSolid, size: .small, relativeTo: .body) + .foregroundStyle(.compound.iconOnSolidPrimary) + } + } + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + } +} + private extension View { /// Forwards hardware up/down arrow and escape presses from the system search field to the given closures. /// @@ -242,13 +459,48 @@ private final class KeyNavigatingSearchTextField: UISearchTextField { struct SearchScreen_Previews: PreviewProvider, TestablePreview { static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(), + clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init())) - static let loadedViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), - mediaProvider: MediaProviderMock(.init()), - initialSearchQuery: "Foundation") static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(), + clientProxy: makeClientProxy(), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "John Doe") + static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + searchService: makeSearchService(), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation") + static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(results: .mockResults), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation", + initialSearchMode: .messages) + static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), + searchService: makeSearchService(paginationState: .loading), + clientProxy: makeClientProxy(), + mediaProvider: MediaProviderMock(.init()), + initialSearchQuery: "Foundation", + initialSearchMode: .messages) + + private static func makeSearchService(results: [SearchServiceResult] = [], paginationState: SearchServicePaginationState = .idle(endReached: true)) -> SearchServiceProxyMock { + let mock = SearchServiceProxyMock() + mock.underlyingResultsPublisher = CurrentValueSubject<[SearchServiceResult], Never>(results).asCurrentValuePublisher() + mock.underlyingPaginationStatePublisher = CurrentValueSubject(paginationState).asCurrentValuePublisher() + mock.setQueryReturnValue = .success(()) + return mock + } + + private static func makeClientProxy() -> ClientProxyMock { + let mock = ClientProxyMock(.init(userID: "@alice:matrix.org")) + let names: [String: String] = ["!room1:matrix.org": "Alice", "!room2:matrix.org": "Bob", "!room3:matrix.org": "Coline", + "!room4:matrix.org": "Bob", "!room5:matrix.org": "Office", "!room6:matrix.org": "Data analytics", + "!room7:matrix.org": "Alice", "!room8:matrix.org": "Bob", "!room9:matrix.org": "Coline"] + mock.roomSummaryForIdentifierClosure = { id in .mock(id: id, name: names[id] ?? id) } + return mock + } static var previews: some View { ElementNavigationStack { @@ -262,8 +514,91 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview { .previewDisplayName("No results") ElementNavigationStack { - SearchScreen(context: loadedViewModel.context) + SearchScreen(context: roomsViewModel.context) } - .previewDisplayName("Loaded") + .previewDisplayName("Rooms") + + ElementNavigationStack { + SearchScreen(context: messagesViewModel.context) + } + .previewDisplayName("Messages") + + ElementNavigationStack { + SearchScreen(context: loadingMessagesViewModel.context) + } + .previewDisplayName("Loading messages") + } +} + +private extension [SearchServiceResult] { + static var mockResults: [SearchServiceResult] { + [ + SearchServiceResult(roomID: "!room1:matrix.org", + eventID: "$1", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + content: .message(.text(.init(body: "Have you read the Foundation series?"))), + timestamp: .now), + SearchServiceResult(roomID: "!room2:matrix.org", + eventID: "$2", + sender: .init(id: "@bob:matrix.org", displayName: "Bob"), + content: .message(.text(.init(body: "The Second Foundation was hidden at the other end of the galaxy."))), + timestamp: .now), + SearchServiceResult(roomID: "!room3:matrix.org", + eventID: "$3", + sender: .init(id: "@coline:matrix.org", displayName: "Coline"), + content: .message(.file(.init(filename: "Foundation.pdf", + caption: nil, + source: nil, + fileSize: 4 * 1024 * 1024, + thumbnailSource: nil, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room4:matrix.org", + eventID: "$4", + sender: .init(id: "@bob:matrix.org", displayName: "Bob"), + content: .message(.audio(.init(filename: "Foundation.mp3", + caption: nil, + duration: 42, + waveform: nil, + source: nil, + fileSize: 4 * 1024 * 1024, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room5:matrix.org", + eventID: "$5", + sender: .init(id: "@office:matrix.org", displayName: "Office"), + content: .message(.image(.init(filename: "Foundation.jpg", + caption: nil, + imageInfo: .mockImage, + thumbnailInfo: .mockThumbnail, + blurhash: nil, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room6:matrix.org", + eventID: "$6", + sender: .init(id: "@data:matrix.org", displayName: "Data analytics"), + content: .message(.video(.init(filename: "Foundation.mp4", + caption: nil, + videoInfo: .mockVideo, + thumbnailInfo: .mockThumbnail, + blurhash: nil, + contentType: nil))), + timestamp: .now), + SearchServiceResult(roomID: "!room7:matrix.org", + eventID: "$7", + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + content: .poll(question: "What's your favourite Foundation book?"), + timestamp: .now), + SearchServiceResult(roomID: "!room8:matrix.org", + eventID: "$8", + sender: .init(id: "@bob:matrix.org", displayName: "Bob"), + content: .liveLocation, + timestamp: .now), + SearchServiceResult(roomID: "!room9:matrix.org", + eventID: "$9", + sender: .init(id: "@coline:matrix.org", displayName: "Coline"), + content: .redacted, + timestamp: .now) + ] } } diff --git a/ElementX/Sources/Services/Search/SearchServiceProxy.swift b/ElementX/Sources/Services/Search/SearchServiceProxy.swift index 317fc8662..43f642e96 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxy.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -11,27 +11,41 @@ import MatrixRustSDK class SearchServiceProxy: SearchServiceProxyProtocol { private let searchService: SearchServiceProtocol private let timelineItemFactory: RoomTimelineItemFactoryProtocol - + // periphery:ignore - required for instance retention in the rust codebase private var resultsHandle: TaskHandle? - + private let resultsSubject = CurrentValueSubject<[SearchServiceResult], Never>([]) var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { resultsSubject.asCurrentValuePublisher() } - + + // periphery:ignore - required for instance retention in the rust codebase + private var paginationStateHandle: TaskHandle? + + private let paginationStateSubject: CurrentValueSubject + var paginationStatePublisher: CurrentValuePublisher { + paginationStateSubject.asCurrentValuePublisher() + } + init(searchService: SearchServiceProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) { self.searchService = searchService self.timelineItemFactory = timelineItemFactory + + paginationStateSubject = CurrentValueSubject(.init(sdkState: searchService.paginationState())) + + paginationStateHandle = searchService.subscribeToPaginationStateUpdates(listener: SDKListener.onMainActor { [weak self] state in + self?.paginationStateSubject.send(.init(sdkState: state)) + }) } - + func setQuery(_ query: String) async -> Result { if resultsHandle == nil { resultsHandle = await searchService.subscribeToResults(listener: SDKListener.onMainActor { [weak self] updates in self?.handleResultUpdates(updates) }) } - + do { try await searchService.setQuery(query: query) return .success(()) @@ -40,7 +54,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { return .failure(.sdkError(error)) } } - + func paginate() async { do { try await searchService.paginate() @@ -48,12 +62,12 @@ class SearchServiceProxy: SearchServiceProxyProtocol { MXLog.error("Failed to paginate search results: \(error)") } } - + // MARK: - Private - + private func handleResultUpdates(_ updates: [SearchServiceResultsUpdate]) { var results = resultsSubject.value - + for update in updates { switch update { case .append(let values): @@ -88,15 +102,15 @@ class SearchServiceProxy: SearchServiceProxyProtocol { results = values.compactMap { self.makeResult($0) } } } - + resultsSubject.send(results) } - + private func makeResult(_ searchResult: MatrixRustSDK.SearchServiceResult) -> SearchServiceResult? { switch searchResult { case .message(let roomID, let result): let sender = TimelineItemSender(senderID: result.sender, senderProfile: result.senderProfile) - + let content: TimelineEventContent switch result.content { case .msgLike(let msgLike): @@ -111,7 +125,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { default: content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) } - + return SearchServiceResult(roomID: roomID, eventID: result.eventId, sender: sender, diff --git a/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift index 0db0fd11a..4378d8dea 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxyProtocol.swift @@ -15,9 +15,11 @@ enum SearchServiceProxyError: Error { // sourcery: AutoMockable protocol SearchServiceProxyProtocol { var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { get } - + + var paginationStatePublisher: CurrentValuePublisher { get } + func setQuery(_ query: String) async -> Result - + /// Loads the next page of results. No-ops if a page is already loading or the end has been reached. func paginate() async } @@ -29,3 +31,19 @@ struct SearchServiceResult { let content: TimelineEventContent let timestamp: Date } + +enum SearchServicePaginationState: Equatable { + case idle(endReached: Bool) + case loading +} + +extension SearchServicePaginationState { + init(sdkState: MatrixRustSDK.SearchServicePaginationState) { + switch sdkState { + case .loading: + self = .loading + case .idle(let endReached): + self = .idle(endReached: endReached) + } + } +} diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index a862b9254..71a607256 100644 --- a/UnitTests/Sources/SearchScreenViewModelTests.swift +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import Combine @testable import ElementX import Testing @@ -16,7 +17,14 @@ struct SearchScreenViewModelTests { } init() { + let searchService = SearchServiceProxyMock() + searchService.underlyingResultsPublisher = CurrentValueSubject<[SearchServiceResult], Never>([]).asCurrentValuePublisher() + searchService.underlyingPaginationStatePublisher = CurrentValueSubject(.idle(endReached: true)).asCurrentValuePublisher() + searchService.setQueryReturnValue = .success(()) + viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + searchService: searchService, + clientProxy: ClientProxyMock(.init()), mediaProvider: MediaProviderMock(.init())) } @@ -31,7 +39,7 @@ struct SearchScreenViewModelTests { func roomSelection() async throws { let deferred = deferFulfillment(viewModel.actionsPublisher) { action in switch action { - case .presentRoom(let roomID): + case .presentRoom(let roomID, _): roomID == "2" case .cancel: false