Address search PR review comments

- Drop the redundant searchService coordinator/view model parameter; derive it from the client proxy.
- Rename the globalSearch avatar size to search.
- Lift the query observation streams out of their tasks.
- Restore the appeared and updateVisibleRange comments.
- Use CompoundIcon defaults and a scaledFrame for media previews.
- Simplify the result mapping with compactMap(makeResult) and drop unsupported events by returning nil.
- Move the preview helpers beneath the previews.
This commit is contained in:
Stefan Ceriu
2026-07-03 11:57:23 +03:00
parent 4c2243cda1
commit 02d9ed1f0e
7 changed files with 50 additions and 50 deletions
@@ -98,7 +98,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) { if flowParameters.appSettings.globalSearchEnabled, #available(iOS 26.0, *) {
let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider, let searchCoordinator = SearchScreenCoordinator(parameters: .init(roomSummaryProvider: flowParameters.userSession.clientProxy.alternateRoomSummaryProvider,
searchService: flowParameters.userSession.clientProxy.searchService,
clientProxy: flowParameters.userSession.clientProxy, clientProxy: flowParameters.userSession.clientProxy,
mediaProvider: flowParameters.userSession.mediaProvider)) mediaProvider: flowParameters.userSession.mediaProvider))
let searchStackCoordinator = NavigationStackCoordinator() let searchStackCoordinator = NavigationStackCoordinator()
+4 -4
View File
@@ -87,7 +87,7 @@ enum UserAvatarSizeOnScreen {
case threadSummary case threadSummary
case map case map
case classicAppAccount case classicAppAccount
case globalSearch case search
var value: CGFloat { var value: CGFloat {
switch self { switch self {
@@ -108,7 +108,7 @@ enum UserAvatarSizeOnScreen {
case .roomDetails: case .roomDetails:
44 44
case .inviteUsers, .knockingUserList, .sessionVerification, case .inviteUsers, .knockingUserList, .sessionVerification,
.settings, .threadList, .mediaPreviewDetails, .globalSearch: .settings, .threadList, .mediaPreviewDetails, .search:
52 52
case .roomChangeRoles: case .roomChangeRoles:
56 56
@@ -131,7 +131,7 @@ enum RoomAvatarSizeOnScreen {
case timeline case timeline
case leaveSpace case leaveSpace
case messageForwarding case messageForwarding
case globalSearch case search
case roomSelection case roomSelection
case details case details
case editRoomDetails case editRoomDetails
@@ -155,7 +155,7 @@ enum RoomAvatarSizeOnScreen {
.spaceAddRooms: .spaceAddRooms:
36 36
case .chats, .spaces, .spaceSettings, case .chats, .spaces, .spaceSettings,
.spaceAddRoomsSelected, .globalSearch: .spaceAddRoomsSelected, .search:
52 52
case .joinRoom, .spaceHeader, .editSpaceDetails: case .joinRoom, .spaceHeader, .editSpaceDetails:
64 64
@@ -10,7 +10,6 @@ import SwiftUI
struct SearchScreenCoordinatorParameters { struct SearchScreenCoordinatorParameters {
let roomSummaryProvider: RoomSummaryProviderProtocol let roomSummaryProvider: RoomSummaryProviderProtocol
let searchService: SearchServiceProxyProtocol
let clientProxy: ClientProxyProtocol let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol let mediaProvider: MediaProviderProtocol
} }
@@ -32,7 +31,6 @@ final class SearchScreenCoordinator: CoordinatorProtocol {
init(parameters: SearchScreenCoordinatorParameters) { init(parameters: SearchScreenCoordinatorParameters) {
viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider, viewModel = SearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
searchService: parameters.searchService,
clientProxy: parameters.clientProxy, clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider) mediaProvider: parameters.mediaProvider)
} }
@@ -25,14 +25,13 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
} }
init(roomSummaryProvider: RoomSummaryProviderProtocol, init(roomSummaryProvider: RoomSummaryProviderProtocol,
searchService: SearchServiceProxyProtocol,
clientProxy: ClientProxyProtocol, clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
initialSearchQuery: String = "", initialSearchQuery: String = "",
initialSearchMode: SearchScreenMode = .rooms) { initialSearchMode: SearchScreenMode = .rooms) {
self.roomSummaryProvider = roomSummaryProvider self.roomSummaryProvider = roomSummaryProvider
self.searchService = searchService
self.clientProxy = clientProxy self.clientProxy = clientProxy
searchService = clientProxy.searchService
super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)), super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)),
mediaProvider: mediaProvider) mediaProvider: mediaProvider)
@@ -68,18 +67,18 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
} }
.store(in: &cancellables) .store(in: &cancellables)
let debouncedSearchQueryStream = context.observe(\.viewState.bindings.searchQuery).debounce(for: .milliseconds(250)).removeDuplicates()
searchQueryObservationTask = Task { [weak self] in 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 debouncedSearchQueryStream {
for await searchQuery in stream {
self?.updateFilter(for: searchQuery) self?.updateFilter(for: searchQuery)
} }
} }
// Flip the loading indicator on the moment the user starts typing, ahead of the debounced // 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. // 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 loadingObservationTask = Task { [weak self] in
guard let stream = self?.context.observe(\.viewState.bindings.searchQuery).removeDuplicates() else { return } for await searchQuery in searchQueryStream {
for await searchQuery in stream {
self?.state.isLoadingRooms = !searchQuery.isEmpty self?.state.isLoadingRooms = !searchQuery.isEmpty
self?.state.isLoadingMessages = !searchQuery.isEmpty self?.state.isLoadingMessages = !searchQuery.isEmpty
} }
@@ -101,6 +100,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
switch viewAction { switch viewAction {
case .appeared: 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) updateFilter(for: state.bindings.searchQuery)
case .selectRoom(let roomID): case .selectRoom(let roomID):
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil)) 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) { private func updateVisibleRange(edge: UIRectEdge) {
switch edge { switch edge {
case .top: case .top:
@@ -267,7 +267,7 @@ private struct SearchScreenRoomCell: View {
private var avatar: some View { private var avatar: some View {
if dynamicTypeSize < .accessibility3 { if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.avatar, RoomAvatarImage(avatar: room.avatar,
avatarSize: .room(on: .globalSearch), avatarSize: .room(on: .search),
mediaProvider: context.mediaProvider) mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true) .accessibilityHidden(true)
@@ -338,7 +338,7 @@ private struct SearchScreenMessageCell: View {
private var avatar: some View { private var avatar: some View {
if dynamicTypeSize < .accessibility3 { if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: message.roomAvatar, RoomAvatarImage(avatar: message.roomAvatar,
avatarSize: .room(on: .globalSearch), avatarSize: .room(on: .search),
mediaProvider: context.mediaProvider) mediaProvider: context.mediaProvider)
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
.accessibilityHidden(true) .accessibilityHidden(true)
@@ -350,12 +350,10 @@ private struct SearchScreenMediaPreviewView: View {
let preview: SearchScreenMediaPreview let preview: SearchScreenMediaPreview
let mediaProvider: MediaProviderProtocol? let mediaProvider: MediaProviderProtocol?
private static let mediaSize: CGFloat = 36
var body: some View { var body: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
media media
.frame(width: Self.mediaSize, height: Self.mediaSize) .scaledFrame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(preview.title) Text(preview.title)
@@ -390,9 +388,9 @@ private struct SearchScreenMediaPreviewView: View {
} }
private func icon(_ icon: KeyPath<CompoundIcons, Image>) -> some View { private func icon(_ icon: KeyPath<CompoundIcons, Image>) -> some View {
CompoundIcon(icon, size: .medium, relativeTo: .body) CompoundIcon(icon)
.foregroundStyle(.compound.iconPrimary) .foregroundStyle(.compound.iconPrimary)
.frame(width: Self.mediaSize, height: Self.mediaSize) .scaledFrame(width: 36, height: 36)
.background(.compound.iconOnSolidPrimary, .background(.compound.iconOnSolidPrimary,
in: RoundedRectangle(cornerRadius: 4, style: .continuous)) in: RoundedRectangle(cornerRadius: 4, style: .continuous))
} }
@@ -470,49 +468,27 @@ private final class KeyNavigatingSearchTextField: UISearchTextField {
struct SearchScreen_Previews: PreviewProvider, TestablePreview { struct SearchScreen_Previews: PreviewProvider, TestablePreview {
static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
searchService: makeSearchService(),
clientProxy: makeClientProxy(), clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init())) mediaProvider: MediaProviderMock(.init()))
static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
searchService: makeSearchService(),
clientProxy: makeClientProxy(), clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()), mediaProvider: MediaProviderMock(.init()),
initialSearchQuery: "John Doe") initialSearchQuery: "John Doe")
static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
searchService: makeSearchService(),
clientProxy: makeClientProxy(), clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()), mediaProvider: MediaProviderMock(.init()),
initialSearchQuery: "Foundation") initialSearchQuery: "Foundation")
static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
searchService: makeSearchService(results: .mockResults), clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)),
clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()), mediaProvider: MediaProviderMock(.init()),
initialSearchQuery: "Foundation", initialSearchQuery: "Foundation",
initialSearchMode: .messages) initialSearchMode: .messages)
static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))), static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
searchService: makeSearchService(paginationState: .loading), clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)),
clientProxy: makeClientProxy(),
mediaProvider: MediaProviderMock(.init()), mediaProvider: MediaProviderMock(.init()),
initialSearchQuery: "Foundation", initialSearchQuery: "Foundation",
initialSearchMode: .messages) 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 { static var previews: some View {
ElementNavigationStack { ElementNavigationStack {
SearchScreen(context: emptyViewModel.context) SearchScreen(context: emptyViewModel.context)
@@ -539,6 +515,24 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
} }
.previewDisplayName("Loading messages") .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] { private extension [SearchServiceResult] {
@@ -71,7 +71,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
for update in updates { for update in updates {
switch update { switch update {
case .append(let values): case .append(let values):
results.append(contentsOf: values.compactMap { self.makeResult($0) }) results.append(contentsOf: values.compactMap(makeResult))
case .clear: case .clear:
results.removeAll() results.removeAll()
case .pushFront(let value): case .pushFront(let value):
@@ -99,7 +99,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
case .truncate(let length): case .truncate(let length):
results.removeSubrange(Int(length)..<results.count) results.removeSubrange(Int(length)..<results.count)
case .reset(let values): case .reset(let values):
results = values.compactMap { self.makeResult($0) } results = values.compactMap(makeResult)
} }
} }
@@ -128,10 +128,10 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
case .liveLocation: case .liveLocation:
content = .liveLocation content = .liveLocation
default: default:
content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) return nil
} }
default: default:
content = .message(.text(.init(body: L10n.commonUnsupportedEvent))) return nil
} }
return SearchServiceResult(roomID: roomID, return SearchServiceResult(roomID: roomID,
@@ -22,9 +22,11 @@ struct SearchScreenViewModelTests {
searchService.underlyingPaginationStatePublisher = CurrentValueSubject(.idle(endReached: true)).asCurrentValuePublisher() searchService.underlyingPaginationStatePublisher = CurrentValueSubject(.idle(endReached: true)).asCurrentValuePublisher()
searchService.setQueryReturnValue = .success(()) searchService.setQueryReturnValue = .success(())
let clientProxy = ClientProxyMock(.init())
clientProxy.searchService = searchService
viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
searchService: searchService, clientProxy: clientProxy,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MediaProviderMock(.init())) mediaProvider: MediaProviderMock(.init()))
} }