Membership aware room routes (#2709)

* Remove the invites button when room list invites are enabled

* element-hq/element-x-ios/issues/2632 - Handle room permalinks based on their membership state and show the according room joining screens:
- modify the room flow coordinator to only take a roomID and be able to decide its own room flows (especially for unjoined rooms)
- add support for the new room preview API
- have RoomDirectorySearch go through the same paths
This commit is contained in:
Stefan Ceriu
2024-04-19 17:04:18 +03:00
committed by GitHub
parent 9a172df7fd
commit effe3b6a9b
35 changed files with 698 additions and 247 deletions
@@ -40,7 +40,7 @@ enum RoomFlowCoordinatorAction: Equatable {
// swiftlint:disable file_length
class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let roomProxy: RoomProxyProtocol
private let roomID: String
private let userSession: UserSessionProtocol
private let isChildFlow: Bool
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
@@ -52,6 +52,10 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private let userIndicatorController: UserIndicatorControllerProtocol
private let orientationManager: OrientationManagerProtocol
private var roomProxy: RoomProxyProtocol!
private weak var joinRoomScreenCoordinator: JoinRoomScreenCoordinator?
// periphery:ignore - used to avoid deallocation
private var rolesAndPermissionsFlowCoordinator: RoomRolesAndPermissionsFlowCoordinator?
// periphery:ignore - used to avoid deallocation
@@ -68,7 +72,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
private var timelineController: RoomTimelineControllerProtocol?
init(roomProxy: RoomProxyProtocol,
init(roomID: String,
userSession: UserSessionProtocol,
isChildFlow: Bool,
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
@@ -79,7 +83,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
analytics: AnalyticsService,
userIndicatorController: UserIndicatorControllerProtocol,
orientationManager: OrientationManagerProtocol) async {
self.roomProxy = roomProxy
self.roomID = roomID
self.userSession = userSession
self.isChildFlow = isChildFlow
self.roomTimelineControllerFactory = roomTimelineControllerFactory
@@ -91,14 +95,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
self.userIndicatorController = userIndicatorController
self.orientationManager = orientationManager
// The SDK needs to handle multiple subscription calls before we can start adding child room flows.
let subscriptionTask = Task { await roomProxy.subscribeForUpdates() }
setupStateMachine()
analytics.signpost.beginRoomFlow(roomProxy.id)
_ = await subscriptionTask.result
analytics.signpost.beginRoomFlow(roomID)
}
// MARK: - FlowCoordinatorProtocol
@@ -110,21 +109,31 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute {
case .room(let roomID):
guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") }
stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated))
Task {
await handleRoomRoute(roomID: roomID, animated: animated)
}
case .childRoom(let roomID):
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
childRoomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
} else if roomID != roomProxy.id {
stateMachine.tryEvent(.presentChildRoom(roomID: roomID), userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.startChildFlow(roomID: roomID), userInfo: EventUserInfo(animated: animated))
} else {
MXLog.info("Ignoring presentation of the same room as a child of this one.")
}
case .roomDetails(let roomID):
guard roomID == roomProxy.id else { fatalError("Navigation route doesn't belong to this room flow.") }
stateMachine.tryEvent(.presentRoomDetails, userInfo: EventUserInfo(animated: animated))
case .roomList:
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
Task {
if roomProxy == nil {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
return
}
await storeAndSubscribeToRoomProxy(roomProxy)
}
stateMachine.tryEvent(.presentRoomDetails, userInfo: EventUserInfo(animated: animated))
}
case .roomMemberDetails(let userID):
// Always assume this will be presented on the child, external permalinks to a user aren't for a room member.
if case .presentingChild = stateMachine.state, let childRoomFlowCoordinator {
@@ -132,27 +141,68 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID), userInfo: EventUserInfo(animated: animated))
}
case .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
case .roomList, .userProfile, .genericCallLink, .oidcCallback, .settings, .chatBackupSettings:
break
}
}
private func handleRoomRoute(roomID: String, animated: Bool) async {
guard roomID == self.roomID else { fatalError("Navigation route doesn't belong to this room flow.") }
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
stateMachine.tryEvent(.presentJoinRoomScreen, userInfo: EventUserInfo(animated: animated))
return
}
switch roomProxy.membership {
case .joined:
await storeAndSubscribeToRoomProxy(roomProxy)
stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated))
default:
stateMachine.tryEvent(.presentJoinRoomScreen, userInfo: EventUserInfo(animated: animated))
}
}
func clearRoute(animated: Bool) {
guard stateMachine.state != .initial else {
return
}
stateMachine.tryEvent(.dismissRoom, userInfo: EventUserInfo(animated: animated))
stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated))
}
// MARK: - Private
private func storeAndSubscribeToRoomProxy(_ roomProxy: RoomProxyProtocol) async {
if let oldRoomProxy = self.roomProxy {
if oldRoomProxy.id != roomProxy.id {
fatalError("Trying to create different room proxies for the same flow coordinator")
}
return
}
guard roomProxy.membership == .joined else {
fatalError("Requesting room details for an unjoined room")
}
self.roomProxy = roomProxy
await roomProxy.subscribeForUpdates()
}
// swiftlint:disable:next function_body_length
private func setupStateMachine() {
stateMachine.addRouteMapping { event, fromState, _ in
switch (fromState, event) {
case (_, .presentJoinRoomScreen):
return .joinRoomScreen
case (_, .dismissJoinRoomScreen):
return .complete
case (_, .presentRoom):
return .room
case (_, .dismissRoom):
case (_, .dismissFlow):
return .complete
case (.initial, .presentRoomDetails):
@@ -255,9 +305,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// Child flow
case (_, .presentChildRoom(let roomID)):
case (_, .startChildFlow(let roomID)):
return .presentingChild(childRoomID: roomID, previousState: fromState)
case (.presentingChild(_, let previousState), .dismissChildRoom):
case (.presentingChild(_, let previousState), .dismissChildFlow):
return previousState
default:
@@ -271,9 +321,14 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
let animated = (context.userInfo as? EventUserInfo)?.animated ?? true
switch (context.fromState, context.event, context.toState) {
case (_, .presentJoinRoomScreen, .joinRoomScreen):
presentJoinRoomScreen(animated: true)
case (_, .dismissJoinRoomScreen, .complete):
dismissFlow(animated: animated)
case (_, .presentRoom, .room):
Task { await self.presentRoom(animated: animated) }
case (_, .dismissRoom, .complete):
case (_, .dismissFlow, .complete):
dismissFlow(animated: animated)
case (.initial, .presentRoomDetails, .roomDetails(let isRoot)),
@@ -381,9 +436,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
rolesAndPermissionsFlowCoordinator = nil
// Child flow
case (_, .presentChildRoom(let roomID), .presentingChild):
Task { await self.presentChildFlow(for: roomID) }
case (.presentingChild, .dismissChildRoom, _):
case (_, .startChildFlow(let roomID), .presentingChild):
Task { await self.startChildFlow(for: roomID) }
case (.presentingChild, .dismissChildFlow, _):
childRoomFlowCoordinator = nil
default:
@@ -479,27 +534,84 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
if !isChildFlow {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoom)
self?.stateMachine.tryEvent(.dismissFlow)
}
} else {
if joinRoomScreenCoordinator != nil {
navigationStackCoordinator.pop()
}
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissFlow)
}
}
}
private func presentJoinRoomScreen(animated: Bool) {
let coordinator = JoinRoomScreenCoordinator(parameters: .init(roomID: roomID,
clientProxy: userSession.clientProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController))
joinRoomScreenCoordinator = coordinator
coordinator.actionsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else { return }
switch action {
case .joined:
Task { [weak self] in
guard let self else { return }
if let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) {
await storeAndSubscribeToRoomProxy(roomProxy)
stateMachine.tryEvent(.presentRoom, userInfo: EventUserInfo(animated: animated))
analytics.trackJoinedRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace, activeMemberCount: UInt(roomProxy.activeMembersCount))
} else {
stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated))
}
}
case .cancelled:
stateMachine.tryEvent(.dismissJoinRoomScreen)
}
}
.store(in: &cancellables)
if !isChildFlow {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
if self?.stateMachine.state == .joinRoomScreen {
self?.stateMachine.tryEvent(.dismissJoinRoomScreen)
}
}
} else {
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoom)
if self?.stateMachine.state == .joinRoomScreen {
self?.stateMachine.tryEvent(.dismissJoinRoomScreen)
}
}
}
}
private func dismissFlow(animated: Bool) {
childRoomFlowCoordinator?.handleAppRoute(.roomList, animated: animated)
childRoomFlowCoordinator?.clearRoute(animated: animated)
if isChildFlow {
// We don't support dismissing a child flow by itself, only the entire chain.
MXLog.info("Leaving navigation clean-up to the parent flow.")
MXLog.info("Leaving the rest of the navigation clean-up to the parent flow.")
if joinRoomScreenCoordinator != nil {
navigationStackCoordinator.pop()
}
} else {
navigationStackCoordinator.popToRoot(animated: false)
navigationStackCoordinator.setRootCoordinator(nil, animated: false)
}
roomProxy.unsubscribeFromUpdates()
roomProxy?.unsubscribeFromUpdates()
timelineController = nil
actionsSubject.send(.finished)
@@ -520,7 +632,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .leftRoom:
stateMachine.tryEvent(.dismissRoom)
stateMachine.tryEvent(.dismissFlow)
case .presentRoomMembersList:
stateMachine.tryEvent(.presentRoomMembersList)
case .presentRoomDetailsEditScreen:
@@ -539,7 +651,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
if isRoot {
navigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self] in
self?.stateMachine.tryEvent(.dismissRoom) // This seems wrong but it isn't, Maybe the event should be called dismissFlow?
self?.stateMachine.tryEvent(.dismissFlow)
}
} else {
navigationStackCoordinator.push(coordinator, animated: animated) { [weak self] in
@@ -890,7 +1002,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .openUserProfile:
stateMachine.tryEvent(.presentUserProfile(userID: userID))
case .openDirectChat(let roomID):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
stateMachine.tryEvent(.startChildFlow(roomID: roomID))
}
}
.store(in: &cancellables)
@@ -913,7 +1025,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .openDirectChat(let roomID):
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
stateMachine.tryEvent(.startChildFlow(roomID: roomID))
case .dismiss:
break // Not supported when pushed.
}
@@ -983,7 +1095,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// We don't need to worry about passing in the room proxy as timelines are
// cached. The local echo will be visible when fetching the room by its ID.
stateMachine.tryEvent(.presentChildRoom(roomID: roomID))
stateMachine.tryEvent(.startChildFlow(roomID: roomID))
}
private func presentNotificationSettingsScreen() {
@@ -1117,15 +1229,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Child Flow
private func presentChildFlow(for roomID: String) async {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Child flow requested for missing room.")
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
stateMachine.tryEvent(.dismissChildRoom)
return
}
let coordinator = await RoomFlowCoordinator(roomProxy: roomProxy,
private func startChildFlow(for roomID: String) async {
let coordinator = await RoomFlowCoordinator(roomID: roomID,
userSession: userSession,
isChildFlow: true,
roomTimelineControllerFactory: roomTimelineControllerFactory,
@@ -1145,7 +1250,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .finished:
stateMachine.tryEvent(.dismissChildRoom)
stateMachine.tryEvent(.dismissChildFlow)
}
}
.store(in: &cancellables)
@@ -1170,6 +1275,7 @@ private extension RoomFlowCoordinator {
indirect enum State: StateType {
case initial
case joinRoomScreen
case room
case roomDetails(isRoot: Bool)
case roomDetailsEditScreen
@@ -1201,8 +1307,11 @@ private extension RoomFlowCoordinator {
}
enum Event: EventType {
case presentJoinRoomScreen
case dismissJoinRoomScreen
case presentRoom
case dismissRoom
case dismissFlow
case presentReportContent(itemID: TimelineItemIdentifier, senderID: String)
case dismissReportContent
@@ -1256,8 +1365,8 @@ private extension RoomFlowCoordinator {
case dismissRolesAndPermissionsScreen
// Child room flow events
case presentChildRoom(roomID: String)
case dismissChildRoom
case startChildFlow(roomID: String)
case dismissChildFlow
}
}
@@ -183,12 +183,12 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
switch appRoute {
case .room(let roomID):
Task { await self.handleRoomRoute(roomID: roomID, animated: animated) }
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated))
case .childRoom(let roomID):
if let roomFlowCoordinator {
roomFlowCoordinator.handleAppRoute(appRoute, animated: animated)
} else {
Task { await self.handleRoomRoute(roomID: roomID, animated: animated) }
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated))
}
case .roomDetails(let roomID):
if stateMachine.state.selectedRoomID == roomID {
@@ -196,12 +196,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
} else {
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true), userInfo: .init(animated: animated))
}
case .roomList, .roomMemberDetails:
self.roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
case .roomList:
roomFlowCoordinator?.clearRoute(animated: animated)
case .roomMemberDetails:
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
case .userProfile(let userID):
stateMachine.processEvent(.showUserProfileScreen(userID: userID), userInfo: .init(animated: animated))
case .genericCallLink(let url):
self.navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
navigationSplitCoordinator.setSheetCoordinator(GenericCallLinkCoordinator(parameters: .init(url: url)), animated: animated)
case .oidcCallback:
break
case .settings, .chatBackupSettings:
@@ -210,23 +212,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func handleRoomRoute(roomID: String, animated: Bool) async {
switch await userSession.clientProxy.roomForIdentifier(roomID)?.membership {
case .invited:
if UIDevice.current.isPhone {
roomFlowCoordinator?.clearRoute(animated: animated)
}
stateMachine.processEvent(.showInvitesScreen, userInfo: .init(animated: animated))
case .joined:
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false), userInfo: .init(animated: animated))
case .left, .none:
// Do nothing but maybe we should ask design to have some kind of error state
break
}
}
func clearRoute(animated: Bool) {
roomFlowCoordinator?.handleAppRoute(.roomList, animated: animated)
roomFlowCoordinator?.clearRoute(animated: animated)
}
// MARK: - Private
@@ -262,14 +249,14 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
attemptStartingOnboarding()
case(.roomList, .selectRoom(let roomID, let showingRoomDetails), .roomList):
Task { await self.presentRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) }
Task { await self.startRoomFlow(roomID: roomID, showingRoomDetails: showingRoomDetails, animated: animated) }
case(.roomList, .deselectRoom, .roomList):
tearDownRoomFlow(animated: animated)
dismissRoomFlow(animated: animated)
case (.invitesScreen, .selectRoom, .invitesScreen):
break
case (.invitesScreen, .deselectRoom, .invitesScreen):
tearDownRoomFlow(animated: animated)
dismissRoomFlow(animated: animated)
case (.roomList, .showSettingsScreen, .settingsScreen):
break
@@ -350,9 +337,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .presentRoom(let roomID):
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: false))
handleAppRoute(.room(roomID: roomID), animated: true)
case .presentRoomDetails(let roomID):
stateMachine.processEvent(.selectRoom(roomID: roomID, showingRoomDetails: true))
handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .roomLeft(let roomID):
if case .roomList(selectedRoomID: let selectedRoomID) = stateMachine.state,
selectedRoomID == roomID {
@@ -430,13 +417,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
// MARK: Room Flow
private func presentRoomFlow(roomID: String, showingRoomDetails: Bool, animated: Bool) async {
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Invalid room ID: \(roomID)")
return
}
let coordinator = await RoomFlowCoordinator(roomProxy: roomProxy,
private func startRoomFlow(roomID: String, showingRoomDetails: Bool, animated: Bool) async {
let coordinator = await RoomFlowCoordinator(roomID: roomID,
userSession: userSession,
isChildFlow: false,
roomTimelineControllerFactory: roomTimelineControllerFactory,
@@ -486,7 +468,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func tearDownRoomFlow(animated: Bool) {
private func dismissRoomFlow(animated: Bool) {
// THIS MUST BE CALLED *AFTER* THE FLOW HAS TIDIED UP THE STACK OR IT CAN CAUSE A CRASH.
navigationSplitCoordinator.setDetailCoordinator(nil, animated: animated)
roomFlowCoordinator = nil
@@ -650,7 +632,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .joined(let roomID):
case .select(let roomID):
stateMachine.processEvent(.dismissedRoomDirectorySearchScreen)
handleAppRoute(.room(roomID: roomID), animated: true)
case .dismiss:
@@ -2553,6 +2553,74 @@ class ClientProxyMock: ClientProxyProtocol {
return roomForIdentifierReturnValue
}
}
//MARK: - roomPreviewForIdentifier
var roomPreviewForIdentifierUnderlyingCallsCount = 0
var roomPreviewForIdentifierCallsCount: Int {
get {
if Thread.isMainThread {
return roomPreviewForIdentifierUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = roomPreviewForIdentifierUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomPreviewForIdentifierUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
roomPreviewForIdentifierUnderlyingCallsCount = newValue
}
}
}
}
var roomPreviewForIdentifierCalled: Bool {
return roomPreviewForIdentifierCallsCount > 0
}
var roomPreviewForIdentifierReceivedIdentifier: String?
var roomPreviewForIdentifierReceivedInvocations: [String] = []
var roomPreviewForIdentifierUnderlyingReturnValue: Result<RoomPreviewDetails, ClientProxyError>!
var roomPreviewForIdentifierReturnValue: Result<RoomPreviewDetails, ClientProxyError>! {
get {
if Thread.isMainThread {
return roomPreviewForIdentifierUnderlyingReturnValue
} else {
var returnValue: Result<RoomPreviewDetails, ClientProxyError>? = nil
DispatchQueue.main.sync {
returnValue = roomPreviewForIdentifierUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomPreviewForIdentifierUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
roomPreviewForIdentifierUnderlyingReturnValue = newValue
}
}
}
}
var roomPreviewForIdentifierClosure: ((String) async -> Result<RoomPreviewDetails, ClientProxyError>)?
func roomPreviewForIdentifier(_ identifier: String) async -> Result<RoomPreviewDetails, ClientProxyError> {
roomPreviewForIdentifierCallsCount += 1
roomPreviewForIdentifierReceivedIdentifier = identifier
roomPreviewForIdentifierReceivedInvocations.append(identifier)
if let roomPreviewForIdentifierClosure = roomPreviewForIdentifierClosure {
return await roomPreviewForIdentifierClosure(identifier)
} else {
return roomPreviewForIdentifierReturnValue
}
}
//MARK: - loadUserDisplayName
var loadUserDisplayNameUnderlyingCallsCount = 0
@@ -113,7 +113,7 @@ struct HomeScreenContent: View {
HomeScreenRecoveryKeyConfirmationBanner(context: context)
}
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused {
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused, !ServiceLocator.shared.settings.roomListInvitesEnabled {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
@@ -40,12 +40,18 @@ struct HomeScreenInviteCell: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 16)
.padding(.trailing, 16)
.multilineTextAlignment(.leading)
.overlay(alignment: .bottom) {
separator
}
}
.padding(.top, 12)
.padding(.leading, 16)
.onTapGesture {
if let roomId = room.roomId {
context.send(viewAction: .selectRoom(roomIdentifier: roomId))
}
}
}
// MARK: - Private
@@ -19,10 +19,9 @@ import SwiftUI
struct JoinRoomScreenCoordinatorParameters {
let roomID: String
let roomName: String
let avatarURL: URL?
let interaction: JoinRoomScreenInteraction
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum JoinRoomScreenCoordinatorAction {
@@ -45,10 +44,9 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = JoinRoomScreenViewModel(roomID: parameters.roomID,
roomName: parameters.roomName,
avatarURL: parameters.avatarURL,
interaction: parameters.interaction,
clientProxy: parameters.clientProxy)
clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
@@ -65,6 +63,10 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol {
}
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(JoinRoomScreen(context: viewModel.context))
@@ -21,34 +21,44 @@ enum JoinRoomScreenViewModelAction {
case cancelled
}
enum JoinRoomScreenInteractionMode {
case unknown
case invited
case join
case knock
}
struct JoinRoomScreenViewState: BindableState {
// Maybe use room summary details or similar here??
let roomID: String
let roomName: String
let avatarURL: URL?
let interaction: JoinRoomScreenInteraction
var roomDetails: RoomPreviewDetails?
var isJoining = false
var mode: JoinRoomScreenInteractionMode {
guard let roomDetails else {
return .unknown
}
if roomDetails.isInvited {
return .invited
}
if roomDetails.isPublic {
return .join
}
// Knocking is not supported yet, treat it as .unknown
// if roomDetails.canKnock {
// return .knock
// }
return .unknown
}
var bindings = JoinRoomScreenViewStateBindings()
var title: String {
switch interaction {
case .knock:
L10n.screenJoinRoomTitleKnock
case .join, .invited:
L10n.screenJoinRoomTitleNoPreview
}
}
var subtitle: String {
switch interaction {
case .knock:
L10n.screenJoinRoomSubtitleKnock
case .join, .invited:
L10n.screenJoinRoomSubtitleNoPreview
}
roomDetails?.name ?? L10n.screenJoinRoomTitleNoPreview
}
}
@@ -57,13 +67,7 @@ struct JoinRoomScreenViewStateBindings {
}
enum JoinRoomScreenAlertType {
case joinFailed
}
enum JoinRoomScreenInteraction {
case knock
case join
case invited
case declineInvite
}
enum JoinRoomScreenViewAction {
@@ -20,20 +20,28 @@ import SwiftUI
typealias JoinRoomScreenViewModelType = StateStoreViewModel<JoinRoomScreenViewState, JoinRoomScreenViewAction>
class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewModelProtocol {
private let roomID: String
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<JoinRoomScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<JoinRoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomID: String, roomName: String, avatarURL: URL?, interaction: JoinRoomScreenInteraction, clientProxy: ClientProxyProtocol) {
init(roomID: String,
clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomID = roomID
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID,
roomName: roomName,
avatarURL: avatarURL,
interaction: interaction))
super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID), imageProvider: mediaProvider)
Task {
await loadRoomDetails()
}
}
// MARK: - Public
@@ -47,21 +55,93 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
case .join:
Task { await joinRoom() }
case .acceptInvite:
break
Task { await joinRoom() }
case .declineInvite:
break
showDeclineInviteConfirmationAlert()
}
}
func stop() {
hideLoadingIndicator()
}
// MARK: - Private
private func joinRoom() async {
state.isJoining = true
switch await clientProxy.joinRoom(state.roomID) {
case .success:
actionsSubject.send(.joined)
private func loadRoomDetails() async {
showLoadingIndicator()
defer {
hideLoadingIndicator()
}
switch await clientProxy.roomPreviewForIdentifier(roomID) {
case .success(let roomDetails):
state.roomDetails = roomDetails
case .failure:
state.bindings.alertInfo = .init(id: .joinFailed)
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
private func joinRoom() async {
showLoadingIndicator()
defer {
hideLoadingIndicator()
}
switch await clientProxy.joinRoom(roomID) {
case .success:
actionsSubject.send(.joined)
case .failure(let error):
MXLog.error("Failed joining room with error: \(error)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func showDeclineInviteConfirmationAlert() {
guard let roomDetails = state.roomDetails else {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
return
}
let roomName = roomDetails.name ?? roomID
state.bindings.alertInfo = .init(id: .declineInvite,
title: L10n.screenInvitesDeclineChatTitle,
message: L10n.screenInvitesDeclineChatMessage(roomName),
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite() } }))
}
private func declineInvite() async {
defer {
userIndicatorController.retractIndicatorWithId(roomID)
}
userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true))
guard let roomProxy = await clientProxy.roomForIdentifier(roomID) else {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
return
}
let result = await roomProxy.rejectInvitation()
if case .failure = result {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private static let loadingIndicatorIdentifier = "\(JoinRoomScreenViewModel.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true),
title: L10n.commonLoading,
persistent: true),
delay: .seconds(0.25))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -20,4 +20,6 @@ import Combine
protocol JoinRoomScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<JoinRoomScreenViewModelAction, Never> { get }
var context: JoinRoomScreenViewModelType.Context { get }
func stop()
}
@@ -28,16 +28,16 @@ struct JoinRoomScreen: View {
} bottomContent: {
buttons
}
.alert(item: $context.alertInfo)
.background()
.backgroundStyle(.compound.bgCanvasDefault)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
var mainContent: some View {
VStack(spacing: 16) {
LoadableAvatarImage(url: context.viewState.avatarURL,
name: context.viewState.roomName,
LoadableAvatarImage(url: context.viewState.roomDetails?.avatarURL,
name: context.viewState.title,
contentID: context.viewState.roomID,
avatarSize: .room(on: .joinRoom),
imageProvider: context.imageProvider)
@@ -49,10 +49,28 @@ struct JoinRoomScreen: View {
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(context.viewState.subtitle)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
if let alias = context.viewState.roomDetails?.canonicalAlias {
Text(alias)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
if let memberCount = context.viewState.roomDetails?.memberCount {
Label("\(memberCount)", icon: \.userProfile, iconSize: .small, relativeTo: .compound.bodySMSemibold)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textSecondary)
.padding(4)
.background(Capsule().fill(.compound.bgSubtleSecondary))
.labelStyle(.custom(spacing: 4))
}
if let topic = context.viewState.roomDetails?.topic {
Text(topic)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
.lineLimit(3)
}
}
Image(asset: Asset.Images.joinRoomGraphic)
@@ -62,7 +80,9 @@ struct JoinRoomScreen: View {
@ViewBuilder
var buttons: some View {
switch context.viewState.interaction {
switch context.viewState.mode {
case .unknown:
EmptyView()
case .knock:
Button(L10n.screenJoinRoomKnockAction) { context.send(viewAction: .knock) }
.buttonStyle(.compound(.primary))
@@ -79,52 +99,82 @@ struct JoinRoomScreen: View {
@ViewBuilder
var inviteButtons: some View {
Button(L10n.actionDecline) { context.send(viewAction: .acceptInvite) }
Button(L10n.actionDecline) { context.send(viewAction: .declineInvite) }
.buttonStyle(.compound(.secondary))
Button(L10n.actionAccept) { context.send(viewAction: .declineInvite) }
Button(L10n.actionAccept) { context.send(viewAction: .acceptInvite) }
.buttonStyle(.compound(.primary))
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .principal) {
RoomHeaderView(roomID: context.viewState.roomID,
roomName: context.viewState.roomName,
avatarURL: context.viewState.avatarURL,
imageProvider: context.imageProvider)
}
}
}
// MARK: - Previews
struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
static let knockViewModel = makeViewModel(interaction: .knock)
static let joinViewModel = makeViewModel(interaction: .join)
static let inviteViewModel = makeViewModel(interaction: .invited)
static let unknownViewModel = makeViewModel(mode: .unknown)
static let knockViewModel = makeViewModel(mode: .knock)
static let joinViewModel = makeViewModel(mode: .join)
static let inviteViewModel = makeViewModel(mode: .invited)
static var previews: some View {
NavigationStack {
JoinRoomScreen(context: knockViewModel.context)
JoinRoomScreen(context: unknownViewModel.context)
}
.previewDisplayName("Knock")
.previewDisplayName("Unknown")
.snapshot(delay: 0.25)
// NavigationStack {
// JoinRoomScreen(context: knockViewModel.context)
// }
// .previewDisplayName("Knock")
// .snapshot(delay: 0.25)
NavigationStack {
JoinRoomScreen(context: joinViewModel.context)
}
.previewDisplayName("Join")
.snapshot(delay: 0.25)
NavigationStack {
JoinRoomScreen(context: inviteViewModel.context)
}
.previewDisplayName("Invite")
.snapshot(delay: 0.25)
}
static func makeViewModel(interaction: JoinRoomScreenInteraction) -> JoinRoomScreenViewModel {
JoinRoomScreenViewModel(roomID: "6",
roomName: "Room Name",
avatarURL: nil,
interaction: interaction,
clientProxy: ClientProxyMock(.init()))
static func makeViewModel(mode: JoinRoomScreenInteractionMode) -> JoinRoomScreenViewModel {
let clientProxy = ClientProxyMock(.init())
// swiftlint:disable:next large_tuple
let membership: (isJoined: Bool, isInvited: Bool, isPublic: Bool, canKnock: Bool) = switch mode {
case .unknown:
(false, false, false, false)
case .invited:
(false, true, false, false)
case .join:
(false, false, true, false)
case .knock:
(false, false, false, true)
}
if mode == .unknown {
clientProxy.roomPreviewForIdentifierReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
} else {
clientProxy.roomPreviewForIdentifierReturnValue = .success(.init(roomID: "1",
name: "The Three-Body Problem - 三体",
canonicalAlias: "#3🌞problem:matrix.org",
// swiftlint:disable:next line_length
topic: "“Science and technology were the only keys to opening the door to the future, and people approached science with the faith and sincerity of elementary school students.”",
avatarURL: URL.homeDirectory,
memberCount: UInt(100),
isHistoryWorldReadable: false,
isJoined: membership.isJoined,
isInvited: membership.isInvited,
isPublic: membership.isPublic,
canKnock: membership.canKnock))
}
return JoinRoomScreenViewModel(roomID: "1",
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
@@ -24,7 +24,7 @@ struct RoomDirectorySearchScreenCoordinatorParameters {
}
enum RoomDirectorySearchScreenCoordinatorAction {
case joined(roomID: String)
case select(roomID: String)
case dismiss
}
@@ -48,8 +48,8 @@ final class RoomDirectorySearchScreenCoordinator: CoordinatorProtocol {
viewModel.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .joined(let roomID):
actionsSubject.send(.joined(roomID: roomID))
case .select(let roomID):
actionsSubject.send(.select(roomID: roomID))
case .dismiss:
actionsSubject.send(.dismiss)
}
@@ -17,7 +17,7 @@
import Foundation
enum RoomDirectorySearchScreenViewModelAction {
case joined(roomID: String)
case select(roomID: String)
case dismiss
}
@@ -35,6 +35,6 @@ struct RoomDirectorySearchScreenViewStateBindings {
enum RoomDirectorySearchScreenViewAction {
case dismiss
case join(roomID: String)
case select(roomID: String)
case reachedBottom
}
@@ -65,8 +65,8 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
switch viewAction {
case .dismiss:
actionsSubject.send(.dismiss)
case .join(roomID: let roomID):
joinRoom(roomID: roomID)
case .select(roomID: let roomID):
actionsSubject.send(.select(roomID: roomID))
case .reachedBottom:
loadNextPage()
}
@@ -74,24 +74,6 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
// 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"
private func search(query: String?) {
@@ -122,17 +104,4 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
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)
}
}
@@ -26,7 +26,7 @@ struct RoomDirectorySearchScreen: View {
Section {
ForEach(context.viewState.rooms) { room in
RoomDirectorySearchCell(result: room, imageProvider: context.imageProvider) {
context.send(viewAction: .join(roomID: room.id))
context.send(viewAction: .select(roomID: room.id))
}
}
} footer: {
@@ -456,6 +456,16 @@ class ClientProxy: ClientProxyProtocol {
room: room,
backgroundTaskService: backgroundTaskService)
}
func roomPreviewForIdentifier(_ identifier: String) async -> Result<RoomPreviewDetails, ClientProxyError> {
do {
let roomPreview = try await client.getRoomPreview(roomIdOrAlias: identifier)
return .success(.init(roomPreview))
} catch {
MXLog.error("Failed retrieving preview for room: \(identifier) with error: \(error)")
return .failure(.sdkError(error))
}
}
func loadUserDisplayName() async -> Result<Void, ClientProxyError> {
await Task.dispatch(on: clientQueue) {
@@ -930,3 +940,19 @@ private class IgnoredUsersListenerProxy: IgnoredUsersListener {
onUpdateClosure(ignoredUserIds)
}
}
private extension RoomPreviewDetails {
init(_ roomPreview: RoomPreview) {
self = RoomPreviewDetails(roomID: roomPreview.roomId,
name: roomPreview.name,
canonicalAlias: roomPreview.canonicalAlias,
topic: roomPreview.topic,
avatarURL: roomPreview.avatarUrl.flatMap(URL.init(string:)),
memberCount: UInt(roomPreview.numJoinedMembers),
isHistoryWorldReadable: roomPreview.isHistoryWorldReadable,
isJoined: roomPreview.isJoined,
isInvited: roomPreview.isInvited,
isPublic: roomPreview.isPublic,
canKnock: roomPreview.canKnock)
}
}
@@ -67,6 +67,20 @@ enum SessionVerificationState {
case unverified
}
struct RoomPreviewDetails {
let roomID: String
let name: String?
let canonicalAlias: String?
let topic: String?
let avatarURL: URL?
let memberCount: UInt
let isHistoryWorldReadable: Bool
let isJoined: Bool
let isInvited: Bool
let isPublic: Bool
let canKnock: Bool
}
// sourcery: AutoMockable
protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var actionsPublisher: AnyPublisher<ClientProxyAction, Never> { get }
@@ -123,6 +137,8 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol?
func roomPreviewForIdentifier(_ identifier: String) async -> Result<RoomPreviewDetails, ClientProxyError>
@discardableResult func loadUserDisplayName() async -> Result<Void, ClientProxyError>
func setUserDisplayName(_ name: String) async -> Result<Void, ClientProxyError>
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:47d269397c823468762d9c7fd7075a6f998b4180f4ef5c126d13623348ba02b2
size 2089420
oid sha256:95b4eb933dfcab0a5da9c608a0defc26a62206ab665e3a9af0dd6ac4a29bba72
size 2099891
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1df7a389ffbf315811e1605cb542a01ae560e977872c49e15092dc0f7432b1eb
size 2053936
oid sha256:0b1b5884816d8998bc519d62fb538d961d878e79c62c386c152aa7d6910f3acf
size 2064569
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf9c852e2d1618056391d08faacfbe402be84ced71793364e25e4e1fd0a84481
size 2057420
oid sha256:1e227ebf40207f27f1e9ad19881d274a6e6b87bdc2ea40051b6e7fa1e59c84c4
size 2054048
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:991acd0c4f868421686daa929e1c52bc7c1d47afcc39e23da19d17f0f19c8a0a
size 2048512
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5df7c700584320710a80633c4b06b1045886a88a32ba112a692560b58ce5eaf0
size 2123755
oid sha256:da004ba89cda86c27b44ee6acf78c69d845d887ede2776e90e4194f2819a4e1a
size 2104970
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8945225fb83489edf46bb1454770b0c55d2265b8b330ecd16079534c1fc2ef76
size 2087732
oid sha256:3a663cf0f68bc9ed2ac0df7e88a9b144a4cc25205fc71d9a3f0e25f892c86788
size 2069238
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e60ad9a4485f37882412ec46821127ea0f605f9abe435598f43d8bc2d5279c83
size 2104307
oid sha256:09dca383db2dfc978c9f0d78bda8331783aca72a1650c60cba1262fb73637af3
size 2059391
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac085ce2465a37d69978c118fba9b35b301449f1a8c5acd97fb5632cb0a8fe47
size 2062324
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e17277315e799bbac0caf145de342a624bfc80357aa63f297334fe8d6c81367
size 826589
oid sha256:2806fc99ef156ed9b0d9e97bc7329a00905eabe5bac5200a17e59124de5b94c1
size 857415
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4e45734de2938d98904d8b7c19a44b997fb1f6447839f60909e79c1e620096fd
size 806983
oid sha256:5691851137a7bd9c6fd1e6b18597ed4f5b6aa6d89cba714224f54e620e24fb96
size 837040
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a0f4de3f95f6242680a8b8b52939fd2a3a79a299d8dea6d5609244038c95bc1a
size 811524
oid sha256:c28a125af66dac1dbdfe12cb8dc588b51a1c4611693275113caf522ffe1c68c8
size 829240
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:32985fbf4c2f829638965222ef31392e490fd260ff0d07bc777571eed90d25de
size 810769
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d0bca263cdc71f987ef6b0f4f3d70f4779ea3865a603f752fc5e3ae773c9f394
size 857464
oid sha256:52d999afeabc4fd6dded6ad2ece35968c59c955b90c53961ee1fd42179aeb706
size 861376
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:171a0753cc99267b93c83f3bc2caf9546d2d72c685251f9fe70796556db5fc3e
size 837228
oid sha256:0bae4ecdbf0b1a8d06e4115af0add88ea5dadd6dc43f61e6a6c0a4aa55dbd34e
size 840927
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a88bb18fd0311c7c380f76a7c1364a1eb844f708835425b8a22abcc12997c25c
size 851763
oid sha256:32d350137393e9ca3ab38f481c57fa7eacf9c0a89be63de6cdacc2274d0f26cb
size 834432
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d1a65eab7cb10c6918f1a4efa9ae210b5b55959d90128a3a21536d5235948daf
size 825025
@@ -34,13 +34,44 @@ class JoinRoomScreenViewModelTests: XCTestCase {
try await deferred.fulfill()
}
func testAcceptInviteInteraction() async throws {
setupViewModel()
let deferred = deferFulfillment(viewModel.actionsPublisher) { $0 == .joined }
context.send(viewAction: .acceptInvite)
try await deferred.fulfill()
}
func testDeclineInviteInteraction() async throws {
setupViewModel()
try await deferFulfillment(viewModel.context.$viewState) { $0.roomDetails != nil }.fulfill()
context.send(viewAction: .declineInvite)
XCTAssertEqual(viewModel.context.alertInfo?.id, .declineInvite)
}
private func setupViewModel(throwing: Bool = false) {
let clientProxy = ClientProxyMock(.init())
clientProxy.joinRoomReturnValue = throwing ? .failure(.sdkError(ClientProxyMockError.generic)) : .success(())
clientProxy.roomPreviewForIdentifierReturnValue = .success(.init(roomID: "",
name: nil,
canonicalAlias: nil,
topic: nil,
avatarURL: nil,
memberCount: 0,
isHistoryWorldReadable: false,
isJoined: false,
isInvited: false,
isPublic: false,
canKnock: false))
viewModel = JoinRoomScreenViewModel(roomID: "1",
roomName: "Room Name",
avatarURL: nil,
interaction: .join,
clientProxy: clientProxy)
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
+112 -18
View File
@@ -21,32 +21,33 @@ import Combine
@MainActor
class RoomFlowCoordinatorTests: XCTestCase {
var clientProxy: ClientProxyMock!
var roomFlowCoordinator: RoomFlowCoordinator!
var navigationStackCoordinator: NavigationStackCoordinator!
var cancellables = Set<AnyCancellable>()
func testRoomPresentation() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
try await process(route: .roomList, expectedAction: .finished)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
}
func testRoomDetailsPresentation() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
try await process(route: .roomList, expectedAction: .finished)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
}
func testNoOp() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .roomDetails(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomDetailsScreenCoordinator)
@@ -60,7 +61,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
func testPushDetails() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
@@ -73,7 +74,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
func testChildRoomFlow() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
@@ -88,14 +89,14 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssert(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
try await process(route: .roomList, expectedAction: .finished)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
}
/// Tests the child flow teardown in isolation of it's parent.
func testChildFlowTearDown() async throws {
await setupViewModel(asChildFlow: true)
await setupRoomFlowCoordinator(asChildFlow: true)
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
try await process(route: .room(roomID: "1"))
@@ -105,7 +106,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomDetailsScreenCoordinator)
try await process(route: .roomList, expectedAction: .finished)
try await clearRoute(expectedActions: [.finished])
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 2, "A child room flow should leave its parent to clean up the stack.")
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.first is RoomScreenCoordinator, "A child room flow should leave its parent to clean up the stack.")
@@ -113,7 +114,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
func testChildRoomMemberDetails() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
@@ -130,7 +131,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
}
func testChildRoomIgnoresDirectDuplicate() async throws {
await setupViewModel()
await setupRoomFlowCoordinator()
try await process(route: .room(roomID: "1"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
@@ -151,6 +152,60 @@ class RoomFlowCoordinatorTests: XCTestCase {
XCTAssert(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
}
func testRoomMembershipInvite() async throws {
await setupRoomFlowCoordinator(roomType: .invited(roomID: "InvitedRoomID"))
try await process(route: .room(roomID: "InvitedRoomID"))
XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.rootCoordinator)
try await process(route: .room(roomID: "InvitedRoomID"))
XCTAssert(navigationStackCoordinator.rootCoordinator is JoinRoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
// "Join" the room
clientProxy.roomForIdentifierClosure = { _ in
RoomProxyMock(with: .init())
}
try await process(route: .room(roomID: "InvitedRoomID"))
XCTAssert(navigationStackCoordinator.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 0)
}
func testChildRoomMembershipInvite() async throws {
await setupRoomFlowCoordinator(asChildFlow: true, roomType: .invited(roomID: "InvitedRoomID"))
navigationStackCoordinator.setRootCoordinator(BlankFormCoordinator())
try await process(route: .room(roomID: "InvitedRoomID"))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator)
try await clearRoute(expectedActions: [.finished])
XCTAssertNil(navigationStackCoordinator.stackCoordinators.last, "A child room flow should remove the join room scren on dismissal")
navigationStackCoordinator.popToRoot()
try await process(route: .room(roomID: "InvitedRoomID"))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is JoinRoomScreenCoordinator)
// "Join" the room
clientProxy.roomForIdentifierClosure = { _ in
RoomProxyMock(with: .init())
}
try await process(route: .room(roomID: "InvitedRoomID"))
XCTAssertTrue(navigationStackCoordinator.rootCoordinator is BlankFormCoordinator, "A child room flow should push onto the stack, leaving the root alone.")
XCTAssertEqual(navigationStackCoordinator.stackCoordinators.count, 1)
XCTAssertTrue(navigationStackCoordinator.stackCoordinators.last is RoomScreenCoordinator)
}
// MARK: - Private
private func process(route: AppRoute) async throws {
@@ -159,11 +214,15 @@ class RoomFlowCoordinatorTests: XCTestCase {
try await Task.sleep(for: .milliseconds(100))
}
private func process(route: AppRoute, expectedAction: RoomFlowCoordinatorAction) async throws {
try await process(route: route, expectedActions: [expectedAction])
private func clearRoute(expectedActions: [RoomFlowCoordinatorAction]) async throws {
try await processRouteOrClear(route: nil, expectedActions: expectedActions)
}
private func process(route: AppRoute, expectedActions: [RoomFlowCoordinatorAction]) async throws {
try await processRouteOrClear(route: route, expectedActions: expectedActions)
}
private func processRouteOrClear(route: AppRoute?, expectedActions: [RoomFlowCoordinatorAction]) async throws {
guard !expectedActions.isEmpty else {
return
}
@@ -176,16 +235,40 @@ class RoomFlowCoordinatorTests: XCTestCase {
})
}
roomFlowCoordinator.handleAppRoute(route, animated: true)
if let route {
roomFlowCoordinator.handleAppRoute(route, animated: true)
} else {
roomFlowCoordinator.clearRoute(animated: true)
}
for fulfillment in fulfillments {
try await fulfillment.fulfill()
}
}
private func setupViewModel(asChildFlow: Bool = false) async {
private func setupRoomFlowCoordinator(asChildFlow: Bool = false, roomType: RoomType? = nil) async {
cancellables.removeAll()
let clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
clientProxy = ClientProxyMock(.init(userID: "hi@bob", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
clientProxy.roomPreviewForIdentifierClosure = { [roomType] roomID in
switch roomType {
case .invited:
return .success(.init(roomID: roomID,
name: "Invite",
canonicalAlias: nil,
topic: nil,
avatarURL: nil,
memberCount: 0,
isHistoryWorldReadable: false,
isJoined: false,
isInvited: true,
isPublic: false,
canKnock: false))
default:
fatalError("Something isn't set up right")
}
}
let mediaProvider = MockMediaProvider()
let voiceMessageMediaManager = VoiceMessageMediaManagerMock()
let userSession = MockUserSession(clientProxy: clientProxy,
@@ -196,7 +279,14 @@ class RoomFlowCoordinatorTests: XCTestCase {
navigationStackCoordinator = NavigationStackCoordinator()
navigationSplitCoordinator.setDetailCoordinator(navigationStackCoordinator)
roomFlowCoordinator = await RoomFlowCoordinator(roomProxy: RoomProxyMock(with: .init(id: "1")),
let roomID = switch roomType {
case .invited(let roomID):
roomID
default:
"1"
}
roomFlowCoordinator = await RoomFlowCoordinator(roomID: roomID,
userSession: userSession,
isChildFlow: asChildFlow,
roomTimelineControllerFactory: MockRoomTimelineControllerFactory(),
@@ -209,3 +299,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
orientationManager: OrientationManagerMock())
}
}
private enum RoomType {
case invited(roomID: String)
}
@@ -325,7 +325,7 @@ class RoomScreenViewModelTests: XCTestCase {
let items = [TextRoomTimelineItem(eventID: "t1"),
TextRoomTimelineItem(eventID: "t2"),
TextRoomTimelineItem(eventID: "t3")]
let (viewModel, roomProxy, timelineProxy, _, notificationCenter) = readReceiptsConfiguration(with: items)
let (viewModel, _, timelineProxy, _, _) = readReceiptsConfiguration(with: items)
// When sending a read receipt for the last item.
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))