Fixes #2518, fixes #2590 - Implement public room search list paginati… (#2607)

* Fixes #2518, fixes #2590 - Implement public room search list pagination and room joining

* Address PR comments
This commit is contained in:
Stefan Ceriu 2024-03-27 10:50:53 +02:00 committed by GitHub
parent d72fa02ac6
commit be9cf8713e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 248 additions and 59 deletions

View File

@ -577,14 +577,19 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: Room Directory Search
private func presentRoomDirectorySearch() {
let coordinator = RoomDirectorySearchScreenCoordinator(parameters: .init(roomDirectorySearchProxy: userSession.clientProxy.roomDirectorySearchProxy(),
let coordinator = RoomDirectorySearchScreenCoordinator(parameters: .init(clientProxy: userSession.clientProxy,
imageProvider: userSession.mediaProvider,
userIndicatorController: ServiceLocator.shared.userIndicatorController))
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .joined(let roomID):
stateMachine.processEvent(.dismissedRoomDirectorySearchScreen)
handleAppRoute(.room(roomID: roomID), animated: true)
case .dismiss:
self?.stateMachine.processEvent(.dismissedRoomDirectorySearchScreen(joinedRoomID: nil))
stateMachine.processEvent(.dismissedRoomDirectorySearchScreen)
}
}
.store(in: &cancellables)

View File

@ -86,7 +86,8 @@ class UserSessionFlowCoordinatorStateMachine {
case dismissedLogoutConfirmationScreen
case showRoomDirectorySearchScreen
case dismissedRoomDirectorySearchScreen(joinedRoomID: String?)
case dismissedRoomDirectorySearchScreen
}
private let stateMachine: StateMachine<State, Event>
@ -144,10 +145,7 @@ class UserSessionFlowCoordinatorStateMachine {
case (.roomList(let selectedRoomID), .showRoomDirectorySearchScreen):
return .roomDirectorySearchScreen(selectedRoomID: selectedRoomID)
case (.roomDirectorySearchScreen(let selectedRoomID), .dismissedRoomDirectorySearchScreen(let joinedRoomID)):
if let joinedRoomID {
return .roomList(selectedRoomID: joinedRoomID)
}
case (.roomDirectorySearchScreen(let selectedRoomID), .dismissedRoomDirectorySearchScreen):
return .roomList(selectedRoomID: selectedRoomID)
default:

View File

@ -21,6 +21,7 @@ struct ClientProxyMockConfiguration {
var userID: String = RoomMemberProxyMock.mockMe.userID
var deviceID: String?
var roomSummaryProvider: RoomSummaryProviderProtocol? = RoomSummaryProviderMock(.init())
var roomDirectorySearchProxy: RoomDirectorySearchProxyProtocol?
}
extension ClientProxyMock {
@ -36,6 +37,8 @@ extension ClientProxyMock {
alternateRoomSummaryProvider = RoomSummaryProviderMock(.init())
inviteSummaryProvider = RoomSummaryProviderMock(.init())
roomDirectorySearchProxyReturnValue = configuration.roomDirectorySearchProxy
actionsPublisher = PassthroughSubject<ClientProxyAction, Never>().eraseToAnyPublisher()
loadingStatePublisher = CurrentValuePublisher<ClientProxyLoadingState, Never>(.notLoading)
verificationStatePublisher = CurrentValuePublisher<SessionVerificationState, Never>(.unknown)

View File

@ -898,6 +898,27 @@ class ClientProxyMock: ClientProxyProtocol {
return createRoomNameTopicIsRoomPrivateUserIDsAvatarURLReturnValue
}
}
//MARK: - joinRoom
var joinRoomCallsCount = 0
var joinRoomCalled: Bool {
return joinRoomCallsCount > 0
}
var joinRoomReceivedRoomID: String?
var joinRoomReceivedInvocations: [String] = []
var joinRoomReturnValue: Result<Void, ClientProxyError>!
var joinRoomClosure: ((String) async -> Result<Void, ClientProxyError>)?
func joinRoom(_ roomID: String) async -> Result<Void, ClientProxyError> {
joinRoomCallsCount += 1
joinRoomReceivedRoomID = roomID
joinRoomReceivedInvocations.append(roomID)
if let joinRoomClosure = joinRoomClosure {
return await joinRoomClosure(roomID)
} else {
return joinRoomReturnValue
}
}
//MARK: - uploadMedia
var uploadMediaCallsCount = 0

View File

@ -18,12 +18,13 @@ import Combine
import SwiftUI
struct RoomDirectorySearchScreenCoordinatorParameters {
let roomDirectorySearchProxy: RoomDirectorySearchProxyProtocol
let clientProxy: ClientProxyProtocol
let imageProvider: ImageProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum RoomDirectorySearchScreenCoordinatorAction {
case joined(roomID: String)
case dismiss
}
@ -31,22 +32,26 @@ final class RoomDirectorySearchScreenCoordinator: CoordinatorProtocol {
private let viewModel: RoomDirectorySearchScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<RoomDirectorySearchScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<RoomDirectorySearchScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: RoomDirectorySearchScreenCoordinatorParameters) {
viewModel = RoomDirectorySearchScreenViewModel(roomDirectorySearch: parameters.roomDirectorySearchProxy, userIndicatorController: parameters.userIndicatorController, imageProvider: parameters.imageProvider)
viewModel = RoomDirectorySearchScreenViewModel(clientProxy: parameters.clientProxy,
userIndicatorController: parameters.userIndicatorController,
imageProvider: parameters.imageProvider)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .joined(let roomID):
actionsSubject.send(.joined(roomID: roomID))
case .dismiss:
self.actionsSubject.send(.dismiss)
actionsSubject.send(.dismiss)
}
}
.store(in: &cancellables)

View File

@ -17,11 +17,12 @@
import Foundation
enum RoomDirectorySearchScreenViewModelAction {
case joined(roomID: String)
case dismiss
}
struct RoomDirectorySearchScreenViewState: BindableState {
var searchResults: [RoomDirectorySearchResult] = []
var rooms: [RoomDirectorySearchResult] = []
var isLoading = false
var bindings = RoomDirectorySearchScreenViewStateBindings()
@ -35,4 +36,5 @@ struct RoomDirectorySearchScreenViewStateBindings {
enum RoomDirectorySearchScreenViewAction {
case dismiss
case join(roomID: String)
case reachedBottom
}

View File

@ -20,24 +20,29 @@ import SwiftUI
typealias RoomDirectorySearchScreenViewModelType = StateStoreViewModel<RoomDirectorySearchScreenViewState, RoomDirectorySearchScreenViewAction>
class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType, RoomDirectorySearchScreenViewModelProtocol {
private let roomDirectorySearch: RoomDirectorySearchProxyProtocol
private let clientProxy: ClientProxyProtocol
private let roomDirectorySearchProxy: RoomDirectorySearchProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<RoomDirectorySearchScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<RoomDirectorySearchScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomDirectorySearch: RoomDirectorySearchProxyProtocol,
init(clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
imageProvider: ImageProviderProtocol) {
self.roomDirectorySearch = roomDirectorySearch
self.clientProxy = clientProxy
roomDirectorySearchProxy = clientProxy.roomDirectorySearchProxy()
self.userIndicatorController = userIndicatorController
super.init(initialViewState: RoomDirectorySearchScreenViewState(), imageProvider: imageProvider)
roomDirectorySearch.resultsPublisher
state.rooms = roomDirectorySearchProxy.resultsPublisher.value
roomDirectorySearchProxy.resultsPublisher
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.searchResults, on: self)
.weakAssign(to: \.state.rooms, on: self)
.store(in: &cancellables)
context.$viewState.map(\.bindings.searchString)
@ -62,10 +67,30 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
actionsSubject.send(.dismiss)
case .join(roomID: let roomID):
joinRoom(roomID: roomID)
case .reachedBottom:
loadNextPage()
}
}
private func joinRoom(roomID: String) { }
// MARK: - Private
private func joinRoom(roomID: String) {
showLoadingIndicator()
Task {
defer {
hideLoadingIndicator()
}
switch await clientProxy.joinRoom(roomID) {
case .success:
actionsSubject.send(.joined(roomID: roomID))
case .failure(let error):
MXLog.error("Failed joining room with error: \(error)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
private static let errorID = "roomDirectorySearchViewModelLoadingError"
@ -74,9 +99,10 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
return
}
state.rooms = []
state.isLoading = true
Task {
switch await roomDirectorySearch.search(query: query) {
switch await roomDirectorySearchProxy.search(query: query) {
case .success:
break
case .failure:
@ -88,4 +114,25 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
state.isLoading = false
}
}
private func loadNextPage() {
Task {
state.isLoading = true
let _ = await roomDirectorySearchProxy.nextPage()
state.isLoading = false
}
}
private static let loadingIndicatorIdentifier = "\(RoomDirectorySearchScreenViewModel.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}

View File

@ -20,6 +20,7 @@ import SwiftUI
struct RoomDirectorySearchCell: View {
let result: RoomDirectorySearchResult
let imageProvider: ImageProviderProtocol?
let joinAction: () -> Void
private var description: String? {
if let topic = result.topic {
@ -36,9 +37,17 @@ struct RoomDirectorySearchCell: View {
}
var body: some View {
ListRow(label: .avatar(title: result.name ?? result.alias ?? result.id,
description: description,
icon: avatar), kind: .label)
if result.canBeJoined {
ListRow(label: .avatar(title: result.name ?? result.alias ?? result.id,
description: description,
icon: avatar),
details: .label(title: L10n.actionJoin, icon: EmptyView()),
kind: .navigationLink(action: joinAction))
} else {
ListRow(label: .avatar(title: result.name ?? result.alias ?? result.id,
description: description,
icon: avatar), kind: .label)
}
}
private var avatar: some View {
@ -56,14 +65,69 @@ struct RoomDirectorySearchCell: View {
struct RoomDirectorySearchCell_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
List {
RoomDirectorySearchCell(result: .init(id: "!test_id_1:matrix.org", alias: "#test:example.com", name: "Test title", topic: "test description", avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_2:matrix.org", alias: "#test:example.com", name: nil, topic: "test description", avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_3:example.com", alias: "#test_no_topic:example.com", name: "Test title no topic", topic: nil, avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_4:example.com", alias: "#test_no_topic:example.com", name: nil, topic: nil, avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_5:example.com", alias: nil, name: "Test title no alias", topic: nil, avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_6:example.com", alias: nil, name: "Test title no alias", topic: "Topic", avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_7:example.com", alias: nil, name: nil, topic: "Topic", avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_8:example.com", alias: nil, name: nil, topic: nil, avatarURL: nil, canBeJoined: false), imageProvider: MockMediaProvider())
RoomDirectorySearchCell(result: .init(id: "!test_id_1:matrix.org",
alias: "#test:example.com",
name: "Test title",
topic: "test description",
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_2:matrix.org",
alias: "#test:example.com",
name: nil,
topic: "test description",
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_3:example.com",
alias: "#test_no_topic:example.com",
name: "Test title no topic",
topic: nil,
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_4:example.com",
alias: "#test_no_topic:example.com",
name: nil,
topic: nil,
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_5:example.com",
alias: nil,
name: "Test title no alias",
topic: nil,
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_6:example.com",
alias: nil,
name: "Test title no alias",
topic: "Topic",
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_7:example.com",
alias: nil,
name: nil,
topic: "Topic",
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
RoomDirectorySearchCell(result: .init(id: "!test_id_8:example.com",
alias: nil,
name: nil,
topic: nil,
avatarURL: nil,
canBeJoined: false),
imageProvider: MockMediaProvider()) { }
}
}
}

View File

@ -24,8 +24,22 @@ struct RoomDirectorySearchScreen: View {
NavigationStack {
List {
Section {
ForEach(context.viewState.searchResults) {
RoomDirectorySearchCell(result: $0, imageProvider: context.imageProvider)
ForEach(context.viewState.rooms) { room in
RoomDirectorySearchCell(result: room, imageProvider: context.imageProvider) {
context.send(viewAction: .join(roomID: room.id))
}
}
} footer: {
VStack(spacing: 0) {
if context.viewState.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
}
emptyRectangle
.onAppear {
context.send(viewAction: .reachedBottom)
}
}
}
}
@ -46,25 +60,39 @@ struct RoomDirectorySearchScreen: View {
}
}
}
// The greedy size of Rectangle can create an issue with the navigation bar when the search is highlighted, so is best to use a fixed frame instead of hidden() or EmptyView()
private var emptyRectangle: some View {
Rectangle()
.frame(width: 0, height: 0)
}
}
// MARK: - Previews
struct RoomDirectorySearchScreenScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomDirectorySearchScreenViewModel(roomDirectorySearch: RoomDirectorySearchProxyMock(configuration: .init(results: [.init(id: "test_1",
alias: "#test_1:example.com",
name: "Test 1",
topic: "Test description 1",
avatarURL: nil,
canBeJoined: true),
.init(id: "test_2",
alias: "#test_2:example.com",
name: "Test 2",
topic: "Test description 2",
avatarURL: URL.documentsDirectory,
canBeJoined: false)])),
userIndicatorController: UserIndicatorControllerMock(),
imageProvider: MockMediaProvider())
static let viewModel: RoomDirectorySearchScreenViewModel = {
let results = [RoomDirectorySearchResult(id: "test_1",
alias: "#test_1:example.com",
name: "Test 1",
topic: "Test description 1",
avatarURL: nil,
canBeJoined: true),
RoomDirectorySearchResult(id: "test_2",
alias: "#test_2:example.com",
name: "Test 2",
topic: nil,
avatarURL: URL.documentsDirectory,
canBeJoined: false)]
let roomDirectorySearchProxy = RoomDirectorySearchProxyMock(configuration: .init(results: results))
let clientProxy = ClientProxyMock(.init(roomDirectorySearchProxy: roomDirectorySearchProxy))
return RoomDirectorySearchScreenViewModel(clientProxy: clientProxy,
userIndicatorController: UserIndicatorControllerMock(),
imageProvider: MockMediaProvider())
}()
static var previews: some View {
RoomDirectorySearchScreen(context: viewModel.context)

View File

@ -342,6 +342,19 @@ class ClientProxy: ClientProxyProtocol {
return await waitForRoomSummary(with: result, name: name)
}
func joinRoom(_ roomID: String) async -> Result<Void, ClientProxyError> {
do {
let _ = try await client.joinRoomById(roomId: roomID)
// Wait for the room to appear in the room lists to avoid issues downstream
let _ = await waitForRoomSummary(with: .success(roomID), name: nil, timeout: 30)
return .success(())
} catch {
return .failure(.failedJoiningRoom)
}
}
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError> {
guard let mimeType = media.mimeType else { return .failure(ClientProxyError.mediaFileError) }
do {
@ -356,8 +369,7 @@ class ClientProxy: ClientProxyProtocol {
}
/// Await the room to be available in the room summary list
/// - Parameter result: the result of a room creation Task with the `roomID`.
private func waitForRoomSummary(with result: Result<String, ClientProxyError>, name: String?) async -> Result<String, ClientProxyError> {
private func waitForRoomSummary(with result: Result<String, ClientProxyError>, name: String?, timeout: Int = 10) async -> Result<String, ClientProxyError> {
guard case .success(let roomID) = result else { return result }
let runner = ExpiringTaskRunner { [weak self] in
guard let roomLists = self?.roomSummaryProvider?.roomListPublisher.values else {
@ -373,8 +385,8 @@ class ClientProxy: ClientProxyProtocol {
}
}
// we want to ignore the timeout error, and return the .success case because the room it was properly created already, we are only waiting for it to appear
try? await runner.run(timeout: .seconds(10))
// we want to ignore the timeout error, and return the .success case because the room was properly created/joined already, we are only waiting for it to appear
try? await runner.run(timeout: .seconds(timeout))
return result
}

View File

@ -53,6 +53,7 @@ enum ClientProxyError: Error {
case failedCheckingIsLastDevice(Error?)
case failedIgnoringUser
case failedUnignoringUser
case failedJoiningRoom
}
enum SlidingSyncConstants {
@ -126,6 +127,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError>
func joinRoom(_ roomID: String) async -> Result<Void, ClientProxyError>
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError>
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol?

1
changelog.d/2518.feature Normal file
View File

@ -0,0 +1 @@
Implement public room search list pagination and room joining