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.
This commit is contained in:
Stefan Ceriu
2026-07-02 13:25:56 +03:00
parent 5851100f07
commit 4fbfc87551
13 changed files with 727 additions and 81 deletions
@@ -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)
}
@@ -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
}
@@ -13,7 +13,11 @@ typealias SearchScreenViewModelType = StateStoreViewModelV2<SearchScreenViewStat
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
private let roomSummaryProvider: RoomSummaryProviderProtocol
private let searchService: SearchServiceProxyProtocol
private let clientProxy: ClientProxyProtocol
private var searchQueryObservationTask: Task<Void, Never>?
private var loadingObservationTask: Task<Void, Never>?
private var setQueryTask: Task<Void, Never>?
private let actionsSubject: PassthroughSubject<SearchScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
@@ -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:
@@ -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<CompoundIcons, Image>) -> 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)
]
}
}