Accept space invites from a home screen cell. (#4554)

* Push the SpaceScreen after accepting an invite from the room list.

* Add a test for accepting a space invite.
This commit is contained in:
Doug
2025-09-30 10:43:48 +01:00
committed by GitHub
parent 8b7b1b5a37
commit 89c9e3112f
10 changed files with 339 additions and 123 deletions
@@ -29,6 +29,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - retaining purpose // periphery:ignore - retaining purpose
private var roomFlowCoordinator: RoomFlowCoordinator? private var roomFlowCoordinator: RoomFlowCoordinator?
// periphery:ignore - retaining purpose
private var spaceFlowCoordinator: SpaceFlowCoordinator?
// periphery:ignore - retaining purpose // periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator? private var bugReportFlowCoordinator: BugReportFlowCoordinator?
@@ -117,7 +119,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
} }
case .roomDetails(let roomID): case .roomDetails(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID { if case .room(roomID) = stateMachine.state.detailState {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else { } else {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .roomDetails), userInfo: .init(animated: animated)) stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .roomDetails), userInfo: .init(animated: animated))
@@ -154,7 +156,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated)) stateMachine.processEvent(.showShareExtensionRoomList(sharePayload: payload), userInfo: .init(animated: animated))
} }
case .transferOwnership(let roomID): case .transferOwnership(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID { if case .room(roomID) = stateMachine.state.detailState {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated) roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else { } else {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership)) stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership))
@@ -178,12 +180,15 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
private func setupStateMachine() { private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in stateMachine.addTransitionHandler { [weak self] context in
guard let self else { return } guard let self else { return }
let animated = (context.userInfo as? ChatsFlowCoordinatorStateMachine.EventUserInfo)?.animated ?? true
let userInfo = context.userInfo as? ChatsFlowCoordinatorStateMachine.EventUserInfo
let animated = userInfo?.animated ?? true
switch (context.fromState, context.event, context.toState) { switch (context.fromState, context.event, context.toState) {
case (.initial, .start, .roomList): case (.initial, .start, .roomList):
presentHomeScreen() presentHomeScreen()
case(.roomList(let roomListSelectedRoomID), .selectRoom(let roomID, let via, let entryPoint), .roomList): case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList):
if roomListSelectedRoomID == roomID, if case .room(roomID) = detailState,
!entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading. !entryPoint.isEventID, // Don't reuse the existing room so the live timeline is hidden while the detached timeline is loading.
let roomFlowCoordinator { let roomFlowCoordinator {
let route: AppRoute = switch entryPoint { let route: AppRoute = switch entryPoint {
@@ -195,11 +200,20 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
} }
roomFlowCoordinator.handleAppRoute(route, animated: animated) roomFlowCoordinator.handleAppRoute(route, animated: animated)
} else { } else {
if case .space = detailState {
dismissRoomFlow(animated: animated)
}
startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated) startRoomFlow(roomID: roomID, via: via, entryPoint: entryPoint, animated: animated)
} }
actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user. actionsSubject.send(.hideCallScreenOverlay) // Turn any active call into a PiP so that navigation from a notification is visible to the user.
case(.roomList, .deselectRoom, .roomList): case(.roomList, .deselectRoom, .roomList):
dismissRoomFlow(animated: animated) dismissRoomFlow(animated: animated)
case(.roomList, .startSpaceFlow, .roomList):
guard let spaceRoomListProxy = userInfo?.spaceRoomListProxy else { fatalError("A space room list proxy is required.") }
startSpaceFlow(spaceRoomListProxy: spaceRoomListProxy, animated: animated)
case (.roomList, .finishedSpaceFlow, .roomList):
dismissSpaceFlow(animated: animated)
case (.roomList, .feedbackScreen, .feedbackScreen): case (.roomList, .feedbackScreen, .feedbackScreen):
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator), bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator),
@@ -264,8 +278,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.addTransitionHandler { [weak self] context in stateMachine.addTransitionHandler { [weak self] context in
switch context.toState { switch context.toState {
case .roomList(let roomListSelectedRoomID): case .roomList(detailState: .room(let detailStateRoomID)):
self?.selectedRoomSubject.send(roomListSelectedRoomID) self?.selectedRoomSubject.send(detailStateRoomID)
default: default:
break break
} }
@@ -351,9 +365,11 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
handleAppRoute(.roomDetails(roomID: roomID), animated: true) handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .presentReportRoom(let roomID): case .presentReportRoom(let roomID):
stateMachine.processEvent(.presentReportRoomScreen(roomID: roomID)) stateMachine.processEvent(.presentReportRoomScreen(roomID: roomID))
case .presentSpace(let spaceRoomListProxy):
stateMachine.processEvent(.startSpaceFlow, userInfo: .init(animated: true, spaceRoomListProxy: spaceRoomListProxy))
case .roomLeft(let roomID): case .roomLeft(let roomID):
if case .roomList(roomListSelectedRoomID: let roomListSelectedRoomID) = stateMachine.state, if case .roomList(detailState: .room(let detailStateRoomID)) = stateMachine.state,
roomListSelectedRoomID == roomID { detailStateRoomID == roomID {
clearRoute(animated: true) clearRoute(animated: true)
} }
case .presentSettingsScreen: case .presentSettingsScreen:
@@ -398,8 +414,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .dismiss(let shouldLeaveRoom): case .dismiss(let shouldLeaveRoom):
if shouldLeaveRoom, if shouldLeaveRoom,
case .roomList(let roomListSelectedRoomID) = stateMachine.state, case .roomList(detailState: .room(let detailStateRoomID)) = stateMachine.state,
roomListSelectedRoomID == roomID { detailStateRoomID == roomID {
clearRoute(animated: true) clearRoute(animated: true)
} }
navigationSplitCoordinator.setSheetCoordinator(nil) navigationSplitCoordinator.setSheetCoordinator(nil)
@@ -491,6 +507,43 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
roomFlowCoordinator = nil roomFlowCoordinator = nil
} }
// MARK: Space Flow
private func startSpaceFlow(spaceRoomListProxy: SpaceRoomListProxyProtocol, animated: Bool) {
let coordinator = SpaceFlowCoordinator(entryPoint: .space(spaceRoomListProxy),
spaceServiceProxy: userSession.clientProxy.spaceService,
isChildFlow: false,
navigationStackCoordinator: detailNavigationStackCoordinator,
flowParameters: flowParameters)
coordinator.actionsPublisher
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.showCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.sessionVerification(.userInitiator(userID: userID)))
case .finished:
stateMachine.processEvent(.finishedSpaceFlow)
}
}
.store(in: &cancellables)
spaceFlowCoordinator = coordinator
if navigationSplitCoordinator.detailCoordinator !== detailNavigationStackCoordinator {
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated)
}
coordinator.start()
}
private func dismissSpaceFlow(animated: Bool) {
// Based on dismissRoomFlow, past me was very insistent that this must happen after the flow has tidied the stack 😅.
navigationSplitCoordinator.setDetailCoordinator(nil, animated: animated)
roomFlowCoordinator = nil
}
// MARK: Start Chat // MARK: Start Chat
private func presentStartChat(animated: Bool) { private func presentStartChat(animated: Bool) {
@@ -10,63 +10,69 @@ import Foundation
import SwiftState import SwiftState
class ChatsFlowCoordinatorStateMachine { class ChatsFlowCoordinatorStateMachine {
enum DetailState: Hashable {
case room(roomID: String)
case space
}
/// States the AppCoordinator can find itself in /// States the AppCoordinator can find itself in
enum State: StateType { enum State: StateType {
/// The initial state, used before the coordinator starts /// The initial state, used before the coordinator starts
case initial case initial
/// Showing the home screen. The `roomListSelectedRoomID` represents the timeline shown on the detail panel (if any) /// Showing the home screen. The `roomListSelectedRoomID` represents the timeline shown on the detail panel (if any)
case roomList(roomListSelectedRoomID: String?) case roomList(detailState: DetailState?)
/// Showing the feedback screen. /// Showing the feedback screen.
case feedbackScreen(roomListSelectedRoomID: String?) case feedbackScreen(detailState: DetailState?)
/// Showing the recovery key screen. /// Showing the recovery key screen.
case recoveryKeyScreen(roomListSelectedRoomID: String?) case recoveryKeyScreen(detailState: DetailState?)
/// Showing the encryption reset flow. /// Showing the encryption reset flow.
case encryptionResetFlow(roomListSelectedRoomID: String?) case encryptionResetFlow(detailState: DetailState?)
/// Showing the start chat screen /// Showing the start chat screen
case startChatScreen(roomListSelectedRoomID: String?) case startChatScreen(detailState: DetailState?)
/// Showing the logout flows /// Showing the logout flows
case logoutConfirmationScreen(roomListSelectedRoomID: String?) case logoutConfirmationScreen(detailState: DetailState?)
/// Showing Room Directory Search screen /// Showing Room Directory Search screen
case roomDirectorySearchScreen(roomListSelectedRoomID: String?) case roomDirectorySearchScreen(detailState: DetailState?)
/// Showing the user profile screen. This screen clears the navigation. /// Showing the user profile screen. This screen clears the navigation.
case userProfileScreen case userProfileScreen
/// Showing the report room screen, for the given room identrifier /// Showing the report room screen, for the given room identrifier
case reportRoomScreen(roomListSelectedRoomID: String?) case reportRoomScreen(detailState: DetailState?)
case shareExtensionRoomList(sharePayload: ShareExtensionPayload) case shareExtensionRoomList(sharePayload: ShareExtensionPayload)
case declineAndBlockUserScreen(roomListSelectedRoomID: String?) case declineAndBlockUserScreen(detailState: DetailState?)
/// The selected room ID from the state if available. /// The selected room ID from the state if available.
var roomListSelectedRoomID: String? { var detailState: DetailState? {
switch self { switch self {
case .initial, .userProfileScreen, .shareExtensionRoomList: case .initial, .userProfileScreen, .shareExtensionRoomList:
nil nil
case .roomList(let roomListSelectedRoomID), case .roomList(let detailState),
.feedbackScreen(let roomListSelectedRoomID), .feedbackScreen(let detailState),
.recoveryKeyScreen(let roomListSelectedRoomID), .recoveryKeyScreen(let detailState),
.encryptionResetFlow(let roomListSelectedRoomID), .encryptionResetFlow(let detailState),
.startChatScreen(let roomListSelectedRoomID), .startChatScreen(let detailState),
.logoutConfirmationScreen(let roomListSelectedRoomID), .logoutConfirmationScreen(let detailState),
.roomDirectorySearchScreen(let roomListSelectedRoomID), .roomDirectorySearchScreen(let detailState),
.reportRoomScreen(let roomListSelectedRoomID), .reportRoomScreen(let detailState),
.declineAndBlockUserScreen(let roomListSelectedRoomID): .declineAndBlockUserScreen(let detailState):
roomListSelectedRoomID detailState
} }
} }
} }
struct EventUserInfo { struct EventUserInfo {
let animated: Bool let animated: Bool
var spaceRoomListProxy: SpaceRoomListProxyProtocol?
} }
/// Events that can be triggered on the AppCoordinator state machine /// Events that can be triggered on the AppCoordinator state machine
@@ -82,6 +88,13 @@ class ChatsFlowCoordinatorStateMachine {
/// The room screen has been dismissed /// The room screen has been dismissed
case deselectRoom case deselectRoom
/// Request presentation of a space.
///
/// The space's `RoomListProxyProtocol` must be provided in the `EventUserInfo`.
case startSpaceFlow
/// The space has been dismissed.
case finishedSpaceFlow
/// Request presentation of the feedback screen /// Request presentation of the feedback screen
case feedbackScreen case feedbackScreen
/// The feedback screen has been dismissed /// The feedback screen has been dismissed
@@ -134,59 +147,64 @@ class ChatsFlowCoordinatorStateMachine {
} }
private func configure() { private func configure() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(roomListSelectedRoomID: nil)]) stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(detailState: nil)])
stateMachine.addRouteMapping { event, fromState, _ in stateMachine.addRouteMapping { event, fromState, _ in
switch (fromState, event) { switch (fromState, event) {
case (.roomList, .selectRoom(let roomID, _, _)): case (.roomList, .selectRoom(let roomID, _, _)):
return .roomList(roomListSelectedRoomID: roomID) return .roomList(detailState: .room(roomID: roomID))
case (.roomList, .deselectRoom): case (.roomList, .deselectRoom):
return .roomList(roomListSelectedRoomID: nil) return .roomList(detailState: nil)
case (.roomList, .startSpaceFlow):
return .roomList(detailState: .space)
case (.roomList, .finishedSpaceFlow):
return .roomList(detailState: nil)
case (.roomList(let roomListSelectedRoomID), .feedbackScreen): case (.roomList(let detailState), .feedbackScreen):
return .feedbackScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .feedbackScreen(detailState: detailState)
case (.feedbackScreen(let roomListSelectedRoomID), .dismissedFeedbackScreen): case (.feedbackScreen(let detailState), .dismissedFeedbackScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .showRecoveryKeyScreen): case (.roomList(let detailState), .showRecoveryKeyScreen):
return .recoveryKeyScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .recoveryKeyScreen(detailState: detailState)
case (.recoveryKeyScreen(let roomListSelectedRoomID), .dismissedRecoveryKeyScreen): case (.recoveryKeyScreen(let detailState), .dismissedRecoveryKeyScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .startEncryptionResetFlow): case (.roomList(let detailState), .startEncryptionResetFlow):
return .encryptionResetFlow(roomListSelectedRoomID: roomListSelectedRoomID) return .encryptionResetFlow(detailState: detailState)
case (.encryptionResetFlow(let roomListSelectedRoomID), .finishedEncryptionResetFlow): case (.encryptionResetFlow(let detailState), .finishedEncryptionResetFlow):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .showStartChatScreen): case (.roomList(let detailState), .showStartChatScreen):
return .startChatScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .startChatScreen(detailState: detailState)
case (.startChatScreen(let roomListSelectedRoomID), .dismissedStartChatScreen): case (.startChatScreen(let detailState), .dismissedStartChatScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .showRoomDirectorySearchScreen): case (.roomList(let detailState), .showRoomDirectorySearchScreen):
return .roomDirectorySearchScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .roomDirectorySearchScreen(detailState: detailState)
case (.roomDirectorySearchScreen(let roomListSelectedRoomID), .dismissedRoomDirectorySearchScreen): case (.roomDirectorySearchScreen(let detailState), .dismissedRoomDirectorySearchScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
case (_, .showUserProfileScreen): case (_, .showUserProfileScreen):
return .userProfileScreen return .userProfileScreen
case (.userProfileScreen, .dismissedUserProfileScreen): case (.userProfileScreen, .dismissedUserProfileScreen):
return .roomList(roomListSelectedRoomID: nil) return .roomList(detailState: nil)
case (.roomList, .showShareExtensionRoomList(let sharePayload)): case (.roomList, .showShareExtensionRoomList(let sharePayload)):
return .shareExtensionRoomList(sharePayload: sharePayload) return .shareExtensionRoomList(sharePayload: sharePayload)
case (.shareExtensionRoomList, .dismissedShareExtensionRoomList): case (.shareExtensionRoomList, .dismissedShareExtensionRoomList):
return .roomList(roomListSelectedRoomID: nil) return .roomList(detailState: nil)
case (.roomList(let roomListSelectedRoomID), .presentReportRoomScreen): case (.roomList(let detailState), .presentReportRoomScreen):
return .reportRoomScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .reportRoomScreen(detailState: detailState)
case (.reportRoomScreen(let roomListSelectedRoomID), .dismissedReportRoomScreen): case (.reportRoomScreen(let detailState), .dismissedReportRoomScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
case(.roomList(let roomListSelectedRoomID), .presentDeclineAndBlockScreen): case(.roomList(let detailState), .presentDeclineAndBlockScreen):
return .declineAndBlockUserScreen(roomListSelectedRoomID: roomListSelectedRoomID) return .declineAndBlockUserScreen(detailState: detailState)
case (.declineAndBlockUserScreen(let roomListSelectedRoomID), .dismissedDeclineAndBlockScreen): case (.declineAndBlockUserScreen(let detailState), .dismissedDeclineAndBlockScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID) return .roomList(detailState: detailState)
default: default:
return nil return nil
@@ -221,8 +239,8 @@ class ChatsFlowCoordinatorStateMachine {
/// Flag indicating the machine is displaying room screen with given room identifier /// Flag indicating the machine is displaying room screen with given room identifier
func isDisplayingRoomScreen(withRoomID roomID: String) -> Bool { func isDisplayingRoomScreen(withRoomID roomID: String) -> Bool {
switch stateMachine.state { switch stateMachine.state {
case .roomList(let roomListSelectedRoomID): case .roomList(detailState: .room(let detailStateRoomID)):
return roomID == roomListSelectedRoomID return roomID == detailStateRoomID
default: default:
return false return false
} }
@@ -14,6 +14,7 @@ struct InvitedRoomProxyMockConfiguration {
var id = UUID().uuidString var id = UUID().uuidString
var name: String? var name: String?
var avatarURL: URL? var avatarURL: URL?
var isSpace = false
var members: [RoomMemberProxyMock] = .allMembers var members: [RoomMemberProxyMock] = .allMembers
var inviter: RoomMemberProxyMock = .mockAlice var inviter: RoomMemberProxyMock = .mockAlice
} }
@@ -44,7 +45,7 @@ extension RoomInfoProxyMock {
avatarURL = configuration.avatarURL avatarURL = configuration.avatarURL
isDirect = false isDirect = false
isSpace = false isSpace = configuration.isSpace
successor = nil successor = nil
isFavourite = false isFavourite = false
canonicalAlias = nil canonicalAlias = nil
@@ -341,4 +341,49 @@ extension Array where Element == RoomSummary {
isFavourite: false, isFavourite: false,
isTombstoned: false) isTombstoned: false)
] ]
static let mockSpaceInvites: [Element] = [
RoomSummary(room: RoomSDKMock(),
id: "someAwesomeSpaceID1",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
name: "First space",
isDirect: false,
isSpace: true,
avatarURL: .mockMXCAvatar,
heroes: [],
activeMembersCount: 0,
lastMessage: nil,
lastMessageDate: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: "#footest:somewhere.org",
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "someAwesomeSpaceID2",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
name: "Second space",
isDirect: false,
isSpace: true,
avatarURL: nil,
heroes: [],
activeMembersCount: 0,
lastMessage: nil,
lastMessageDate: nil,
unreadMessagesCount: 0,
unreadMentionsCount: 0,
unreadNotificationsCount: 0,
notificationMode: nil,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false,
isTombstoned: false)
]
} }
@@ -23,6 +23,7 @@ enum HomeScreenCoordinatorAction {
case presentRoomDetails(roomIdentifier: String) case presentRoomDetails(roomIdentifier: String)
case presentReportRoom(roomIdentifier: String) case presentReportRoom(roomIdentifier: String)
case presentDeclineAndBlock(userID: String, roomID: String) case presentDeclineAndBlock(userID: String, roomID: String)
case presentSpace(SpaceRoomListProxyProtocol)
case roomLeft(roomIdentifier: String) case roomLeft(roomIdentifier: String)
case transferOwnership(roomIdentifier: String) case transferOwnership(roomIdentifier: String)
case presentSettingsScreen case presentSettingsScreen
@@ -67,6 +68,10 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier)) actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier))
case .presentReportRoom(let roomIdentifier): case .presentReportRoom(let roomIdentifier):
actionsSubject.send(.presentReportRoom(roomIdentifier: roomIdentifier)) actionsSubject.send(.presentReportRoom(roomIdentifier: roomIdentifier))
case .presentDeclineAndBlock(let userID, let roomID):
actionsSubject.send(.presentDeclineAndBlock(userID: userID, roomID: roomID))
case .presentSpace(let spaceRoomListProxy):
actionsSubject.send(.presentSpace(spaceRoomListProxy))
case .roomLeft(roomIdentifier: let roomIdentifier): case .roomLeft(roomIdentifier: let roomIdentifier):
actionsSubject.send(.roomLeft(roomIdentifier: roomIdentifier)) actionsSubject.send(.roomLeft(roomIdentifier: roomIdentifier))
case .presentFeedbackScreen: case .presentFeedbackScreen:
@@ -85,8 +90,6 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentGlobalSearch) actionsSubject.send(.presentGlobalSearch)
case .logout: case .logout:
actionsSubject.send(.logout) actionsSubject.send(.logout)
case .presentDeclineAndBlock(let userID, let roomID):
actionsSubject.send(.presentDeclineAndBlock(userID: userID, roomID: roomID))
case .transferOwnership(let roomIdentifier): case .transferOwnership(let roomIdentifier):
actionsSubject.send(.transferOwnership(roomIdentifier: roomIdentifier)) actionsSubject.send(.transferOwnership(roomIdentifier: roomIdentifier))
} }
@@ -9,11 +9,12 @@ import Combine
import Foundation import Foundation
import UIKit import UIKit
enum HomeScreenViewModelAction: Equatable { enum HomeScreenViewModelAction {
case presentRoom(roomIdentifier: String) case presentRoom(roomIdentifier: String)
case presentRoomDetails(roomIdentifier: String) case presentRoomDetails(roomIdentifier: String)
case presentReportRoom(roomIdentifier: String) case presentReportRoom(roomIdentifier: String)
case presentDeclineAndBlock(userID: String, roomID: String) case presentDeclineAndBlock(userID: String, roomID: String)
case presentSpace(SpaceRoomListProxyProtocol)
case roomLeft(roomIdentifier: String) case roomLeft(roomIdentifier: String)
case transferOwnership(roomIdentifier: String) case transferOwnership(roomIdentifier: String)
case presentSecureBackupSettings case presentSecureBackupSettings
@@ -414,11 +414,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
switch await userSession.clientProxy.joinRoom(roomID, via: []) { switch await userSession.clientProxy.joinRoom(roomID, via: []) {
case .success: case .success:
actionsSubject.send(.presentRoom(roomIdentifier: roomID)) await finishAcceptInvite(roomProxy: roomProxy)
analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect,
isSpace: roomProxy.info.isSpace,
activeMemberCount: UInt(roomProxy.info.activeMembersCount))
appSettings.seenInvites.remove(roomID)
case .failure(let error): case .failure(let error):
switch error { switch error {
case .invalidInvite: case .invalidInvite:
@@ -429,6 +425,28 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
} }
} }
private func finishAcceptInvite(roomProxy: InvitedRoomProxyProtocol) async {
if roomProxy.info.isSpace {
let spaceService = userSession.clientProxy.spaceService
switch await spaceService.spaceRoomList(spaceID: roomProxy.id, parent: nil) {
case .success(let spaceRoomListProxy):
actionsSubject.send(.presentSpace(spaceRoomListProxy))
case .failure(let error):
MXLog.error("Failed to get the space room list after accepting invite: \(error)")
displayError()
return
}
} else {
actionsSubject.send(.presentRoom(roomIdentifier: roomProxy.id))
}
analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect,
isSpace: roomProxy.info.isSpace,
activeMemberCount: UInt(roomProxy.info.activeMembersCount))
appSettings.seenInvites.remove(roomProxy.id)
}
private func showDeclineInviteConfirmationAlert(roomID: String) async { private func showDeclineInviteConfirmationAlert(roomID: String) async {
guard let room = state.rooms.first(where: { $0.id == roomID }) else { guard let room = state.rooms.first(where: { $0.id == roomID }) else {
displayError() displayError()
@@ -50,29 +50,29 @@ class ChatsFlowCoordinatorTests: XCTestCase {
navigationSplitCoordinator: splitCoordinator, navigationSplitCoordinator: splitCoordinator,
flowParameters: flowParameters) flowParameters: flowParameters)
let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) } let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(detailState: nil) }
chatsFlowCoordinator.start() chatsFlowCoordinator.start()
try await deferred.fulfill() try await deferred.fulfill()
} }
func testRoomPresentation() async throws { func testRoomPresentation() async throws {
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil)) try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator) XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator) XCTAssertNil(detailCoordinator)
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(roomListSelectedRoomID: "2")) try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil)) try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator) XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator) XCTAssertNil(detailCoordinator)
@@ -82,25 +82,25 @@ class ChatsFlowCoordinatorTests: XCTestCase {
func testRoomAliasPresentation() async throws { func testRoomAliasPresentation() async throws {
clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "1", servers: [])) clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "1", servers: []))
try await process(route: .roomAlias("#alias:matrix.org"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomAlias("#alias:matrix.org"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil)) try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator) XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator) XCTAssertNil(detailCoordinator)
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "2", servers: [])) clientProxy.resolveRoomAliasReturnValue = .success(.init(roomId: "2", servers: []))
try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(roomListSelectedRoomID: "2")) try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil)) try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator) XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator) XCTAssertNil(detailCoordinator)
@@ -108,27 +108,27 @@ class ChatsFlowCoordinatorTests: XCTestCase {
} }
func testRoomDetailsPresentation() async throws { func testRoomDetailsPresentation() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil)) try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator) XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator) XCTAssertNil(detailCoordinator)
} }
func testStackUnwinding() async throws { func testStackUnwinding() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(roomListSelectedRoomID: "2")) try await process(route: .room(roomID: "2", via: []), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
} }
func testNoOp() async throws { func testNoOp() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -141,17 +141,17 @@ class ChatsFlowCoordinatorTests: XCTestCase {
} }
func testSwitchToDifferentDetails() async throws { func testSwitchToDifferentDetails() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .roomDetails(roomID: "2"), expectedState: .roomList(roomListSelectedRoomID: "2")) try await process(route: .roomDetails(roomID: "2"), expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
} }
func testPushDetails() async throws { func testPushDetails() async throws {
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -166,17 +166,17 @@ class ChatsFlowCoordinatorTests: XCTestCase {
} }
func testReplaceDetailsWithTimeline() async throws { func testReplaceDetailsWithTimeline() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
} }
func testUserProfileClearsStack() async throws { func testUserProfileClearsStack() async throws {
try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .roomDetails(roomID: "1"), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomDetailsScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
XCTAssertNil(splitCoordinator?.sheetCoordinator) XCTAssertNil(splitCoordinator?.sheetCoordinator)
@@ -191,7 +191,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
} }
func testRoomClearsStack() async throws { func testRoomClearsStack() async throws {
try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -203,7 +203,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
try await process(route: .room(roomID: "3", via: []), expectedState: .roomList(roomListSelectedRoomID: "3")) try await process(route: .room(roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -211,7 +211,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
func testEventRoutes() async throws { func testEventRoutes() async throws {
// A regular event route should set its room as the root of the stack and focus on the event. // A regular event route should set its room as the root of the stack and focus on the event.
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -229,7 +229,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "2") XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "2")
// A subsequent regular event route should clear the stack and set the new room as the root of the stack. // A subsequent regular event route should clear the stack and set the new room as the root of the stack.
try await process(route: .event(eventID: "3", roomID: "3", via: []), expectedState: .roomList(roomListSelectedRoomID: "3")) try await process(route: .event(eventID: "3", roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -237,7 +237,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "3") XCTAssertEqual(timelineControllerFactory.buildTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReceivedArguments?.initialFocussedEventID, "3")
// A regular event route for the same room should set a new instance of the room as the root of the stack. // A regular event route for the same room should set a new instance of the room as the root of the stack.
try await process(route: .event(eventID: "4", roomID: "3", via: []), expectedState: .roomList(roomListSelectedRoomID: "3")) try await process(route: .event(eventID: "4", roomID: "3", via: []), expectedState: .roomList(detailState: .room(roomID: "3")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0) XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -247,24 +247,24 @@ class ChatsFlowCoordinatorTests: XCTestCase {
} }
func testShareMediaRouteWithRoom() async throws { func testShareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload), try await process(route: .share(sharePayload),
expectedState: .roomList(roomListSelectedRoomID: "2")) expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator) XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
} }
func testShareTextRouteWithRoom() async throws { func testShareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text") let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
try await process(route: .share(sharePayload), try await process(route: .share(sharePayload),
expectedState: .roomList(roomListSelectedRoomID: "2")) expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(splitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.") XCTAssertNil(splitCoordinator?.sheetCoordinator, "The media upload sheet shouldn't be shown when sharing text.")
@@ -265,7 +265,7 @@ class HomeScreenViewModelTests: XCTestCase {
} }
func testInviteUnreadBadge() async throws { func testInviteUnreadBadge() async throws {
setupViewModel(withInvites: true) setupViewModel(invites: .rooms)
var invites = context.viewState.rooms.invites var invites = context.viewState.rooms.invites
XCTAssertEqual(invites.count, 2) XCTAssertEqual(invites.count, 2)
@@ -288,7 +288,7 @@ class HomeScreenViewModelTests: XCTestCase {
} }
func testAcceptInvite() async throws { func testAcceptInvite() async throws {
setupViewModel(withInvites: true) setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs) appSettings.seenInvites = Set(invitedRoomIDs)
@@ -302,8 +302,25 @@ class HomeScreenViewModelTests: XCTestCase {
XCTAssertFalse(notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.") XCTAssertFalse(notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.")
} }
func testAcceptSpaceInvite() async throws {
setupViewModel(invites: .spaces)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs)
XCTAssertEqual(invitedRoomIDs.count, 2)
let deferred = deferFulfillment(viewModel.actions) {
$0 == .presentSpace(SpaceRoomListProxyMock(.init(spaceRoomProxy: SpaceRoomProxyMock(.init(id: invitedRoomIDs[0], isSpace: true)))))
}
context.send(viewAction: .acceptInvite(roomIdentifier: invitedRoomIDs[0]))
try await deferred.fulfill()
XCTAssertEqual(appSettings.seenInvites, [invitedRoomIDs[1]])
XCTAssertFalse(notificationManager.removeDeliveredMessageNotificationsForCalled, "The notification will be dismissed when opening the room.")
}
func testDeclineInvite() async throws { func testDeclineInvite() async throws {
setupViewModel(withInvites: true) setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs) appSettings.seenInvites = Set(invitedRoomIDs)
XCTAssertEqual(invitedRoomIDs.count, 2) XCTAssertEqual(invitedRoomIDs.count, 2)
@@ -331,7 +348,7 @@ class HomeScreenViewModelTests: XCTestCase {
} }
func testDeclineAndBlockInvite() async throws { func testDeclineAndBlockInvite() async throws {
setupViewModel(withInvites: true) setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID) let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs) appSettings.seenInvites = Set(invitedRoomIDs)
XCTAssertEqual(invitedRoomIDs.count, 2) XCTAssertEqual(invitedRoomIDs.count, 2)
@@ -347,20 +364,41 @@ class HomeScreenViewModelTests: XCTestCase {
// MARK: - Helpers // MARK: - Helpers
private func setupViewModel(securityStatePublisher: CurrentValuePublisher<SessionSecurityState, Never>? = nil, withInvites: Bool = false) { enum InviteType { case rooms, spaces }
private func setupViewModel(securityStatePublisher: CurrentValuePublisher<SessionSecurityState, Never>? = nil, invites: InviteType? = nil) {
var rooms: [RoomSummary] = .mockRooms var rooms: [RoomSummary] = .mockRooms
if withInvites {
switch invites {
case .rooms:
rooms += .mockInvites rooms += .mockInvites
case .spaces:
rooms += .mockSpaceInvites
case nil:
break
} }
roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms))) roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms)))
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", clientProxy = ClientProxyMock(.init(userID: "@mock:client.com",
roomSummaryProvider: roomSummaryProvider)) roomSummaryProvider: roomSummaryProvider))
if withInvites {
clientProxy.joinRoomViaReturnValue = .success(()) clientProxy.joinRoomViaReturnValue = .success(())
clientProxy.joinRoomAliasReturnValue = .success(()) clientProxy.joinRoomAliasReturnValue = .success(())
clientProxy.roomForIdentifierClosure = { _ in .invited(InvitedRoomProxyMock(.init())) }
switch invites {
case .rooms:
clientProxy.roomForIdentifierClosure = { roomID in .invited(InvitedRoomProxyMock(.init(id: roomID))) }
case .spaces:
clientProxy.roomForIdentifierClosure = { spaceID in .invited(InvitedRoomProxyMock(.init(id: spaceID, isSpace: true))) }
let spaceServiceProxy = SpaceServiceProxyMock(.init())
spaceServiceProxy.spaceRoomListSpaceIDParentClosure = { spaceID, _ in
.success(SpaceRoomListProxyMock(.init(spaceRoomProxy: SpaceRoomProxyMock(.init(id: spaceID, isSpace: true)))))
}
clientProxy.underlyingSpaceService = spaceServiceProxy
case nil:
break
} }
let userSession = UserSessionMock(.init(clientProxy: clientProxy)) let userSession = UserSessionMock(.init(clientProxy: clientProxy))
@@ -390,3 +428,42 @@ private extension [HomeScreenRoom] {
} }
} }
} }
extension HomeScreenViewModelAction: @retroactive Equatable {
public static func == (lhs: HomeScreenViewModelAction, rhs: HomeScreenViewModelAction) -> Bool {
switch (lhs, rhs) {
case (.presentRoom(let lhsID), .presentRoom(let rhsID)):
lhsID == rhsID
case (.presentRoomDetails(let lhsID), .presentRoomDetails(let rhsID)):
lhsID == rhsID
case (.presentReportRoom(let lhsID), .presentReportRoom(let rhsID)):
lhsID == rhsID
case (.presentDeclineAndBlock(let lhsUserID, let lhsRoomID), .presentDeclineAndBlock(let rhsUserID, let rhsRoomID)):
lhsUserID == rhsUserID && lhsRoomID == rhsRoomID
case (.presentSpace(let lhsSpaceRoomListProxy), .presentSpace(let rhsSpaceRoomListProxy)):
lhsSpaceRoomListProxy.spaceRoomProxy.id == rhsSpaceRoomListProxy.spaceRoomProxy.id
case (.roomLeft(let lhsID), .roomLeft(let rhsID)):
lhsID == rhsID
case (.transferOwnership(let lhsID), .transferOwnership(let rhsID)):
lhsID == rhsID
case (.presentSecureBackupSettings, .presentSecureBackupSettings):
true
case (.presentRecoveryKeyScreen, .presentRecoveryKeyScreen):
true
case (.presentEncryptionResetScreen, .presentEncryptionResetScreen):
true
case (.presentSettingsScreen, .presentSettingsScreen):
true
case (.presentFeedbackScreen, .presentFeedbackScreen):
true
case (.presentStartChatScreen, .presentStartChatScreen):
true
case (.presentGlobalSearch, .presentGlobalSearch):
true
case (.logout, .logout):
true
default:
false
}
}
}
@@ -76,7 +76,7 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
} }
func testRoomPresentation() async throws { func testRoomPresentation() async throws {
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
} }
@@ -86,14 +86,14 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator) XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
XCTAssertNil(detailCoordinator) XCTAssertNil(detailCoordinator)
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(detailState: .room(roomID: "1")))
XCTAssertNil((tabCoordinator?.sheetCoordinator)) XCTAssertNil((tabCoordinator?.sheetCoordinator))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
} }
func testChildRoomPresentation() async throws { func testChildRoomPresentation() async throws {
try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .room(roomID: "1", via: []), expectedChatsState: .roomList(detailState: .room(roomID: "1")))
let detailNavigationStack = try XCTUnwrap(detailNavigationStack, "There must be a navigation stack.") let detailNavigationStack = try XCTUnwrap(detailNavigationStack, "There must be a navigation stack.")
XCTAssertTrue(detailNavigationStack.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator) XCTAssertNotNil(detailCoordinator)
@@ -121,14 +121,14 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
} }
func testShareMediaRouteWithRoom() async throws { func testShareMediaRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator) XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator) XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)]) let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload), try await process(route: .share(sharePayload),
expectedChatsState: .roomList(roomListSelectedRoomID: "2")) expectedChatsState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator) XCTAssertNil(tabCoordinator?.sheetCoordinator)
@@ -149,14 +149,14 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
} }
func testShareTextRouteWithRoom() async throws { func testShareTextRouteWithRoom() async throws {
try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(roomListSelectedRoomID: "1")) try await process(route: .event(eventID: "1", roomID: "1", via: []), expectedChatsState: .roomList(detailState: .room(roomID: "1")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator) XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator) XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text") let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
try await process(route: .share(sharePayload), try await process(route: .share(sharePayload),
expectedChatsState: .roomList(roomListSelectedRoomID: "2")) expectedChatsState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator) XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator) XCTAssertNil(tabCoordinator?.sheetCoordinator)