mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +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:
@@ -1,5 +1,7 @@
|
|||||||
"screen_home_tab_search" = "Search";
|
"screen_home_tab_search" = "Search";
|
||||||
"screen_search_empty_state_message" = "Search for rooms";
|
"screen_search_empty_state_message" = "Search for chats and messages";
|
||||||
|
"screen_search_tab_messages" = "Messages";
|
||||||
|
"screen_search_tab_rooms" = "Rooms";
|
||||||
"screen_search_empty_state_title" = "Start searching...";
|
"screen_search_empty_state_title" = "Start searching...";
|
||||||
"screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term.";
|
"screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term.";
|
||||||
"soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages.";
|
"soft_logout_clear_data_dialog_content" = "Clear all data currently stored on this device?\nSign in again to access your account data and messages.";
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ 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,
|
||||||
mediaProvider: flowParameters.userSession.mediaProvider))
|
mediaProvider: flowParameters.userSession.mediaProvider))
|
||||||
let searchStackCoordinator = NavigationStackCoordinator()
|
let searchStackCoordinator = NavigationStackCoordinator()
|
||||||
searchStackCoordinator.setRootCoordinator(searchCoordinator)
|
searchStackCoordinator.setRootCoordinator(searchCoordinator)
|
||||||
@@ -222,20 +224,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable:next function_body_length
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
searchScreenCoordinator?.actionsPublisher
|
|
||||||
.sink { [weak self] action in
|
|
||||||
guard let self else { return }
|
|
||||||
switch action {
|
|
||||||
case .presentRoom(let roomID):
|
|
||||||
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
|
||||||
case .cancel:
|
|
||||||
// Return to the tab the user came from, but never back into search.
|
|
||||||
navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
chatsTabFlowCoordinator.actionsPublisher
|
chatsTabFlowCoordinator.actionsPublisher
|
||||||
.sink { [weak self] action in
|
.sink { [weak self] action in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -334,6 +324,23 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
searchScreenCoordinator?.actionsPublisher
|
||||||
|
.sink { [weak self] action in
|
||||||
|
guard let self else { return }
|
||||||
|
switch action {
|
||||||
|
case .presentRoom(let roomID, let eventID):
|
||||||
|
if let eventID {
|
||||||
|
handleAppRoute(.event(eventID: eventID, roomID: roomID, via: []), animated: true)
|
||||||
|
} else {
|
||||||
|
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
||||||
|
}
|
||||||
|
case .cancel:
|
||||||
|
// Return to the tab the user came from, but never back into search.
|
||||||
|
navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Onboarding
|
// MARK: - Onboarding
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import Foundation
|
|||||||
internal nonisolated enum UntranslatedL10n {
|
internal nonisolated enum UntranslatedL10n {
|
||||||
/// Search
|
/// Search
|
||||||
internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") }
|
internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") }
|
||||||
/// Search for rooms
|
/// Search for chats and messages
|
||||||
internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") }
|
internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") }
|
||||||
/// Start searching...
|
/// Start searching...
|
||||||
internal static var screenSearchEmptyStateTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_title") }
|
internal static var screenSearchEmptyStateTitle: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_title") }
|
||||||
@@ -20,6 +20,10 @@ internal nonisolated enum UntranslatedL10n {
|
|||||||
internal static func screenSearchNoResultsMessage(_ p1: Any) -> String {
|
internal static func screenSearchNoResultsMessage(_ p1: Any) -> String {
|
||||||
return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1))
|
return UntranslatedL10n.tr("Untranslated", "screen_search_no_results_message", String(describing: p1))
|
||||||
}
|
}
|
||||||
|
/// Messages
|
||||||
|
internal static var screenSearchTabMessages: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_messages") }
|
||||||
|
/// Rooms
|
||||||
|
internal static var screenSearchTabRooms: String { return UntranslatedL10n.tr("Untranslated", "screen_search_tab_rooms") }
|
||||||
/// Clear all data currently stored on this device?
|
/// Clear all data currently stored on this device?
|
||||||
/// Sign in again to access your account data and messages.
|
/// Sign in again to access your account data and messages.
|
||||||
internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }
|
internal static var softLogoutClearDataDialogContent: String { return UntranslatedL10n.tr("Untranslated", "soft_logout_clear_data_dialog_content") }
|
||||||
|
|||||||
@@ -2354,6 +2354,11 @@ nonisolated class ClientProxyMock: ClientProxyProtocol, @unchecked Sendable {
|
|||||||
set(value) { underlyingSpaceService = value }
|
set(value) { underlyingSpaceService = value }
|
||||||
}
|
}
|
||||||
nonisolated(unsafe) var underlyingSpaceService: SpaceServiceProxyProtocol!
|
nonisolated(unsafe) var underlyingSpaceService: SpaceServiceProxyProtocol!
|
||||||
|
var searchService: SearchServiceProxyProtocol {
|
||||||
|
get { return underlyingSearchService }
|
||||||
|
set(value) { underlyingSearchService = value }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var underlyingSearchService: SearchServiceProxyProtocol!
|
||||||
var capabilities: HomeserverCapabilitiesProxyProtocol {
|
var capabilities: HomeserverCapabilitiesProxyProtocol {
|
||||||
get { return underlyingCapabilities }
|
get { return underlyingCapabilities }
|
||||||
set(value) { underlyingCapabilities = value }
|
set(value) { underlyingCapabilities = value }
|
||||||
@@ -11035,6 +11040,78 @@ nonisolated class RoomThreadListServiceProxyMock: RoomThreadListServiceProxyProt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
nonisolated class SearchServiceProxyMock: SearchServiceProxyProtocol, @unchecked Sendable {
|
||||||
|
var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> {
|
||||||
|
get { return underlyingResultsPublisher }
|
||||||
|
set(value) { underlyingResultsPublisher = value }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var underlyingResultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never>!
|
||||||
|
var paginationStatePublisher: CurrentValuePublisher<SearchServicePaginationState, Never> {
|
||||||
|
get { return underlyingPaginationStatePublisher }
|
||||||
|
set(value) { underlyingPaginationStatePublisher = value }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var underlyingPaginationStatePublisher: CurrentValuePublisher<SearchServicePaginationState, Never>!
|
||||||
|
|
||||||
|
//MARK: - setQuery
|
||||||
|
|
||||||
|
private let setQueryCallsCountLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var setQueryUnderlyingCallsCount = 0
|
||||||
|
var setQueryCallsCount: Int {
|
||||||
|
get { setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount } }
|
||||||
|
set { setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount = newValue } }
|
||||||
|
}
|
||||||
|
var setQueryCalled: Bool {
|
||||||
|
return setQueryCallsCount > 0
|
||||||
|
}
|
||||||
|
private let setQueryReceivedQueryLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var setQueryUnderlyingReceivedQuery: String?
|
||||||
|
var setQueryReceivedQuery: String? {
|
||||||
|
get { setQueryReceivedQueryLock.withLock { setQueryUnderlyingReceivedQuery } }
|
||||||
|
set { setQueryReceivedQueryLock.withLock { setQueryUnderlyingReceivedQuery = newValue } }
|
||||||
|
}
|
||||||
|
private let setQueryReceivedInvocationsLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var setQueryUnderlyingReceivedInvocations: [String] = []
|
||||||
|
var setQueryReceivedInvocations: [String] {
|
||||||
|
get { setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations } }
|
||||||
|
set { setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let setQueryReturnValueLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var setQueryUnderlyingReturnValue: Result<Void, SearchServiceProxyError>!
|
||||||
|
var setQueryReturnValue: Result<Void, SearchServiceProxyError>! {
|
||||||
|
get { setQueryReturnValueLock.withLock { setQueryUnderlyingReturnValue } }
|
||||||
|
set { setQueryReturnValueLock.withLock { setQueryUnderlyingReturnValue = newValue } }
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var setQueryClosure: ((String) async -> Result<Void, SearchServiceProxyError>)?
|
||||||
|
|
||||||
|
@concurrent func setQuery(_ query: String) async -> Result<Void, SearchServiceProxyError> {
|
||||||
|
setQueryCallsCountLock.withLock { setQueryUnderlyingCallsCount += 1 }
|
||||||
|
setQueryReceivedQuery = query
|
||||||
|
setQueryReceivedInvocationsLock.withLock { setQueryUnderlyingReceivedInvocations.append(query) }
|
||||||
|
if let setQueryClosure = setQueryClosure {
|
||||||
|
return await setQueryClosure(query)
|
||||||
|
} else {
|
||||||
|
return setQueryReturnValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//MARK: - paginate
|
||||||
|
|
||||||
|
private let paginateCallsCountLock = NSLock()
|
||||||
|
private nonisolated(unsafe) var paginateUnderlyingCallsCount = 0
|
||||||
|
var paginateCallsCount: Int {
|
||||||
|
get { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount } }
|
||||||
|
set { paginateCallsCountLock.withLock { paginateUnderlyingCallsCount = newValue } }
|
||||||
|
}
|
||||||
|
var paginateCalled: Bool {
|
||||||
|
return paginateCallsCount > 0
|
||||||
|
}
|
||||||
|
nonisolated(unsafe) var paginateClosure: (() async -> Void)?
|
||||||
|
|
||||||
|
@concurrent func paginate() async {
|
||||||
|
paginateCallsCountLock.withLock { paginateUnderlyingCallsCount += 1 }
|
||||||
|
await paginateClosure?()
|
||||||
|
}
|
||||||
|
}
|
||||||
nonisolated class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sendable {
|
nonisolated class SecureBackupControllerMock: SecureBackupControllerProtocol, @unchecked Sendable {
|
||||||
var recoveryState: CurrentValuePublisher<SecureBackupRecoveryState, Never> {
|
var recoveryState: CurrentValuePublisher<SecureBackupRecoveryState, Never> {
|
||||||
get { return underlyingRecoveryState }
|
get { return underlyingRecoveryState }
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ enum UserAvatarSizeOnScreen {
|
|||||||
case threadSummary
|
case threadSummary
|
||||||
case map
|
case map
|
||||||
case classicAppAccount
|
case classicAppAccount
|
||||||
|
case globalSearch
|
||||||
|
|
||||||
var value: CGFloat {
|
var value: CGFloat {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -107,7 +108,7 @@ enum UserAvatarSizeOnScreen {
|
|||||||
case .roomDetails:
|
case .roomDetails:
|
||||||
44
|
44
|
||||||
case .inviteUsers, .knockingUserList, .sessionVerification,
|
case .inviteUsers, .knockingUserList, .sessionVerification,
|
||||||
.settings, .threadList, .mediaPreviewDetails:
|
.settings, .threadList, .mediaPreviewDetails, .globalSearch:
|
||||||
52
|
52
|
||||||
case .roomChangeRoles:
|
case .roomChangeRoles:
|
||||||
56
|
56
|
||||||
@@ -148,13 +149,13 @@ enum RoomAvatarSizeOnScreen {
|
|||||||
switch self {
|
switch self {
|
||||||
case .timeline, .leaveSpace, .notificationSettings, .roomDirectorySearch,
|
case .timeline, .leaveSpace, .notificationSettings, .roomDirectorySearch,
|
||||||
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
|
.completionSuggestions, .authorizedSpaces, .createRoomSelectSpace,
|
||||||
.spaceFilters, .globalSearch:
|
.spaceFilters:
|
||||||
32
|
32
|
||||||
case .messageForwarding, .roomSelection,
|
case .messageForwarding, .roomSelection,
|
||||||
.spaceAddRooms:
|
.spaceAddRooms:
|
||||||
36
|
36
|
||||||
case .chats, .spaces, .spaceSettings,
|
case .chats, .spaces, .spaceSettings,
|
||||||
.spaceAddRoomsSelected:
|
.spaceAddRoomsSelected, .globalSearch:
|
||||||
52
|
52
|
||||||
case .joinRoom, .spaceHeader, .editSpaceDetails:
|
case .joinRoom, .spaceHeader, .editSpaceDetails:
|
||||||
64
|
64
|
||||||
|
|||||||
@@ -301,3 +301,9 @@ nonisolated extension SDKListener: SearchServiceResultsListener where T == [Sear
|
|||||||
onUpdateClosure(updates)
|
onUpdateClosure(updates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonisolated extension SDKListener: SearchServicePaginationStateListener where T == MatrixRustSDK.SearchServicePaginationState {
|
||||||
|
func onUpdate(paginationState: MatrixRustSDK.SearchServicePaginationState) {
|
||||||
|
onUpdateClosure(paginationState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SearchScreenCoordinatorParameters {
|
struct SearchScreenCoordinatorParameters {
|
||||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||||
|
let searchService: SearchServiceProxyProtocol
|
||||||
|
let clientProxy: ClientProxyProtocol
|
||||||
let mediaProvider: MediaProviderProtocol
|
let mediaProvider: MediaProviderProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SearchScreenCoordinatorAction {
|
enum SearchScreenCoordinatorAction {
|
||||||
case presentRoom(roomID: String)
|
case presentRoom(roomID: String, eventID: String?)
|
||||||
case cancel
|
case cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@ 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,
|
||||||
mediaProvider: parameters.mediaProvider)
|
mediaProvider: parameters.mediaProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +41,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol {
|
|||||||
viewModel.actionsPublisher.sink { [weak self] action in
|
viewModel.actionsPublisher.sink { [weak self] action in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
switch action {
|
switch action {
|
||||||
case .presentRoom(let roomID):
|
case .presentRoom(let roomID, let eventID):
|
||||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
|
||||||
case .cancel:
|
case .cancel:
|
||||||
actionsSubject.send(.cancel)
|
actionsSubject.send(.cancel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,31 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SearchScreenViewModelAction {
|
enum SearchScreenViewModelAction {
|
||||||
case presentRoom(roomID: String)
|
case presentRoom(roomID: String, eventID: String?)
|
||||||
case cancel
|
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 {
|
struct SearchScreenViewState: BindableState {
|
||||||
var rooms = [SearchScreenRoom]()
|
var rooms = [SearchScreenRoom]()
|
||||||
|
var messages = [SearchScreenMessage]()
|
||||||
|
var isLoadingRooms = false
|
||||||
|
var isLoadingMessages = false
|
||||||
var bindings: SearchScreenViewStateBindings
|
var bindings: SearchScreenViewStateBindings
|
||||||
|
|
||||||
var isSearching: Bool {
|
var isSearching: Bool {
|
||||||
@@ -23,11 +42,13 @@ struct SearchScreenViewState: BindableState {
|
|||||||
|
|
||||||
struct SearchScreenViewStateBindings {
|
struct SearchScreenViewStateBindings {
|
||||||
var searchQuery = ""
|
var searchQuery = ""
|
||||||
|
var searchMode: SearchScreenMode = .rooms
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SearchScreenViewAction {
|
enum SearchScreenViewAction {
|
||||||
case appeared
|
case appeared
|
||||||
case selectRoom(roomID: String)
|
case selectRoom(roomID: String)
|
||||||
|
case selectMessage(roomID: String, eventID: String)
|
||||||
case reachedTop
|
case reachedTop
|
||||||
case reachedBottom
|
case reachedBottom
|
||||||
case cancel
|
case cancel
|
||||||
@@ -39,3 +60,99 @@ struct SearchScreenRoom: Identifiable, Equatable {
|
|||||||
let description: String
|
let description: String
|
||||||
let avatar: RoomAvatar
|
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 {
|
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
|
||||||
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||||
|
private let searchService: SearchServiceProxyProtocol
|
||||||
|
private let clientProxy: ClientProxyProtocol
|
||||||
private var searchQueryObservationTask: Task<Void, Never>?
|
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()
|
private let actionsSubject: PassthroughSubject<SearchScreenViewModelAction, Never> = .init()
|
||||||
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
|
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
|
||||||
@@ -21,11 +25,16 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(roomSummaryProvider: RoomSummaryProviderProtocol,
|
init(roomSummaryProvider: RoomSummaryProviderProtocol,
|
||||||
|
searchService: SearchServiceProxyProtocol,
|
||||||
|
clientProxy: ClientProxyProtocol,
|
||||||
mediaProvider: MediaProviderProtocol,
|
mediaProvider: MediaProviderProtocol,
|
||||||
initialSearchQuery: String = "") {
|
initialSearchQuery: String = "",
|
||||||
|
initialSearchMode: SearchScreenMode = .rooms) {
|
||||||
self.roomSummaryProvider = roomSummaryProvider
|
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)
|
mediaProvider: mediaProvider)
|
||||||
|
|
||||||
roomSummaryProvider.roomListPublisher
|
roomSummaryProvider.roomListPublisher
|
||||||
@@ -35,18 +44,54 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.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
|
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 {
|
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
|
||||||
|
// 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)
|
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
isolated deinit {
|
isolated deinit {
|
||||||
searchQueryObservationTask?.cancel()
|
searchQueryObservationTask?.cancel()
|
||||||
|
loadingObservationTask?.cancel()
|
||||||
|
setQueryTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
@@ -56,15 +101,22 @@ 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))
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil))
|
||||||
|
case .selectMessage(let roomID, let eventID):
|
||||||
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
|
||||||
case .reachedTop:
|
case .reachedTop:
|
||||||
updateVisibleRange(edge: .top)
|
if state.bindings.searchMode == .rooms {
|
||||||
|
updateVisibleRange(edge: .top)
|
||||||
|
}
|
||||||
case .reachedBottom:
|
case .reachedBottom:
|
||||||
updateVisibleRange(edge: .bottom)
|
switch state.bindings.searchMode {
|
||||||
|
case .rooms:
|
||||||
|
updateVisibleRange(edge: .bottom)
|
||||||
|
case .messages:
|
||||||
|
Task { await searchService.paginate() }
|
||||||
|
}
|
||||||
case .cancel:
|
case .cancel:
|
||||||
actionsSubject.send(.cancel)
|
actionsSubject.send(.cancel)
|
||||||
}
|
}
|
||||||
@@ -73,17 +125,24 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func updateFilter(for searchQuery: String) {
|
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 {
|
if searchQuery.isEmpty {
|
||||||
roomSummaryProvider.setFilter(.excludeAll)
|
roomSummaryProvider.setFilter(.excludeAll)
|
||||||
|
state.messages = []
|
||||||
} else {
|
} else {
|
||||||
roomSummaryProvider.setFilter(.search(query: searchQuery))
|
roomSummaryProvider.setFilter(.search(query: searchQuery))
|
||||||
|
setQueryTask = Task { [weak self] in
|
||||||
|
await self?.searchService.setQuery(searchQuery)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateRooms(with summaries: [RoomSummary]) {
|
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
|
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 {
|
let identifier = if summary.isDirect {
|
||||||
summary.heroes.first?.id ?? summary.canonicalAlias
|
summary.heroes.first?.id ?? summary.canonicalAlias
|
||||||
} else {
|
} 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) {
|
private func updateVisibleRange(edge: UIRectEdge) {
|
||||||
switch edge {
|
switch edge {
|
||||||
case .top:
|
case .top:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
import Compound
|
import Compound
|
||||||
import GameController
|
import GameController
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -14,11 +15,20 @@ struct SearchScreen: View {
|
|||||||
@Bindable var context: SearchScreenViewModel.Context
|
@Bindable var context: SearchScreenViewModel.Context
|
||||||
|
|
||||||
@FocusState private var isSearchFieldFocused: Bool
|
@FocusState private var isSearchFieldFocused: Bool
|
||||||
/// The room highlighted for hardware keyboard selection (arrow keys + return).
|
/// The result highlighted for hardware keyboard selection (arrow keys + return). Holds the id of a
|
||||||
@State private var selectedRoomID: String?
|
/// 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.
|
/// The selection is only meaningful with a hardware keyboard, so don't highlight anything otherwise.
|
||||||
@State private var isHardwareKeyboardConnected = false
|
@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 {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
// Rendered as content rather than a navigation title so it stays visible while the search field is focused.
|
// 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(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
if context.viewState.rooms.isEmpty {
|
Picker(L10n.actionSearch, selection: $context.searchMode) {
|
||||||
emptyState
|
ForEach(SearchScreenMode.allCases) { mode in
|
||||||
} else {
|
Text(mode.title).tag(mode)
|
||||||
roomList
|
}
|
||||||
|
}
|
||||||
|
.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)
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
@@ -41,10 +77,11 @@ struct SearchScreen: View {
|
|||||||
.searchable(text: $context.searchQuery, placement: .toolbarPrincipal)
|
.searchable(text: $context.searchQuery, placement: .toolbarPrincipal)
|
||||||
.searchFocused($isSearchFieldFocused)
|
.searchFocused($isSearchFieldFocused)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
|
.background(tabShortcuts)
|
||||||
.onSubmit(of: .search) {
|
.onSubmit(of: .search) {
|
||||||
// A software keyboard's submit/search button just dismisses; only a hardware return selects.
|
// A software keyboard's submit/search button just dismisses; only a hardware return selects.
|
||||||
if isHardwareKeyboardConnected, let selectedRoomID {
|
if isHardwareKeyboardConnected, selectedID != nil {
|
||||||
context.send(viewAction: .selectRoom(roomID: selectedRoomID))
|
selectCurrent()
|
||||||
} else {
|
} else {
|
||||||
isSearchFieldFocused = false
|
isSearchFieldFocused = false
|
||||||
}
|
}
|
||||||
@@ -57,7 +94,7 @@ struct SearchScreen: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
context.send(viewAction: .appeared)
|
context.send(viewAction: .appeared)
|
||||||
isSearchFieldFocused = true
|
isSearchFieldFocused = true
|
||||||
selectedRoomID = context.viewState.rooms.first?.id
|
selectedID = selectableIDs.first
|
||||||
updateHardwareKeyboardConnected()
|
updateHardwareKeyboardConnected()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .GCKeyboardDidConnect)) { _ in
|
.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
|
// 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.
|
// so pagination and background updates that leave it untouched don't clobber the selection.
|
||||||
.onChange(of: context.viewState.rooms.first?.id) {
|
.onChange(of: selectableIDs.first) {
|
||||||
selectedRoomID = context.viewState.rooms.first?.id
|
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 {
|
private var roomList: some View {
|
||||||
List {
|
List {
|
||||||
ForEach(context.viewState.rooms) { room in
|
ForEach(context.viewState.rooms) { room in
|
||||||
SearchScreenRoomCell(room: room,
|
SearchScreenRoomCell(room: room,
|
||||||
context: context,
|
context: context,
|
||||||
isLast: room == context.viewState.rooms.last,
|
isLast: room == context.viewState.rooms.last,
|
||||||
isSelected: isHardwareKeyboardConnected && selectedRoomID == room.id)
|
isSelected: isHardwareKeyboardConnected && selectedID == room.id)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if room == context.viewState.rooms.first {
|
if room == context.viewState.rooms.first {
|
||||||
context.send(viewAction: .reachedTop)
|
context.send(viewAction: .reachedTop)
|
||||||
@@ -111,18 +158,54 @@ struct SearchScreen: View {
|
|||||||
.compoundList(.plain)
|
.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) {
|
private func moveSelection(backwards: Bool) {
|
||||||
let rooms = context.viewState.rooms
|
let ids = selectableIDs
|
||||||
guard let selectedRoomID,
|
guard let selectedID,
|
||||||
let currentIndex = rooms.firstIndex(where: { $0.id == selectedRoomID })
|
let currentIndex = ids.firstIndex(of: selectedID)
|
||||||
else {
|
else {
|
||||||
selectedRoomID = rooms.first?.id
|
selectedID = ids.first
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1
|
let nextIndex = backwards ? currentIndex - 1 : currentIndex + 1
|
||||||
guard rooms.indices.contains(nextIndex) else { return }
|
guard ids.indices.contains(nextIndex) else { return }
|
||||||
self.selectedRoomID = rooms[nextIndex].id
|
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() {
|
private func updateHardwareKeyboardConnected() {
|
||||||
@@ -142,12 +225,12 @@ private struct SearchScreenRoomCell: View {
|
|||||||
Button {
|
Button {
|
||||||
context.send(viewAction: .selectRoom(roomID: room.id))
|
context.send(viewAction: .selectRoom(roomID: room.id))
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 16) {
|
||||||
avatar
|
avatar
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(room.title)
|
Text(room.title)
|
||||||
.font(.compound.bodyLG)
|
.font(.compound.bodyLGSemibold)
|
||||||
.foregroundStyle(.compound.textPrimary)
|
.foregroundStyle(.compound.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
@@ -161,7 +244,7 @@ private struct SearchScreenRoomCell: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
.buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected))
|
.buttonStyle(SearchScreenRoomCellButtonStyle(isSelected: isSelected))
|
||||||
.listRowInsets(.init())
|
.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 {
|
private extension View {
|
||||||
/// Forwards hardware up/down arrow and escape presses from the system search field to the given closures.
|
/// 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 {
|
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(),
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
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([]))),
|
static let noResultsViewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded([]))),
|
||||||
|
searchService: makeSearchService(),
|
||||||
|
clientProxy: makeClientProxy(),
|
||||||
mediaProvider: MediaProviderMock(.init()),
|
mediaProvider: MediaProviderMock(.init()),
|
||||||
initialSearchQuery: "John Doe")
|
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 {
|
static var previews: some View {
|
||||||
ElementNavigationStack {
|
ElementNavigationStack {
|
||||||
@@ -262,8 +514,91 @@ struct SearchScreen_Previews: PreviewProvider, TestablePreview {
|
|||||||
.previewDisplayName("No results")
|
.previewDisplayName("No results")
|
||||||
|
|
||||||
ElementNavigationStack {
|
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)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,27 +11,41 @@ import MatrixRustSDK
|
|||||||
class SearchServiceProxy: SearchServiceProxyProtocol {
|
class SearchServiceProxy: SearchServiceProxyProtocol {
|
||||||
private let searchService: SearchServiceProtocol
|
private let searchService: SearchServiceProtocol
|
||||||
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
private let timelineItemFactory: RoomTimelineItemFactoryProtocol
|
||||||
|
|
||||||
// periphery:ignore - required for instance retention in the rust codebase
|
// periphery:ignore - required for instance retention in the rust codebase
|
||||||
private var resultsHandle: TaskHandle?
|
private var resultsHandle: TaskHandle?
|
||||||
|
|
||||||
private let resultsSubject = CurrentValueSubject<[SearchServiceResult], Never>([])
|
private let resultsSubject = CurrentValueSubject<[SearchServiceResult], Never>([])
|
||||||
var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> {
|
var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> {
|
||||||
resultsSubject.asCurrentValuePublisher()
|
resultsSubject.asCurrentValuePublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// periphery:ignore - required for instance retention in the rust codebase
|
||||||
|
private var paginationStateHandle: TaskHandle?
|
||||||
|
|
||||||
|
private let paginationStateSubject: CurrentValueSubject<SearchServicePaginationState, Never>
|
||||||
|
var paginationStatePublisher: CurrentValuePublisher<SearchServicePaginationState, Never> {
|
||||||
|
paginationStateSubject.asCurrentValuePublisher()
|
||||||
|
}
|
||||||
|
|
||||||
init(searchService: SearchServiceProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) {
|
init(searchService: SearchServiceProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) {
|
||||||
self.searchService = searchService
|
self.searchService = searchService
|
||||||
self.timelineItemFactory = timelineItemFactory
|
self.timelineItemFactory = timelineItemFactory
|
||||||
|
|
||||||
|
paginationStateSubject = CurrentValueSubject(.init(sdkState: searchService.paginationState()))
|
||||||
|
|
||||||
|
paginationStateHandle = searchService.subscribeToPaginationStateUpdates(listener: SDKListener.onMainActor { [weak self] state in
|
||||||
|
self?.paginationStateSubject.send(.init(sdkState: state))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setQuery(_ query: String) async -> Result<Void, SearchServiceProxyError> {
|
func setQuery(_ query: String) async -> Result<Void, SearchServiceProxyError> {
|
||||||
if resultsHandle == nil {
|
if resultsHandle == nil {
|
||||||
resultsHandle = await searchService.subscribeToResults(listener: SDKListener.onMainActor { [weak self] updates in
|
resultsHandle = await searchService.subscribeToResults(listener: SDKListener.onMainActor { [weak self] updates in
|
||||||
self?.handleResultUpdates(updates)
|
self?.handleResultUpdates(updates)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await searchService.setQuery(query: query)
|
try await searchService.setQuery(query: query)
|
||||||
return .success(())
|
return .success(())
|
||||||
@@ -40,7 +54,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
|
|||||||
return .failure(.sdkError(error))
|
return .failure(.sdkError(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func paginate() async {
|
func paginate() async {
|
||||||
do {
|
do {
|
||||||
try await searchService.paginate()
|
try await searchService.paginate()
|
||||||
@@ -48,12 +62,12 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
|
|||||||
MXLog.error("Failed to paginate search results: \(error)")
|
MXLog.error("Failed to paginate search results: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private func handleResultUpdates(_ updates: [SearchServiceResultsUpdate]) {
|
private func handleResultUpdates(_ updates: [SearchServiceResultsUpdate]) {
|
||||||
var results = resultsSubject.value
|
var results = resultsSubject.value
|
||||||
|
|
||||||
for update in updates {
|
for update in updates {
|
||||||
switch update {
|
switch update {
|
||||||
case .append(let values):
|
case .append(let values):
|
||||||
@@ -88,15 +102,15 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
|
|||||||
results = values.compactMap { self.makeResult($0) }
|
results = values.compactMap { self.makeResult($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resultsSubject.send(results)
|
resultsSubject.send(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeResult(_ searchResult: MatrixRustSDK.SearchServiceResult) -> SearchServiceResult? {
|
private func makeResult(_ searchResult: MatrixRustSDK.SearchServiceResult) -> SearchServiceResult? {
|
||||||
switch searchResult {
|
switch searchResult {
|
||||||
case .message(let roomID, let result):
|
case .message(let roomID, let result):
|
||||||
let sender = TimelineItemSender(senderID: result.sender, senderProfile: result.senderProfile)
|
let sender = TimelineItemSender(senderID: result.sender, senderProfile: result.senderProfile)
|
||||||
|
|
||||||
let content: TimelineEventContent
|
let content: TimelineEventContent
|
||||||
switch result.content {
|
switch result.content {
|
||||||
case .msgLike(let msgLike):
|
case .msgLike(let msgLike):
|
||||||
@@ -111,7 +125,7 @@ class SearchServiceProxy: SearchServiceProxyProtocol {
|
|||||||
default:
|
default:
|
||||||
content = .message(.text(.init(body: L10n.commonUnsupportedEvent)))
|
content = .message(.text(.init(body: L10n.commonUnsupportedEvent)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return SearchServiceResult(roomID: roomID,
|
return SearchServiceResult(roomID: roomID,
|
||||||
eventID: result.eventId,
|
eventID: result.eventId,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ enum SearchServiceProxyError: Error {
|
|||||||
// sourcery: AutoMockable
|
// sourcery: AutoMockable
|
||||||
protocol SearchServiceProxyProtocol {
|
protocol SearchServiceProxyProtocol {
|
||||||
var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { get }
|
var resultsPublisher: CurrentValuePublisher<[SearchServiceResult], Never> { get }
|
||||||
|
|
||||||
|
var paginationStatePublisher: CurrentValuePublisher<SearchServicePaginationState, Never> { get }
|
||||||
|
|
||||||
func setQuery(_ query: String) async -> Result<Void, SearchServiceProxyError>
|
func setQuery(_ query: String) async -> Result<Void, SearchServiceProxyError>
|
||||||
|
|
||||||
/// Loads the next page of results. No-ops if a page is already loading or the end has been reached.
|
/// Loads the next page of results. No-ops if a page is already loading or the end has been reached.
|
||||||
func paginate() async
|
func paginate() async
|
||||||
}
|
}
|
||||||
@@ -29,3 +31,19 @@ struct SearchServiceResult {
|
|||||||
let content: TimelineEventContent
|
let content: TimelineEventContent
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SearchServicePaginationState: Equatable {
|
||||||
|
case idle(endReached: Bool)
|
||||||
|
case loading
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchServicePaginationState {
|
||||||
|
init(sdkState: MatrixRustSDK.SearchServicePaginationState) {
|
||||||
|
switch sdkState {
|
||||||
|
case .loading:
|
||||||
|
self = .loading
|
||||||
|
case .idle(let endReached):
|
||||||
|
self = .idle(endReached: endReached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
@testable import ElementX
|
@testable import ElementX
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -16,7 +17,14 @@ struct SearchScreenViewModelTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
let searchService = SearchServiceProxyMock()
|
||||||
|
searchService.underlyingResultsPublisher = CurrentValueSubject<[SearchServiceResult], Never>([]).asCurrentValuePublisher()
|
||||||
|
searchService.underlyingPaginationStatePublisher = CurrentValueSubject(.idle(endReached: true)).asCurrentValuePublisher()
|
||||||
|
searchService.setQueryReturnValue = .success(())
|
||||||
|
|
||||||
viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
viewModel = SearchScreenViewModel(roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
|
||||||
|
searchService: searchService,
|
||||||
|
clientProxy: ClientProxyMock(.init()),
|
||||||
mediaProvider: MediaProviderMock(.init()))
|
mediaProvider: MediaProviderMock(.init()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +39,7 @@ struct SearchScreenViewModelTests {
|
|||||||
func roomSelection() async throws {
|
func roomSelection() async throws {
|
||||||
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
|
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||||
switch action {
|
switch action {
|
||||||
case .presentRoom(let roomID):
|
case .presentRoom(let roomID, _):
|
||||||
roomID == "2"
|
roomID == "2"
|
||||||
case .cancel:
|
case .cancel:
|
||||||
false
|
false
|
||||||
|
|||||||
Reference in New Issue
Block a user