mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
50b38555b6
Each recent search now has a trailing close button to remove it from the history. The view action is logged without its payload so removed queries don't end up in the logs.
775 lines
35 KiB
Swift
775 lines
35 KiB
Swift
//
|
|
// Copyright 2026 Element Creations Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
import Compound
|
|
import GameController
|
|
import SwiftUI
|
|
import SwiftUIIntrospect
|
|
|
|
struct SearchScreen: View {
|
|
@Bindable var context: SearchScreenViewModel.Context
|
|
|
|
@FocusState private var isSearchFieldFocused: Bool
|
|
/// 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 rows the user can select, in display order. These are the breadcrumbs
|
|
/// until a query is entered, after which they're the active tab's results.
|
|
private var selectableIDs: [String] {
|
|
guard context.viewState.isSearching else {
|
|
return context.viewState.breadcrumbs.map(\.id)
|
|
}
|
|
|
|
return 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.
|
|
Text(L10n.actionSearch)
|
|
.font(.compound.headingLGBold)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
.accessibilityAddTraits(.isHeader)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
|
|
// There's nothing to switch between until the user searches for something.
|
|
if context.viewState.isSearching {
|
|
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
|
|
}
|
|
}
|
|
|
|
// Dismissing the search field (the tab bar's close button) leaves the tab.
|
|
SearchDismissalObserver { context.send(viewAction: .cancel) }
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.background(Color.compound.bgCanvasDefault)
|
|
.conditionalSearchable(searchQuery: $context.searchQuery)
|
|
.searchFocused($isSearchFieldFocused)
|
|
.autocorrectionDisabled(true)
|
|
.scrollDismissesKeyboard(.immediately)
|
|
.background(tabShortcuts)
|
|
.onSubmit(of: .search) {
|
|
context.send(viewAction: .submitQuery)
|
|
// A software keyboard's submit/search button just dismisses; only a hardware return selects.
|
|
if isHardwareKeyboardConnected, selectedID != nil {
|
|
selectCurrent()
|
|
} else {
|
|
isSearchFieldFocused = false
|
|
}
|
|
}
|
|
.searchResultsKeyboardNavigation(moveUp: { moveSelection(backwards: true) },
|
|
moveDown: { moveSelection(backwards: false) },
|
|
cancel: { context.send(viewAction: .cancel) })
|
|
// The TabView calls onAppear/onDisappear each time the search tab is selected/deselected,
|
|
// so the field re-focuses, the query is cleared and the selection resets on every switch.
|
|
.onAppear {
|
|
context.send(viewAction: .appeared)
|
|
isSearchFieldFocused = true
|
|
selectedID = selectableIDs.first
|
|
updateHardwareKeyboardConnected()
|
|
}
|
|
.onDisappear {
|
|
// Previews and snapshot tests mount and unmount the view, don't clear their fixed state.
|
|
guard !ProcessInfo.isXcodePreview, !ProcessInfo.isRunningTests else { return }
|
|
context.searchQuery = ""
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
|
|
updateHardwareKeyboardConnected()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidDisconnect)) { _ in
|
|
updateHardwareKeyboardConnected()
|
|
}
|
|
// 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: selectableIDs.first) {
|
|
selectedID = selectableIDs.first
|
|
}
|
|
// Reset the selection to the top when switching tabs.
|
|
.onChange(of: context.viewState.bindings.searchMode) {
|
|
selectedID = selectableIDs.first
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var emptyState: some View {
|
|
if context.viewState.isSearching {
|
|
TitleAndIcon(title: L10n.commonNoResults,
|
|
subtitle: UntranslatedL10n.screenSearchNoResultsMessage(context.searchQuery),
|
|
icon: \.search,
|
|
iconStyle: .defaultSolid)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(40)
|
|
} else if !context.viewState.breadcrumbs.isEmpty {
|
|
breadcrumbsList
|
|
} else {
|
|
TitleAndIcon(title: UntranslatedL10n.screenSearchEmptyStateTitle,
|
|
subtitle: UntranslatedL10n.screenSearchEmptyStateMessage,
|
|
icon: \.search,
|
|
iconStyle: .defaultSolid)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(40)
|
|
}
|
|
}
|
|
|
|
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,
|
|
isSelected: isHardwareKeyboardConnected && selectedID == room.id)
|
|
.onAppear {
|
|
if room == context.viewState.rooms.first {
|
|
context.send(viewAction: .reachedTop)
|
|
} else if room == context.viewState.rooms.last {
|
|
context.send(viewAction: .reachedBottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
|
|
private var breadcrumbsList: some View {
|
|
List {
|
|
Section {
|
|
ForEach(context.viewState.breadcrumbs) { breadcrumb in
|
|
let isSelected = isHardwareKeyboardConnected && selectedID == breadcrumb.id
|
|
|
|
let onRemove = { context.send(viewAction: .removeBreadcrumb(breadcrumb)) }
|
|
|
|
switch breadcrumb {
|
|
case .query(let query):
|
|
SearchScreenQueryCell(query: query, isSelected: isSelected, onRemove: onRemove) { context.searchQuery = query }
|
|
case .room(let room):
|
|
SearchScreenRoomCell(room: room, context: context, isSelected: isSelected, avatarSize: .searchHistory, onRemove: onRemove)
|
|
}
|
|
}
|
|
} header: {
|
|
Text(UntranslatedL10n.screenSearchRecentSearches)
|
|
.compoundListSectionHeader()
|
|
}
|
|
}
|
|
.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 ids = selectableIDs
|
|
guard let selectedID,
|
|
let currentIndex = ids.firstIndex(of: selectedID)
|
|
else {
|
|
selectedID = ids.first
|
|
return
|
|
}
|
|
|
|
let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1
|
|
guard ids.indices.contains(nextIndex) else { return }
|
|
self.selectedID = ids[nextIndex]
|
|
}
|
|
|
|
private func selectCurrent() {
|
|
guard let selectedID else { return }
|
|
|
|
guard context.viewState.isSearching else {
|
|
let breadcrumb = context.viewState.breadcrumbs.first { $0.id == selectedID }
|
|
switch breadcrumb {
|
|
case .query(let query):
|
|
context.searchQuery = query
|
|
case .room(let room):
|
|
context.send(viewAction: .selectRoom(roomID: room.id))
|
|
case nil:
|
|
break
|
|
}
|
|
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() {
|
|
isHardwareKeyboardConnected = GCKeyboard.coalesced != nil
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
/// Searchable makes the preview contents randomly appear lower
|
|
@ViewBuilder func conditionalSearchable(searchQuery: Binding<String>) -> some View {
|
|
if !ProcessInfo.isXcodePreview, !ProcessInfo.isRunningTests {
|
|
searchable(text: searchQuery, placement: .toolbarPrincipal)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SearchScreenRoomCell: View {
|
|
let room: SearchScreenRoom
|
|
let context: SearchScreenViewModel.Context
|
|
let isSelected: Bool
|
|
var avatarSize: RoomAvatarSizeOnScreen = .search
|
|
var onRemove: (() -> Void)?
|
|
|
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
|
|
|
var body: some View {
|
|
Button {
|
|
context.send(viewAction: .selectRoom(roomID: room.id))
|
|
} label: {
|
|
HStack(spacing: 16) {
|
|
avatar
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(room.title)
|
|
.font(.compound.bodyLGSemibold)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
.lineLimit(1)
|
|
|
|
if !room.description.isEmpty {
|
|
Text(room.description)
|
|
.font(.compound.bodyMD)
|
|
.foregroundStyle(.compound.textSecondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
if let onRemove {
|
|
SearchScreenRemoveBreadcrumbButton(action: onRemove)
|
|
}
|
|
}
|
|
.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: room.avatar,
|
|
avatarSize: .room(on: avatarSize),
|
|
mediaProvider: context.mediaProvider)
|
|
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reports the search field being dismissed, which `isSearching` only exposes to the searchable view's children.
|
|
private struct SearchDismissalObserver: View {
|
|
@Environment(\.isSearching) private var isSearching
|
|
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.frame(width: 0, height: 0)
|
|
.onChange(of: isSearching) { _, isSearching in
|
|
if !isSearching {
|
|
action()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SearchScreenRoomCellButtonStyle: ButtonStyle {
|
|
let isSelected: Bool
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.background(isSelected || configuration.isPressed ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault)
|
|
.contentShape(Rectangle())
|
|
.animation(isSelected ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isSelected)
|
|
}
|
|
}
|
|
|
|
private struct SearchScreenQueryCell: View {
|
|
let query: String
|
|
let isSelected: Bool
|
|
let onRemove: () -> Void
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 16) {
|
|
CompoundIcon(\.history, size: .small, relativeTo: .compound.bodyLG)
|
|
.foregroundStyle(.compound.iconPrimary)
|
|
.scaledFrame(width: 32, height: 32)
|
|
.background(.compound.bgSubtleSecondary, in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
|
.accessibilityHidden(true)
|
|
|
|
ZStack(alignment: .leading) {
|
|
// Hidden text with 2 lines to match the height of the room rows, scaling with dynamic text.
|
|
Text(verbatim: " \n ")
|
|
.font(.compound.bodyLG)
|
|
|
|
Text(query)
|
|
.font(.compound.bodyLG)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
SearchScreenRemoveBreadcrumbButton(action: onRemove)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
.buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected))
|
|
.listRowInsets(.init())
|
|
.listRowSeparator(.hidden)
|
|
.rowDivider()
|
|
}
|
|
}
|
|
|
|
private struct SearchScreenRemoveBreadcrumbButton: View {
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
CompoundIcon(\.close)
|
|
.foregroundStyle(.compound.iconTertiary)
|
|
.accessibilityLabel(L10n.actionRemove)
|
|
}
|
|
// Borderless so the button handles its own taps instead of the row it's nested in.
|
|
.buttonStyle(.borderless)
|
|
}
|
|
}
|
|
|
|
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: .search),
|
|
mediaProvider: context.mediaProvider)
|
|
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SearchScreenMediaPreviewView: View {
|
|
let preview: SearchScreenMediaPreview
|
|
let mediaProvider: MediaProviderProtocol?
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
media
|
|
.scaledFrame(width: 36, height: 36)
|
|
|
|
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)
|
|
.foregroundStyle(.compound.iconPrimary)
|
|
.scaledFrame(width: 36, height: 36)
|
|
.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.
|
|
///
|
|
/// The `.search` role tab owns its text field, so there's no SwiftUI hook for its key presses. We reach the
|
|
/// field through the navigation stack (as ``SearchFieldStyle`` does) and swap its class for one that overrides
|
|
/// `pressesBegan` — the same key interception the old global search performed on its own field.
|
|
func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void, cancel: @escaping () -> Void) -> some View {
|
|
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
|
|
guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else {
|
|
return
|
|
}
|
|
|
|
if !(textField is KeyNavigatingSearchTextField) {
|
|
object_setClass(textField, KeyNavigatingSearchTextField.self)
|
|
}
|
|
|
|
KeyNavigatingSearchTextField.keyHandler = { keyCode in
|
|
switch keyCode {
|
|
case .keyboardUpArrow:
|
|
moveUp()
|
|
case .keyboardDownArrow:
|
|
moveDown()
|
|
case .keyboardEscape:
|
|
cancel()
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `UISearchTextField` whose class is installed at runtime via `object_setClass`, so it must not declare any
|
|
/// stored properties. The handler is static, which is fine as there's only ever a single search field.
|
|
private final class KeyNavigatingSearchTextField: UISearchTextField {
|
|
static var keyHandler: ((UIKeyboardHIDUsage) -> Bool)?
|
|
|
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
|
if let keyCode = presses.first?.key?.keyCode, Self.keyHandler?(keyCode) == true {
|
|
return
|
|
}
|
|
super.pressesBegan(presses, with: event)
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct SearchScreen_Previews: PreviewProvider, TestablePreview {
|
|
static let emptyViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
|
clientProxy: makeClientProxy(),
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
userSettings: UserSettings.volatile())
|
|
static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
|
clientProxy: makeClientProxy(),
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
userSettings: UserSettings.volatile(),
|
|
initialSearchQuery: "John Doe")
|
|
static let roomsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
|
clientProxy: makeClientProxy(),
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
userSettings: UserSettings.volatile(),
|
|
initialSearchQuery: "Foundation")
|
|
static let messagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
|
clientProxy: makeClientProxy(searchService: makeSearchService(results: .mockResults)),
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
userSettings: UserSettings.volatile(),
|
|
initialSearchQuery: "Foundation",
|
|
initialSearchMode: .messages)
|
|
static let loadingMessagesViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
|
clientProxy: makeClientProxy(searchService: makeSearchService(paginationState: .loading)),
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
userSettings: UserSettings.volatile(),
|
|
initialSearchQuery: "Foundation",
|
|
initialSearchMode: .messages)
|
|
|
|
static let breadcrumbsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
|
clientProxy: makeClientProxy(),
|
|
mediaProvider: MediaProviderMock(.init()),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
userSettings: makeUserSettings(breadcrumbs: [.room(roomID: "!room1:matrix.org"),
|
|
.query("Bob"),
|
|
.room(roomID: "!room5:matrix.org"),
|
|
.query("Element HR"),
|
|
.query("Alice")]))
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
SearchScreen(context: emptyViewModel.context)
|
|
}
|
|
.previewDisplayName("Empty")
|
|
|
|
ElementNavigationStack {
|
|
SearchScreen(context: breadcrumbsViewModel.context)
|
|
}
|
|
.previewDisplayName("Recent searches")
|
|
|
|
ElementNavigationStack {
|
|
SearchScreen(context: noResultsViewModel.context)
|
|
}
|
|
.previewDisplayName("No results")
|
|
|
|
ElementNavigationStack {
|
|
SearchScreen(context: roomsViewModel.context)
|
|
}
|
|
.previewDisplayName("Rooms")
|
|
|
|
ElementNavigationStack {
|
|
SearchScreen(context: messagesViewModel.context)
|
|
}
|
|
.previewDisplayName("Messages")
|
|
|
|
ElementNavigationStack {
|
|
SearchScreen(context: loadingMessagesViewModel.context)
|
|
}
|
|
.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 makeUserSettings(breadcrumbs: [SearchBreadcrumb]) -> UserSettings {
|
|
let userSettings = UserSettings.volatile()
|
|
userSettings.searchBreadcrumbs = breadcrumbs
|
|
return userSettings
|
|
}
|
|
|
|
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
|
|
let name = names[id] ?? id
|
|
return .mock(id: id, name: name, canonicalAlias: "#\(name.lowercased()):matrix.org")
|
|
}
|
|
return mock
|
|
}
|
|
}
|
|
|
|
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)
|
|
]
|
|
}
|
|
}
|