mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Add support for quick room search through the Cmd+K shortcut (#2363)
This commit is contained in:
@@ -24,10 +24,11 @@ class WindowManager: WindowManagerProtocol {
|
||||
|
||||
private(set) var mainWindow: UIWindow!
|
||||
private(set) var overlayWindow: UIWindow!
|
||||
private(set) var globalSearchWindow: UIWindow!
|
||||
private(set) var alternateWindow: UIWindow!
|
||||
|
||||
var windows: [UIWindow] {
|
||||
[mainWindow, overlayWindow, alternateWindow]
|
||||
[mainWindow, overlayWindow, globalSearchWindow, alternateWindow]
|
||||
}
|
||||
|
||||
// periphery:ignore - auto cancels when reassigned
|
||||
@@ -50,6 +51,11 @@ class WindowManager: WindowManagerProtocol {
|
||||
overlayWindow.backgroundColor = .clear
|
||||
overlayWindow.isHidden = false
|
||||
|
||||
globalSearchWindow = UIWindow(windowScene: windowScene)
|
||||
globalSearchWindow.tintColor = .compound.textActionPrimary
|
||||
globalSearchWindow.backgroundColor = .clear
|
||||
globalSearchWindow.isHidden = true
|
||||
|
||||
alternateWindow = UIWindow(windowScene: windowScene)
|
||||
alternateWindow.tintColor = .compound.textActionPrimary
|
||||
|
||||
@@ -60,6 +66,8 @@ class WindowManager: WindowManagerProtocol {
|
||||
mainWindow.isHidden = false
|
||||
overlayWindow.isHidden = false
|
||||
|
||||
mainWindow.makeKey()
|
||||
|
||||
switchTask = Task {
|
||||
// Delay hiding to make sure the main windows are visible.
|
||||
try await Task.sleep(for: windowHideDelay)
|
||||
@@ -76,6 +84,8 @@ class WindowManager: WindowManagerProtocol {
|
||||
// e.g. the keyboard being displayed on top of a call sheet.
|
||||
mainWindow.endEditing(true)
|
||||
|
||||
hideGlobalSearch()
|
||||
|
||||
// alternateWindow.isHidden = false cannot got inside the Task otherwise the timing
|
||||
// is poor when you lock the phone - you briefly see the main window for a few
|
||||
// frames after you've unlocked the phone and then the placeholder animates in.
|
||||
@@ -83,11 +93,30 @@ class WindowManager: WindowManagerProtocol {
|
||||
// Delay hiding to make sure the alternate window is visible.
|
||||
try await Task.sleep(for: windowHideDelay)
|
||||
|
||||
overlayWindow.isHidden = true
|
||||
mainWindow.isHidden = true
|
||||
overlayWindow.isHidden = true
|
||||
globalSearchWindow.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
func showGlobalSearch() {
|
||||
guard alternateWindow.isHidden else {
|
||||
return
|
||||
}
|
||||
|
||||
globalSearchWindow.isHidden = false
|
||||
globalSearchWindow.makeKey()
|
||||
}
|
||||
|
||||
func hideGlobalSearch() {
|
||||
guard alternateWindow.isHidden else {
|
||||
return
|
||||
}
|
||||
|
||||
globalSearchWindow.isHidden = true
|
||||
mainWindow.makeKey()
|
||||
}
|
||||
|
||||
func setOrientation(_ orientation: UIInterfaceOrientationMask) {
|
||||
windowScene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientation))
|
||||
}
|
||||
@@ -103,7 +132,15 @@ private class PassthroughWindow: UIWindow {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let rootViewController else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard hitView != self else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the returned view is the `UIHostingController`'s view, ignore.
|
||||
return rootViewController?.view == hitView ? nil : hitView
|
||||
return rootViewController.view == hitView ? nil : hitView
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol {
|
||||
var mainWindow: UIWindow! { get }
|
||||
/// Presented on top of the main window, to display e.g. user indicators.
|
||||
var overlayWindow: UIWindow! { get }
|
||||
/// A window layered on top of the main one. Used by the global search function
|
||||
var globalSearchWindow: UIWindow! { get }
|
||||
/// A secondary window that can be presented instead of the main/overlay window combo.
|
||||
var alternateWindow: UIWindow! { get }
|
||||
|
||||
@@ -46,4 +48,9 @@ protocol WindowManagerProtocol: AnyObject, OrientationManagerProtocol {
|
||||
|
||||
/// Shows the alternate window, hiding the main and overlay combo.
|
||||
func switchToAlternate()
|
||||
|
||||
/// Makes the global search window key. Used to get automatic text field focus.
|
||||
func showGlobalSearch()
|
||||
|
||||
func hideGlobalSearch()
|
||||
}
|
||||
|
||||
@@ -978,7 +978,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
|
||||
private func presentMessageForwarding(for itemID: TimelineItemIdentifier) {
|
||||
guard let roomProxy, let roomSummaryProvider = userSession.clientProxy.messageForwardingRoomSummaryProvider, let eventID = itemID.eventID else {
|
||||
guard let roomProxy, let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider, let eventID = itemID.eventID else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
@@ -1002,7 +1002,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
await self.forward(eventID: eventID, toRoomID: roomID)
|
||||
}
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
stackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ enum UserSessionFlowCoordinatorAction {
|
||||
}
|
||||
|
||||
class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let windowManager: WindowManagerProtocol
|
||||
private let userSession: UserSessionProtocol
|
||||
private let navigationSplitCoordinator: NavigationSplitCoordinator
|
||||
private let windowManager: WindowManagerProtocol
|
||||
private let bugReportService: BugReportServiceProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let actionsSubject: PassthroughSubject<UserSessionFlowCoordinatorAction, Never> = .init()
|
||||
@@ -41,6 +41,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
// periphery:ignore - retaining purpose
|
||||
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
|
||||
|
||||
// periphery:ignore - retaining purpose
|
||||
private var globalSearchScreenCoordinator: GlobalSearchScreenCoordinator?
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
|
||||
@@ -63,9 +66,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine = UserSessionFlowCoordinatorStateMachine()
|
||||
self.userSession = userSession
|
||||
self.navigationSplitCoordinator = navigationSplitCoordinator
|
||||
self.windowManager = windowManager
|
||||
self.bugReportService = bugReportService
|
||||
self.appSettings = appSettings
|
||||
self.windowManager = windowManager
|
||||
|
||||
sidebarNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
|
||||
detailNavigationStackCoordinator = NavigationStackCoordinator(navigationSplitCoordinator: navigationSplitCoordinator)
|
||||
@@ -331,6 +334,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
Task { await self.runLogoutFlow() }
|
||||
case .presentInvitesScreen:
|
||||
stateMachine.processEvent(.showInvitesScreen)
|
||||
case .presentGlobalSearch:
|
||||
presentGlobalSearch()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
@@ -528,4 +533,44 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(coordinator, animated: true)
|
||||
}
|
||||
|
||||
// MARK: Global search
|
||||
|
||||
private func presentGlobalSearch() {
|
||||
guard let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider else {
|
||||
fatalError("Global search room summary provider unavailable")
|
||||
}
|
||||
|
||||
let coordinator = GlobalSearchScreenCoordinator(parameters: .init(roomSummaryProvider: roomSummaryProvider,
|
||||
mediaProvider: userSession.mediaProvider))
|
||||
|
||||
globalSearchScreenCoordinator = coordinator
|
||||
|
||||
coordinator.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .dismiss:
|
||||
dismissGlobalSearch()
|
||||
case .select(let roomID):
|
||||
dismissGlobalSearch()
|
||||
handleAppRoute(.room(roomID: roomID), animated: true)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let hostingController = UIHostingController(rootView: coordinator.toPresentable())
|
||||
hostingController.view.backgroundColor = .clear
|
||||
windowManager.globalSearchWindow.rootViewController = hostingController
|
||||
|
||||
windowManager.showGlobalSearch()
|
||||
}
|
||||
|
||||
private func dismissGlobalSearch() {
|
||||
windowManager.globalSearchWindow.rootViewController = nil
|
||||
windowManager.hideGlobalSearch()
|
||||
|
||||
globalSearchScreenCoordinator = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ enum RoomAvatarSizeOnScreen {
|
||||
case timeline
|
||||
case home
|
||||
case messageForwarding
|
||||
case globalSearch
|
||||
case details
|
||||
case notificationSettings
|
||||
|
||||
@@ -97,6 +98,8 @@ enum RoomAvatarSizeOnScreen {
|
||||
return 32
|
||||
case .messageForwarding:
|
||||
return 36
|
||||
case .globalSearch:
|
||||
return 36
|
||||
case .home:
|
||||
return 52
|
||||
case .details:
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct GlobalSearchScreenCoordinatorParameters {
|
||||
let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
}
|
||||
|
||||
enum GlobalSearchControllerAction {
|
||||
case dismiss
|
||||
case select(roomID: String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class GlobalSearchScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: GlobalSearchScreenCoordinatorParameters
|
||||
private let viewModel: GlobalSearchScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<GlobalSearchControllerAction, Never> = .init()
|
||||
var actions: AnyPublisher<GlobalSearchControllerAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: GlobalSearchScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: parameters.roomSummaryProvider,
|
||||
imageProvider: parameters.mediaProvider)
|
||||
|
||||
viewModel.actions
|
||||
.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
|
||||
switch action {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .select(let roomID):
|
||||
actionsSubject.send(.select(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(GlobalSearchScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum GlobalSearchScreenViewModelAction {
|
||||
case dismiss
|
||||
case select(roomID: String)
|
||||
}
|
||||
|
||||
struct GlobalSearchScreenViewState: BindableState {
|
||||
var rooms = [GlobalSearchRoom]()
|
||||
var bindings: GlobalSearchScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct GlobalSearchScreenViewStateBindings {
|
||||
var searchQuery: String
|
||||
}
|
||||
|
||||
enum GlobalSearchScreenViewAction {
|
||||
case dismiss
|
||||
case select(roomID: String)
|
||||
case reachedTop
|
||||
case reachedBottom
|
||||
}
|
||||
|
||||
struct GlobalSearchRoom: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let alias: String?
|
||||
let avatarURL: URL?
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias GlobalSearchScreenViewModelType = StateStoreViewModel<GlobalSearchScreenViewState, GlobalSearchScreenViewAction>
|
||||
|
||||
class GlobalSearchScreenViewModel: GlobalSearchScreenViewModelType, GlobalSearchScreenViewModelProtocol {
|
||||
private let roomSummaryProvider: RoomSummaryProviderProtocol
|
||||
|
||||
private var actionsSubject: PassthroughSubject<GlobalSearchScreenViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<GlobalSearchScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(roomSummaryProvider: RoomSummaryProviderProtocol,
|
||||
imageProvider: ImageProviderProtocol) {
|
||||
self.roomSummaryProvider = roomSummaryProvider
|
||||
|
||||
super.init(initialViewState: GlobalSearchScreenViewState(bindings: .init(searchQuery: "")),
|
||||
imageProvider: imageProvider)
|
||||
|
||||
roomSummaryProvider.roomListPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] summaries in
|
||||
self?.updateRooms(with: summaries)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
context.$viewState
|
||||
.map(\.bindings.searchQuery)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] searchQuery in
|
||||
self?.roomSummaryProvider.setFilter(.normalizedMatchRoomName(searchQuery))
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
updateRooms(with: roomSummaryProvider.roomListPublisher.value)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: GlobalSearchScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
roomSummaryProvider.setFilter(.all) // This is a shared provider
|
||||
case .select(let roomID):
|
||||
actionsSubject.send(.select(roomID: roomID))
|
||||
case .reachedTop:
|
||||
updateVisibleRange(edge: .top)
|
||||
case .reachedBottom:
|
||||
updateVisibleRange(edge: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func updateRooms(with summaries: [RoomSummary]) {
|
||||
state.rooms = summaries.compactMap { summary in
|
||||
switch summary {
|
||||
case .empty:
|
||||
return nil
|
||||
case .invalidated(let details), .filled(let details):
|
||||
return GlobalSearchRoom(id: details.id,
|
||||
name: details.name,
|
||||
alias: details.canonicalAlias,
|
||||
avatarURL: details.avatarURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol GlobalSearchScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<GlobalSearchScreenViewModelAction, Never> { get }
|
||||
var context: GlobalSearchScreenViewModelType.Context { get }
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct GlobalSearchScreen: View {
|
||||
@ObservedObject var context: GlobalSearchScreenViewModel.Context
|
||||
|
||||
@State private var selectedRoom: GlobalSearchRoom?
|
||||
@FocusState private var searchFieldFocus
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
header
|
||||
|
||||
Section {
|
||||
ForEach(context.viewState.rooms) { room in
|
||||
GlobalSearchScreenListRow(room: room, context: context)
|
||||
.listRowBackground(backgroundColor(for: room))
|
||||
.listRowInsets(.init())
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .select(roomID: room.id))
|
||||
}
|
||||
.onAppear {
|
||||
if room == context.viewState.rooms.first {
|
||||
context.send(viewAction: .reachedTop)
|
||||
} else if room == context.viewState.rooms.last {
|
||||
context.send(viewAction: .reachedBottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.frame(maxWidth: 700, maxHeight: 800)
|
||||
.background(.compound.bgCanvasDefault)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.black.opacity(0.5).ignoresSafeArea())
|
||||
.background { keyboardShortcuts }
|
||||
.onAppear {
|
||||
selectedRoom = context.viewState.rooms.first
|
||||
searchFieldFocus = true
|
||||
}
|
||||
.onChange(of: context.viewState.rooms) { _ in
|
||||
selectedRoom = context.viewState.rooms.first
|
||||
}
|
||||
.onTapGesture {
|
||||
context.send(viewAction: .dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
GlobalSearchTextFieldRepresentable(placeholder: L10n.actionSearch, text: $context.searchQuery) { keyCode in
|
||||
switch keyCode {
|
||||
case .keyboardUpArrow:
|
||||
moveToNextEntry(backwards: true)
|
||||
return true
|
||||
case .keyboardDownArrow:
|
||||
moveToNextEntry()
|
||||
return true
|
||||
case .keyboardReturnOrEnter, .keyboardReturn:
|
||||
if let selectedRoom {
|
||||
context.send(viewAction: .select(roomID: selectedRoom.id))
|
||||
}
|
||||
return true
|
||||
case .keyboardEscape:
|
||||
context.send(viewAction: .dismiss)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
} endEditingHandler: {
|
||||
if let selectedRoom {
|
||||
context.send(viewAction: .select(roomID: selectedRoom.id))
|
||||
} else { // Bring the focus back to the text field
|
||||
searchFieldFocus = true
|
||||
}
|
||||
}
|
||||
.focused($searchFieldFocus)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
private var keyboardShortcuts: some View {
|
||||
Group {
|
||||
Button("") {
|
||||
context.send(viewAction: .dismiss)
|
||||
}
|
||||
// Need this to enable escape on the textfield and forward the presses
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func backgroundColor(for room: GlobalSearchRoom) -> Color {
|
||||
if selectedRoom == room {
|
||||
.compound.bgSubtlePrimary
|
||||
} else {
|
||||
.compound.bgCanvasDefault
|
||||
}
|
||||
}
|
||||
|
||||
private func moveToNextEntry(backwards: Bool = false) {
|
||||
guard let selectedRoom else {
|
||||
selectedRoom = context.viewState.rooms.first
|
||||
return
|
||||
}
|
||||
|
||||
guard let currentIndex = context.viewState.rooms.firstIndex(of: selectedRoom) else {
|
||||
return
|
||||
}
|
||||
|
||||
let nextIndex = (backwards ? currentIndex - 1 : currentIndex + 1)
|
||||
|
||||
guard context.viewState.rooms.indices.contains(nextIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.selectedRoom = context.viewState.rooms[nextIndex]
|
||||
}
|
||||
}
|
||||
|
||||
private struct GlobalSearchTextFieldRepresentable: UIViewRepresentable {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
let keyPressHandler: (UIKeyboardHIDUsage) -> Bool
|
||||
let endEditingHandler: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UITextField {
|
||||
let textField = GlobalSearchTextField(keyPressHandler: keyPressHandler)
|
||||
textField.delegate = context.coordinator
|
||||
textField.autocorrectionType = .no
|
||||
textField.placeholder = placeholder
|
||||
return textField
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextField, context: Context) {
|
||||
uiView.text = text
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text, endEditingHandler: endEditingHandler)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate {
|
||||
var text: Binding<String>
|
||||
let endEditingHandler: () -> Void
|
||||
|
||||
init(text: Binding<String>, endEditingHandler: @escaping () -> Void) {
|
||||
self.text = text
|
||||
self.endEditingHandler = endEditingHandler
|
||||
}
|
||||
|
||||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||||
// pressesBegan sometimes doesn't receive return events. Handle it here instead
|
||||
if string.rangeOfCharacter(from: .newlines) != nil {
|
||||
endEditingHandler()
|
||||
return false
|
||||
}
|
||||
|
||||
let currentText = textField.text ?? ""
|
||||
DispatchQueue.main.async {
|
||||
self.text.wrappedValue = (currentText as NSString).replacingCharacters(in: range, with: string)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class GlobalSearchTextField: UITextField {
|
||||
let keyPressHandler: (UIKeyboardHIDUsage) -> Bool
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
init(keyPressHandler: @escaping (UIKeyboardHIDUsage) -> Bool) {
|
||||
self.keyPressHandler = keyPressHandler
|
||||
super.init(frame: .zero)
|
||||
}
|
||||
|
||||
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
||||
guard let key = presses.first?.key else { return }
|
||||
|
||||
if keyPressHandler(key.keyCode) {
|
||||
return
|
||||
}
|
||||
|
||||
super.pressesBegan(presses, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct GlobalSearchScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)),
|
||||
imageProvider: MockMediaProvider())
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
GlobalSearchScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct GlobalSearchScreenListRow: View {
|
||||
@Environment(\.dynamicTypeSize) var dynamicTypeSize
|
||||
|
||||
let room: GlobalSearchRoom
|
||||
let context: GlobalSearchScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
ZStack { // The list row swallows listRowBackgrounds for some reason
|
||||
ListRow(label: .avatar(title: room.name,
|
||||
description: room.alias ?? room.id,
|
||||
icon: avatar),
|
||||
kind: .label)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder @MainActor
|
||||
var avatar: some View {
|
||||
if dynamicTypeSize < .accessibility3 {
|
||||
LoadableAvatarImage(url: room.avatarURL,
|
||||
name: room.name,
|
||||
contentID: room.id,
|
||||
avatarSize: .room(on: .messageForwarding),
|
||||
imageProvider: context.imageProvider)
|
||||
.dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlobalSearchScreenListRow_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = GlobalSearchScreenViewModel(roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)),
|
||||
imageProvider: MockMediaProvider())
|
||||
|
||||
static var previews: some View {
|
||||
List {
|
||||
GlobalSearchScreenListRow(room: .init(id: "123",
|
||||
name: "Tech central",
|
||||
alias: "The best place in the whole wide world",
|
||||
avatarURL: .picturesDirectory),
|
||||
context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ enum HomeScreenCoordinatorAction {
|
||||
case presentSecureBackupSettings
|
||||
case presentStartChatScreen
|
||||
case presentInvitesScreen
|
||||
case presentGlobalSearch
|
||||
case logout
|
||||
}
|
||||
|
||||
@@ -82,6 +83,8 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentStartChatScreen)
|
||||
case .presentInvitesScreen:
|
||||
actionsSubject.send(.presentInvitesScreen)
|
||||
case .presentGlobalSearch:
|
||||
actionsSubject.send(.presentGlobalSearch)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -28,6 +28,7 @@ enum HomeScreenViewModelAction {
|
||||
case presentFeedbackScreen
|
||||
case presentStartChatScreen
|
||||
case presentInvitesScreen
|
||||
case presentGlobalSearch
|
||||
case logout
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ enum HomeScreenViewAction {
|
||||
case skipRecoveryKeyConfirmation
|
||||
case updateVisibleItemRange(range: Range<Int>, isScrolling: Bool)
|
||||
case selectInvites
|
||||
case globalSearch
|
||||
}
|
||||
|
||||
enum HomeScreenRoomListMode: CustomStringConvertible {
|
||||
|
||||
@@ -141,6 +141,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
actionsSubject.send(.presentStartChatScreen)
|
||||
case .selectInvites:
|
||||
actionsSubject.send(.presentInvitesScreen)
|
||||
case .globalSearch:
|
||||
actionsSubject.send(.presentGlobalSearch)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ struct HomeScreenContent: View {
|
||||
.onChange(of: context.viewState.visibleRooms) { _ in
|
||||
updateVisibleRange()
|
||||
}
|
||||
.background(
|
||||
Button("", action: {
|
||||
context.send(viewAction: .globalSearch)
|
||||
})
|
||||
.keyboardShortcut(KeyEquivalent("k"), modifiers: [.command])
|
||||
)
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.scrollDisabled(context.viewState.roomListMode == .skeletons)
|
||||
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
actionsSubject.send(.dismiss)
|
||||
roomSummaryProvider?.setFilter(.all)
|
||||
case .send:
|
||||
guard let roomID = state.selectedRoomID else {
|
||||
fatalError()
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ final class RoomNotificationSettingsScreenCoordinator: CoordinatorProtocol {
|
||||
case .dismiss:
|
||||
self?.parameters.navigationStackCoordinator?.pop(animated: true)
|
||||
}
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
|
||||
@@ -43,7 +43,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
// These following summary providers both operate on the same allRooms() list but
|
||||
// can apply their own filtering and pagination
|
||||
private(set) var roomSummaryProvider: RoomSummaryProviderProtocol?
|
||||
private(set) var messageForwardingRoomSummaryProvider: RoomSummaryProviderProtocol?
|
||||
private(set) var alternateRoomSummaryProvider: RoomSummaryProviderProtocol?
|
||||
|
||||
private(set) var inviteSummaryProvider: RoomSummaryProviderProtocol?
|
||||
|
||||
@@ -505,6 +505,7 @@ class ClientProxy: ClientProxyProtocol {
|
||||
mentionBuilder: PlainMentionBuilder()))
|
||||
let eventStringBuilder = RoomEventStringBuilder(stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID),
|
||||
messageEventStringBuilder: roomMessageEventStringBuilder)
|
||||
|
||||
roomSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
|
||||
eventStringBuilder: eventStringBuilder,
|
||||
name: "AllRooms",
|
||||
@@ -514,13 +515,13 @@ class ClientProxy: ClientProxyProtocol {
|
||||
appSettings: appSettings)
|
||||
try await roomSummaryProvider?.setRoomList(roomListService.allRooms())
|
||||
|
||||
messageForwardingRoomSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
|
||||
eventStringBuilder: eventStringBuilder,
|
||||
name: "MessageForwarding",
|
||||
notificationSettings: notificationSettings,
|
||||
backgroundTaskService: backgroundTaskService,
|
||||
appSettings: appSettings)
|
||||
try await messageForwardingRoomSummaryProvider?.setRoomList(roomListService.allRooms())
|
||||
alternateRoomSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
|
||||
eventStringBuilder: eventStringBuilder,
|
||||
name: "MessageForwarding",
|
||||
notificationSettings: notificationSettings,
|
||||
backgroundTaskService: backgroundTaskService,
|
||||
appSettings: appSettings)
|
||||
try await alternateRoomSummaryProvider?.setRoomList(roomListService.allRooms())
|
||||
|
||||
inviteSummaryProvider = RoomSummaryProvider(roomListService: roomListService,
|
||||
eventStringBuilder: eventStringBuilder,
|
||||
|
||||
@@ -89,7 +89,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
||||
|
||||
var roomSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
var messageForwardingRoomSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
/// Used for listing rooms that shouldn't be affected by the main `roomSummaryProvider` filtering
|
||||
var alternateRoomSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
var inviteSummaryProvider: RoomSummaryProviderProtocol? { get }
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class MockClientProxy: ClientProxyProtocol {
|
||||
|
||||
var roomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
var messageForwardingRoomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
var alternateRoomSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
var inviteSummaryProvider: RoomSummaryProviderProtocol? = MockRoomSummaryProvider()
|
||||
|
||||
|
||||
@@ -34,8 +34,14 @@ struct RoomSummaryDetails {
|
||||
}
|
||||
|
||||
extension RoomSummaryDetails: CustomStringConvertible {
|
||||
var description: String {
|
||||
"RoomSummaryDetails: - id: \(id) - isDirect: \(isDirect) - unreadMessagesCount: \(unreadMessagesCount) - unreadMentionsCount: \(unreadMentionsCount) - unreadNotificationsCount: \(unreadNotificationsCount) - notificationMode: \(notificationMode?.rawValue ?? "nil")"
|
||||
var description: String { """
|
||||
RoomSummaryDetails: - id: \(id) \
|
||||
- isDirect: \(isDirect) \
|
||||
- unreadMessagesCount: \(unreadMessagesCount) \
|
||||
- unreadMentionsCount: \(unreadMentionsCount) \
|
||||
- unreadNotificationsCount: \(unreadNotificationsCount) \
|
||||
- notificationMode: \(notificationMode?.rawValue ?? "nil")
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user