diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index ceae85c74..5b2c0f474 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -98,7 +98,6 @@ 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() diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index 677436ebf..29711a08c 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -87,7 +87,7 @@ enum UserAvatarSizeOnScreen { case threadSummary case map case classicAppAccount - case globalSearch + case search var value: CGFloat { switch self { @@ -108,7 +108,7 @@ enum UserAvatarSizeOnScreen { case .roomDetails: 44 case .inviteUsers, .knockingUserList, .sessionVerification, - .settings, .threadList, .mediaPreviewDetails, .globalSearch: + .settings, .threadList, .mediaPreviewDetails, .search: 52 case .roomChangeRoles: 56 @@ -131,7 +131,7 @@ enum RoomAvatarSizeOnScreen { case timeline case leaveSpace case messageForwarding - case globalSearch + case search case roomSelection case details case editRoomDetails @@ -155,7 +155,7 @@ enum RoomAvatarSizeOnScreen { .spaceAddRooms: 36 case .chats, .spaces, .spaceSettings, - .spaceAddRoomsSelected, .globalSearch: + .spaceAddRoomsSelected, .search: 52 case .joinRoom, .spaceHeader, .editSpaceDetails: 64 diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index a5d1a52ea..12f5fc5d2 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -10,7 +10,6 @@ import SwiftUI struct SearchScreenCoordinatorParameters { let roomSummaryProvider: RoomSummaryProviderProtocol - let searchService: SearchServiceProxyProtocol let clientProxy: ClientProxyProtocol let mediaProvider: MediaProviderProtocol } @@ -32,7 +31,6 @@ final class SearchScreenCoordinator: CoordinatorProtocol { init(parameters: SearchScreenCoordinatorParameters) { viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, - searchService: parameters.searchService, clientProxy: parameters.clientProxy, mediaProvider: parameters.mediaProvider) } diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index 7d50ed9b7..76ae4f65a 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -25,14 +25,13 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } init(roomSummaryProvider: RoomSummaryProviderProtocol, - searchService: SearchServiceProxyProtocol, clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, initialSearchQuery: String = "", initialSearchMode: SearchScreenMode = .rooms) { self.roomSummaryProvider = roomSummaryProvider - self.searchService = searchService self.clientProxy = clientProxy + searchService = clientProxy.searchService super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)), mediaProvider: mediaProvider) @@ -68,18 +67,18 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro } .store(in: &cancellables) + let debouncedSearchQueryStream = context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates() searchQueryObservationTask = Task { [weak self] in - guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates() else { return } - for await searchQuery in stream { + for await searchQuery in debouncedSearchQueryStream { 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. + let searchQueryStream = context.observe(\.viewState.bindings.searchQuery).removeDuplicates() loadingObservationTask = Task { [weak self] in - guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } - for await searchQuery in stream { + for await searchQuery in searchQueryStream { self?.state.isLoadingRooms = !searchQuery.isEmpty self?.state.isLoadingMessages = !searchQuery.isEmpty } @@ -101,6 +100,8 @@ 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, eventID: nil)) @@ -156,6 +157,12 @@ 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 f0f3fd3be..dfacbc0ad 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -267,7 +267,7 @@ private struct SearchScreenRoomCell: View { private var avatar: some View { if dynamicTypeSize < .accessibility3 { RoomAvatarImage(avatar: room.avatar, - avatarSize: .room(on: .globalSearch), + avatarSize: .room(on: .search), mediaProvider: context.mediaProvider) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .accessibilityHidden(true) @@ -338,7 +338,7 @@ private struct SearchScreenMessageCell: View { private var avatar: some View { if dynamicTypeSize < .accessibility3 { RoomAvatarImage(avatar: message.roomAvatar, - avatarSize: .room(on: .globalSearch), + avatarSize: .room(on: .search), mediaProvider: context.mediaProvider) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .accessibilityHidden(true) @@ -350,12 +350,10 @@ 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) + .scaledFrame(width: 36, height: 36) VStack(alignment: .leading, spacing: 0) { Text(preview.title) @@ -390,9 +388,9 @@ private struct SearchScreenMediaPreviewView: View { } private func icon(_ icon: KeyPath) -> some View { - CompoundIcon(icon, size: .medium, relativeTo: .body) + CompoundIcon(icon) .foregroundStyle(.compound.iconPrimary) - .frame(width: Self.mediaSize, height: Self.mediaSize) + .scaledFrame(width: 36, height: 36) .background(.compound.iconOnSolidPrimary, in: RoundedRectangle(cornerRadius: 4, style: .continuous)) } @@ -470,49 +468,27 @@ 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 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(), + clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)), mediaProvider: MediaProviderMock(.init()), initialSearchQuery: "Foundation", initialSearchMode: .messages) static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), - searchService: makeSearchService(paginationState: .loading), - clientProxy: makeClientProxy(), + clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)), 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 { SearchScreen(context: emptyViewModel.context) @@ -539,6 +515,24 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview { } .previewDisplayName("Loading 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(searchService: SearchServiceProxyMock = makeSearchService()) -> ClientProxyMock { + let mock = ClientProxyMock(.init(userID: "@alice:matrix.org")) + mock.searchService = searchService + 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 + } } private extension [SearchServiceResult] { diff --git a/ElementX/Sources/Services/Search/SearchServiceProxy.swift b/ElementX/Sources/Services/Search/SearchServiceProxy.swift index c04deb18f..9aa17448f 100644 --- a/ElementX/Sources/Services/Search/SearchServiceProxy.swift +++ b/ElementX/Sources/Services/Search/SearchServiceProxy.swift @@ -71,7 +71,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { for update in updates { switch update { case .append(let values): - results.append(contentsOf: values.compactMap { self.makeResult($0) }) + results.append(contentsOf: values.compactMap(makeResult)) case .clear: results.removeAll() case .pushFront(let value): @@ -99,7 +99,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol { case .truncate(let length): results.removeSubrange(Int(length)..