mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-01 01:03:01 +00:00
c41a38159b
* Initial plan * Offer only joined rooms in destination pickers Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com> * Clean whitespace in share picker test Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com> * Restrict the shared picker room list to joined rooms The alternate room summary provider, which backs the forward, share and Add rooms pickers and global search, now always includes the SDK's joined filter in its base filter. This replaces the per-call joinedOnly flags, so Add rooms no longer offers invited rooms or spaces and no screen can forget the flag. A filter set before the provider has its room list is now applied once the list is set, instead of being reset to the default. The share picker hit this on a cold launch from the share extension. The forward and share pickers also hide upgraded rooms, matching the Suggestions section. The SDK has no filter for these. Also registers the share picker's test file in the project, fixes the blank-line indentation SwiftFormat flagged, and re-records the picker snapshots without the upgraded room. * Check room membership for forwarding suggestions and search breadcrumbs RoomSummary now has isJoined, built from the room's membership. The forwarding suggestions and the search breadcrumbs used a missing joinRequestType to mean joined, which is also the case for rooms that have been left or banned from. It only worked because the room list's base filter already drops those. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com> Co-authored-by: Mauro Romito <mauro.romito@element.io>
274 lines
12 KiB
Swift
274 lines
12 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 AsyncAlgorithms
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
typealias SearchScreenViewModelType = StateStoreViewModelV2<SearchScreenViewState, SearchScreenViewAction>
|
|
|
|
class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelProtocol {
|
|
/// The number of entries kept in the search history.
|
|
private static let maximumBreadcrumbCount = 25
|
|
|
|
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
|
private let clientProxy: ClientProxyProtocol
|
|
private let searchService: SearchServiceProxyProtocol
|
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
|
private let userSettings: UserSettings
|
|
private var searchQueryObservationTask: Task<Void, Never>?
|
|
private var searchModeObservationTask: Task<Void, Never>?
|
|
private var loadingObservationTask: Task<Void, Never>?
|
|
private var setQueryTask: Task<Void, Never>?
|
|
/// The query each tab last searched, so switching tabs only re-searches when the query changed.
|
|
private var searchedQueries: [SearchScreenMode: String] = [:]
|
|
|
|
private let actionsSubject: PassthroughSubject<SearchScreenViewModelAction, Never> = .init()
|
|
var actionsPublisher: AnyPublisher<SearchScreenViewModelAction, Never> {
|
|
actionsSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
init(roomSummaryProvider: RoomSummaryProviderProtocol,
|
|
clientProxy: ClientProxyProtocol,
|
|
mediaProvider: MediaProviderProtocol,
|
|
userIndicatorController: UserIndicatorControllerProtocol,
|
|
userSettings: UserSettings,
|
|
initialSearchQuery: String = "",
|
|
initialSearchMode: SearchScreenMode = .rooms) {
|
|
self.roomSummaryProvider = roomSummaryProvider
|
|
self.clientProxy = clientProxy
|
|
searchService = clientProxy.searchService
|
|
self.userIndicatorController = userIndicatorController
|
|
self.userSettings = userSettings
|
|
|
|
super.init(initialViewState: SearchScreenViewState(bindings: .init(searchQuery: initialSearchQuery, searchMode: initialSearchMode)),
|
|
mediaProvider: mediaProvider)
|
|
|
|
roomSummaryProvider.roomListPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] summaries in
|
|
self?.updateRooms(with: summaries)
|
|
}
|
|
.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)
|
|
|
|
// The room list is empty on a cold start, so the room breadcrumbs are re-resolved as it loads.
|
|
userSettings.searchBreadcrumbsPublisher
|
|
.combineLatest(clientProxy.staticRoomSummaryProvider.roomListPublisher)
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] storedBreadcrumbs, _ in
|
|
guard let self else { return }
|
|
let breadcrumbs = makeBreadcrumbs(from: storedBreadcrumbs)
|
|
guard breadcrumbs != state.breadcrumbs else { return }
|
|
state.breadcrumbs = breadcrumbs
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
searchService.paginationStatePublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] paginationState in
|
|
self?.state.isLoadingMessages = paginationState == .loading
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// Room search is fast so it reacts to every keystroke; message search debounces inside updateFilter.
|
|
let searchQueryStream = context.observe(\.viewState.bindings.searchQuery).removeDuplicates()
|
|
searchQueryObservationTask = Task { [weak self] in
|
|
for await searchQuery in searchQueryStream {
|
|
self?.updateFilter(for: searchQuery)
|
|
}
|
|
}
|
|
|
|
// Re-run the search when switching tabs so the newly active tab reflects the current query.
|
|
let searchModeStream = context.observe(\.viewState.bindings.searchMode).removeDuplicates()
|
|
searchModeObservationTask = Task { [weak self] in
|
|
for await searchMode in searchModeStream {
|
|
guard let self else { return }
|
|
// Opening the messages tab signals the user actually wants history to be searchable.
|
|
if searchMode == .messages {
|
|
clientProxy.startSearchBackfill(strategy: .foreground)
|
|
}
|
|
updateFilter(for: state.bindings.searchQuery)
|
|
}
|
|
}
|
|
|
|
// Flip the loading indicator on the moment the user starts typing, ahead of the debounced
|
|
// message query above, so the empty state doesn't flash while the first search is still pending.
|
|
let loadingQueryStream = context.observe(\.viewState.bindings.searchQuery).removeDuplicates()
|
|
loadingObservationTask = Task { [weak self] in
|
|
for await searchQuery in loadingQueryStream {
|
|
self?.setActiveTabLoading(!searchQuery.isEmpty)
|
|
}
|
|
}
|
|
|
|
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
|
|
state.breadcrumbs = makeBreadcrumbs(from: userSettings.searchBreadcrumbs)
|
|
}
|
|
|
|
isolated deinit {
|
|
searchQueryObservationTask?.cancel()
|
|
searchModeObservationTask?.cancel()
|
|
loadingObservationTask?.cancel()
|
|
setQueryTask?.cancel()
|
|
}
|
|
|
|
// MARK: - Public
|
|
|
|
override func process(viewAction: SearchScreenViewAction) {
|
|
MXLog.info("View model: received view action: \(viewAction)")
|
|
|
|
switch viewAction {
|
|
case .appeared:
|
|
// The provider is shared, so other consumers may have changed its filter while we were off-screen.
|
|
// Re-apply ours on every appearance to keep the displayed results in sync with the query.
|
|
updateFilter(for: state.bindings.searchQuery, forced: true)
|
|
case .submitQuery:
|
|
guard !state.bindings.searchQuery.isEmpty else { return }
|
|
recordBreadcrumb(.query(state.bindings.searchQuery))
|
|
case .selectRoom(let roomID):
|
|
recordBreadcrumb(.room(roomID: roomID))
|
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: nil))
|
|
case .selectMessage(let roomID, let eventID):
|
|
recordBreadcrumb(.room(roomID: roomID))
|
|
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
|
|
case .removeBreadcrumb(let breadcrumb):
|
|
removeBreadcrumb(breadcrumb)
|
|
case .clearBreadcrumbs:
|
|
userSettings.searchBreadcrumbs = []
|
|
case .reachedTop:
|
|
if state.bindings.searchMode == .rooms {
|
|
updateVisibleRange(edge: .top)
|
|
}
|
|
case .reachedBottom:
|
|
switch state.bindings.searchMode {
|
|
case .rooms:
|
|
updateVisibleRange(edge: .bottom)
|
|
case .messages:
|
|
Task { await searchService.paginate() }
|
|
}
|
|
case .cancel:
|
|
actionsSubject.send(.cancel)
|
|
}
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func updateFilter(for searchQuery: String, forced: Bool = false) {
|
|
guard !searchQuery.isEmpty else {
|
|
// Supersede any in-flight query so its results can't land after a newer one's.
|
|
setQueryTask?.cancel()
|
|
searchedQueries.removeAll()
|
|
roomSummaryProvider.setFilter(.excludeAll)
|
|
state.messages = []
|
|
return
|
|
}
|
|
|
|
// Only search the active tab. Room search is fast while message search is slow, so we
|
|
// avoid one blocking the other; the other tab is (re)searched when it becomes active.
|
|
let mode = state.bindings.searchMode
|
|
// Skip a tab that already ran this query (e.g. switching back and forth) unless forced.
|
|
guard forced || searchedQueries[mode] != searchQuery else { return }
|
|
|
|
setQueryTask?.cancel()
|
|
setActiveTabLoading(true)
|
|
switch mode {
|
|
case .rooms:
|
|
searchedQueries[mode] = searchQuery
|
|
roomSummaryProvider.setFilter(.search(query: searchQuery))
|
|
case .messages:
|
|
setQueryTask = Task { [weak self] in
|
|
// Debounce message queries; superseded keystrokes cancel this before it commits.
|
|
try? await Task.sleep(for: .milliseconds(250))
|
|
guard let self, !Task.isCancelled else { return }
|
|
searchedQueries[mode] = searchQuery
|
|
if case .failure = await searchService.setQuery(searchQuery), !Task.isCancelled {
|
|
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func makeBreadcrumbs(from storedBreadcrumbs: [SearchBreadcrumb]) -> [SearchScreenBreadcrumb] {
|
|
storedBreadcrumbs.compactMap { breadcrumb in
|
|
switch breadcrumb {
|
|
case .query(let query):
|
|
return .query(query)
|
|
case .room(let roomID):
|
|
// Drop rooms the user has since left or is only invited to, we've got nothing to show for them.
|
|
guard let summary = clientProxy.roomSummaryForIdentifier(roomID), summary.isJoined else { return nil }
|
|
return .room(SearchScreenRoom(summary))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Moves the breadcrumb to the top of the history, adding it if needed.
|
|
private func recordBreadcrumb(_ breadcrumb: SearchBreadcrumb) {
|
|
var breadcrumbs = userSettings.searchBreadcrumbs
|
|
breadcrumbs.removeAll { $0 == breadcrumb }
|
|
breadcrumbs.insert(breadcrumb, at: 0)
|
|
|
|
userSettings.searchBreadcrumbs = Array(breadcrumbs.prefix(Self.maximumBreadcrumbCount))
|
|
}
|
|
|
|
private func removeBreadcrumb(_ breadcrumb: SearchScreenBreadcrumb) {
|
|
let storedBreadcrumb: SearchBreadcrumb = switch breadcrumb {
|
|
case .query(let query): .query(query)
|
|
case .room(let room): .room(roomID: room.id)
|
|
}
|
|
userSettings.searchBreadcrumbs.removeAll { $0 == storedBreadcrumb }
|
|
}
|
|
|
|
private func setActiveTabLoading(_ isLoading: Bool) {
|
|
switch state.bindings.searchMode {
|
|
case .rooms:
|
|
state.isLoadingRooms = isLoading
|
|
case .messages:
|
|
state.isLoadingMessages = isLoading
|
|
}
|
|
}
|
|
|
|
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(SearchScreenRoom.init)
|
|
}
|
|
|
|
/// The actual range values don't matter as long as they contain the lower
|
|
/// or upper bounds. updateVisibleRange is a hybrid API that powers both
|
|
/// sliding sync visible range update and list paginations.
|
|
/// For lists other than the home screen one we don't care about visible ranges,
|
|
/// we just need the respective bounds to be there to trigger a next page load or
|
|
/// a reset to just one page.
|
|
private func updateVisibleRange(edge: UIRectEdge) {
|
|
switch edge {
|
|
case .top:
|
|
roomSummaryProvider.updateVisibleRange(0..<0)
|
|
case .bottom:
|
|
let roomCount = roomSummaryProvider.roomListPublisher.value.count
|
|
roomSummaryProvider.updateVisibleRange(roomCount..<roomCount)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|