mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
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:
@@ -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)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user