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
private var roomFlowCoordinator: RoomFlowCoordinator?
// periphery:ignore - retaining purpose
private var spaceFlowCoordinator: SpaceFlowCoordinator?
// periphery:ignore - retaining purpose
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
@@ -117,7 +119,7 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
}
case .roomDetails(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID {
if case .room(roomID) = stateMachine.state.detailState {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else {
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))
}
case .transferOwnership(let roomID):
if stateMachine.state.roomListSelectedRoomID == roomID {
if case .room(roomID) = stateMachine.state.detailState {
roomFlowCoordinator?.handleAppRoute(appRoute, animated: animated)
} else {
stateMachine.processEvent(.selectRoom(roomID: roomID, via: [], entryPoint: .transferOwnership))
@@ -178,12 +180,15 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
private func setupStateMachine() {
stateMachine.addTransitionHandler { [weak self] context in
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) {
case (.initial, .start, .roomList):
presentHomeScreen()
case(.roomList(let roomListSelectedRoomID), .selectRoom(let roomID, let via, let entryPoint), .roomList):
if roomListSelectedRoomID == roomID,
case(.roomList(let detailState), .selectRoom(let roomID, let via, let entryPoint), .roomList):
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.
let roomFlowCoordinator {
let route: AppRoute = switch entryPoint {
@@ -195,11 +200,20 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
}
roomFlowCoordinator.handleAppRoute(route, animated: animated)
} else {
if case .space = detailState {
dismissRoomFlow(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.
case(.roomList, .deselectRoom, .roomList):
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):
bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .sheet(sidebarNavigationStackCoordinator),
@@ -264,8 +278,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.addTransitionHandler { [weak self] context in
switch context.toState {
case .roomList(let roomListSelectedRoomID):
self?.selectedRoomSubject.send(roomListSelectedRoomID)
case .roomList(detailState: .room(let detailStateRoomID)):
self?.selectedRoomSubject.send(detailStateRoomID)
default:
break
}
@@ -351,9 +365,11 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
handleAppRoute(.roomDetails(roomID: roomID), animated: true)
case .presentReportRoom(let roomID):
stateMachine.processEvent(.presentReportRoomScreen(roomID: roomID))
case .presentSpace(let spaceRoomListProxy):
stateMachine.processEvent(.startSpaceFlow, userInfo: .init(animated: true, spaceRoomListProxy: spaceRoomListProxy))
case .roomLeft(let roomID):
if case .roomList(roomListSelectedRoomID: let roomListSelectedRoomID) = stateMachine.state,
roomListSelectedRoomID == roomID {
if case .roomList(detailState: .room(let detailStateRoomID)) = stateMachine.state,
detailStateRoomID == roomID {
clearRoute(animated: true)
}
case .presentSettingsScreen:
@@ -398,8 +414,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .dismiss(let shouldLeaveRoom):
if shouldLeaveRoom,
case .roomList(let roomListSelectedRoomID) = stateMachine.state,
roomListSelectedRoomID == roomID {
case .roomList(detailState: .room(let detailStateRoomID)) = stateMachine.state,
detailStateRoomID == roomID {
clearRoute(animated: true)
}
navigationSplitCoordinator.setSheetCoordinator(nil)
@@ -491,6 +507,43 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
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
private func presentStartChat(animated: Bool) {
@@ -10,63 +10,69 @@ import Foundation
import SwiftState
class ChatsFlowCoordinatorStateMachine {
enum DetailState: Hashable {
case room(roomID: String)
case space
}
/// States the AppCoordinator can find itself in
enum State: StateType {
/// The initial state, used before the coordinator starts
case initial
/// 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.
case feedbackScreen(roomListSelectedRoomID: String?)
case feedbackScreen(detailState: DetailState?)
/// Showing the recovery key screen.
case recoveryKeyScreen(roomListSelectedRoomID: String?)
case recoveryKeyScreen(detailState: DetailState?)
/// Showing the encryption reset flow.
case encryptionResetFlow(roomListSelectedRoomID: String?)
case encryptionResetFlow(detailState: DetailState?)
/// Showing the start chat screen
case startChatScreen(roomListSelectedRoomID: String?)
case startChatScreen(detailState: DetailState?)
/// Showing the logout flows
case logoutConfirmationScreen(roomListSelectedRoomID: String?)
case logoutConfirmationScreen(detailState: DetailState?)
/// Showing Room Directory Search screen
case roomDirectorySearchScreen(roomListSelectedRoomID: String?)
case roomDirectorySearchScreen(detailState: DetailState?)
/// Showing the user profile screen. This screen clears the navigation.
case userProfileScreen
/// Showing the report room screen, for the given room identrifier
case reportRoomScreen(roomListSelectedRoomID: String?)
case reportRoomScreen(detailState: DetailState?)
case shareExtensionRoomList(sharePayload: ShareExtensionPayload)
case declineAndBlockUserScreen(roomListSelectedRoomID: String?)
case declineAndBlockUserScreen(detailState: DetailState?)
/// The selected room ID from the state if available.
var roomListSelectedRoomID: String? {
var detailState: DetailState? {
switch self {
case .initial, .userProfileScreen, .shareExtensionRoomList:
nil
case .roomList(let roomListSelectedRoomID),
.feedbackScreen(let roomListSelectedRoomID),
.recoveryKeyScreen(let roomListSelectedRoomID),
.encryptionResetFlow(let roomListSelectedRoomID),
.startChatScreen(let roomListSelectedRoomID),
.logoutConfirmationScreen(let roomListSelectedRoomID),
.roomDirectorySearchScreen(let roomListSelectedRoomID),
.reportRoomScreen(let roomListSelectedRoomID),
.declineAndBlockUserScreen(let roomListSelectedRoomID):
roomListSelectedRoomID
case .roomList(let detailState),
.feedbackScreen(let detailState),
.recoveryKeyScreen(let detailState),
.encryptionResetFlow(let detailState),
.startChatScreen(let detailState),
.logoutConfirmationScreen(let detailState),
.roomDirectorySearchScreen(let detailState),
.reportRoomScreen(let detailState),
.declineAndBlockUserScreen(let detailState):
detailState
}
}
}
struct EventUserInfo {
let animated: Bool
var spaceRoomListProxy: SpaceRoomListProxyProtocol?
}
/// Events that can be triggered on the AppCoordinator state machine
@@ -82,6 +88,13 @@ class ChatsFlowCoordinatorStateMachine {
/// The room screen has been dismissed
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
case feedbackScreen
/// The feedback screen has been dismissed
@@ -134,59 +147,64 @@ class ChatsFlowCoordinatorStateMachine {
}
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
switch (fromState, event) {
case (.roomList, .selectRoom(let roomID, _, _)):
return .roomList(roomListSelectedRoomID: roomID)
return .roomList(detailState: .room(roomID: roomID))
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):
return .feedbackScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.feedbackScreen(let roomListSelectedRoomID), .dismissedFeedbackScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let detailState), .feedbackScreen):
return .feedbackScreen(detailState: detailState)
case (.feedbackScreen(let detailState), .dismissedFeedbackScreen):
return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .showRecoveryKeyScreen):
return .recoveryKeyScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.recoveryKeyScreen(let roomListSelectedRoomID), .dismissedRecoveryKeyScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let detailState), .showRecoveryKeyScreen):
return .recoveryKeyScreen(detailState: detailState)
case (.recoveryKeyScreen(let detailState), .dismissedRecoveryKeyScreen):
return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .startEncryptionResetFlow):
return .encryptionResetFlow(roomListSelectedRoomID: roomListSelectedRoomID)
case (.encryptionResetFlow(let roomListSelectedRoomID), .finishedEncryptionResetFlow):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let detailState), .startEncryptionResetFlow):
return .encryptionResetFlow(detailState: detailState)
case (.encryptionResetFlow(let detailState), .finishedEncryptionResetFlow):
return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .showStartChatScreen):
return .startChatScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.startChatScreen(let roomListSelectedRoomID), .dismissedStartChatScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let detailState), .showStartChatScreen):
return .startChatScreen(detailState: detailState)
case (.startChatScreen(let detailState), .dismissedStartChatScreen):
return .roomList(detailState: detailState)
case (.roomList(let roomListSelectedRoomID), .showRoomDirectorySearchScreen):
return .roomDirectorySearchScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomDirectorySearchScreen(let roomListSelectedRoomID), .dismissedRoomDirectorySearchScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let detailState), .showRoomDirectorySearchScreen):
return .roomDirectorySearchScreen(detailState: detailState)
case (.roomDirectorySearchScreen(let detailState), .dismissedRoomDirectorySearchScreen):
return .roomList(detailState: detailState)
case (_, .showUserProfileScreen):
return .userProfileScreen
case (.userProfileScreen, .dismissedUserProfileScreen):
return .roomList(roomListSelectedRoomID: nil)
return .roomList(detailState: nil)
case (.roomList, .showShareExtensionRoomList(let sharePayload)):
return .shareExtensionRoomList(sharePayload: sharePayload)
case (.shareExtensionRoomList, .dismissedShareExtensionRoomList):
return .roomList(roomListSelectedRoomID: nil)
return .roomList(detailState: nil)
case (.roomList(let roomListSelectedRoomID), .presentReportRoomScreen):
return .reportRoomScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.reportRoomScreen(let roomListSelectedRoomID), .dismissedReportRoomScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case (.roomList(let detailState), .presentReportRoomScreen):
return .reportRoomScreen(detailState: detailState)
case (.reportRoomScreen(let detailState), .dismissedReportRoomScreen):
return .roomList(detailState: detailState)
case(.roomList(let roomListSelectedRoomID), .presentDeclineAndBlockScreen):
return .declineAndBlockUserScreen(roomListSelectedRoomID: roomListSelectedRoomID)
case (.declineAndBlockUserScreen(let roomListSelectedRoomID), .dismissedDeclineAndBlockScreen):
return .roomList(roomListSelectedRoomID: roomListSelectedRoomID)
case(.roomList(let detailState), .presentDeclineAndBlockScreen):
return .declineAndBlockUserScreen(detailState: detailState)
case (.declineAndBlockUserScreen(let detailState), .dismissedDeclineAndBlockScreen):
return .roomList(detailState: detailState)
default:
return nil
@@ -221,8 +239,8 @@ class ChatsFlowCoordinatorStateMachine {
/// Flag indicating the machine is displaying room screen with given room identifier
func isDisplayingRoomScreen(withRoomID roomID: String) -> Bool {
switch stateMachine.state {
case .roomList(let roomListSelectedRoomID):
return roomID == roomListSelectedRoomID
case .roomList(detailState: .room(let detailStateRoomID)):
return roomID == detailStateRoomID
default:
return false
}
@@ -14,6 +14,7 @@ struct InvitedRoomProxyMockConfiguration {
var id = UUID().uuidString
var name: String?
var avatarURL: URL?
var isSpace = false
var members: [RoomMemberProxyMock] = .allMembers
var inviter: RoomMemberProxyMock = .mockAlice
}
@@ -44,7 +45,7 @@ extension RoomInfoProxyMock {
avatarURL = configuration.avatarURL
isDirect = false
isSpace = false
isSpace = configuration.isSpace
successor = nil
isFavourite = false
canonicalAlias = nil
@@ -341,4 +341,49 @@ extension Array where Element == RoomSummary {
isFavourite: 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 presentReportRoom(roomIdentifier: String)
case presentDeclineAndBlock(userID: String, roomID: String)
case presentSpace(SpaceRoomListProxyProtocol)
case roomLeft(roomIdentifier: String)
case transferOwnership(roomIdentifier: String)
case presentSettingsScreen
@@ -67,6 +68,10 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomDetails(roomIdentifier: roomIdentifier))
case .presentReportRoom(let 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):
actionsSubject.send(.roomLeft(roomIdentifier: roomIdentifier))
case .presentFeedbackScreen:
@@ -85,8 +90,6 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentGlobalSearch)
case .logout:
actionsSubject.send(.logout)
case .presentDeclineAndBlock(let userID, let roomID):
actionsSubject.send(.presentDeclineAndBlock(userID: userID, roomID: roomID))
case .transferOwnership(let roomIdentifier):
actionsSubject.send(.transferOwnership(roomIdentifier: roomIdentifier))
}
@@ -9,11 +9,12 @@ import Combine
import Foundation
import UIKit
enum HomeScreenViewModelAction: Equatable {
enum HomeScreenViewModelAction {
case presentRoom(roomIdentifier: String)
case presentRoomDetails(roomIdentifier: String)
case presentReportRoom(roomIdentifier: String)
case presentDeclineAndBlock(userID: String, roomID: String)
case presentSpace(SpaceRoomListProxyProtocol)
case roomLeft(roomIdentifier: String)
case transferOwnership(roomIdentifier: String)
case presentSecureBackupSettings
@@ -414,11 +414,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
switch await userSession.clientProxy.joinRoom(roomID, via: []) {
case .success:
actionsSubject.send(.presentRoom(roomIdentifier: roomID))
analyticsService.trackJoinedRoom(isDM: roomProxy.info.isDirect,
isSpace: roomProxy.info.isSpace,
activeMemberCount: UInt(roomProxy.info.activeMembersCount))
appSettings.seenInvites.remove(roomID)
await finishAcceptInvite(roomProxy: roomProxy)
case .failure(let error):
switch error {
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 {
guard let room = state.rooms.first(where: { $0.id == roomID }) else {
displayError()
@@ -50,29 +50,29 @@ class ChatsFlowCoordinatorTests: XCTestCase {
navigationSplitCoordinator: splitCoordinator,
flowParameters: flowParameters)
let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(roomListSelectedRoomID: nil) }
let deferred = deferFulfillment(stateMachineFactory.chatsFlowStatePublisher) { $0 == .roomList(detailState: nil) }
chatsFlowCoordinator.start()
try await deferred.fulfill()
}
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)
XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil))
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
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)
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)
XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil))
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
@@ -82,25 +82,25 @@ class ChatsFlowCoordinatorTests: XCTestCase {
func testRoomAliasPresentation() async throws {
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)
XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil))
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
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)
XCTAssertNotNil(detailCoordinator)
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)
XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil))
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
@@ -108,27 +108,27 @@ class ChatsFlowCoordinatorTests: XCTestCase {
}
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)
XCTAssertNotNil(detailCoordinator)
try await process(route: .roomList, expectedState: .roomList(roomListSelectedRoomID: nil))
try await process(route: .roomList, expectedState: .roomList(detailState: nil))
XCTAssertNil(detailNavigationStack?.rootCoordinator)
XCTAssertNil(detailCoordinator)
}
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)
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)
XCTAssertNotNil(detailCoordinator)
}
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)
XCTAssertNotNil(detailCoordinator)
@@ -141,17 +141,17 @@ class ChatsFlowCoordinatorTests: XCTestCase {
}
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)
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)
XCTAssertNotNil(detailCoordinator)
}
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)
XCTAssertNotNil(detailCoordinator)
@@ -166,17 +166,17 @@ class ChatsFlowCoordinatorTests: XCTestCase {
}
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)
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)
XCTAssertNotNil(detailCoordinator)
}
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)
XCTAssertNotNil(detailCoordinator)
XCTAssertNil(splitCoordinator?.sheetCoordinator)
@@ -191,7 +191,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
}
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)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
@@ -203,7 +203,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
XCTAssertTrue(detailNavigationStack?.stackCoordinators.first is RoomScreenCoordinator)
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)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
@@ -211,7 +211,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
func testEventRoutes() async throws {
// 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)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
@@ -229,7 +229,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
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.
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)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
@@ -237,7 +237,7 @@ class ChatsFlowCoordinatorTests: XCTestCase {
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.
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)
XCTAssertEqual(detailNavigationStack?.stackCoordinators.count, 0)
XCTAssertNotNil(detailCoordinator)
@@ -247,24 +247,24 @@ class ChatsFlowCoordinatorTests: XCTestCase {
}
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)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedState: .roomList(roomListSelectedRoomID: "2"))
expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertTrue((splitCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is MediaUploadPreviewScreenCoordinator)
}
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)
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
try await process(route: .share(sharePayload),
expectedState: .roomList(roomListSelectedRoomID: "2"))
expectedState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
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 {
setupViewModel(withInvites: true)
setupViewModel(invites: .rooms)
var invites = context.viewState.rooms.invites
XCTAssertEqual(invites.count, 2)
@@ -288,7 +288,7 @@ class HomeScreenViewModelTests: XCTestCase {
}
func testAcceptInvite() async throws {
setupViewModel(withInvites: true)
setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs)
@@ -302,8 +302,25 @@ class HomeScreenViewModelTests: XCTestCase {
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 {
setupViewModel(withInvites: true)
setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs)
XCTAssertEqual(invitedRoomIDs.count, 2)
@@ -331,7 +348,7 @@ class HomeScreenViewModelTests: XCTestCase {
}
func testDeclineAndBlockInvite() async throws {
setupViewModel(withInvites: true)
setupViewModel(invites: .rooms)
let invitedRoomIDs = context.viewState.rooms.invites.compactMap(\.roomID)
appSettings.seenInvites = Set(invitedRoomIDs)
XCTAssertEqual(invitedRoomIDs.count, 2)
@@ -347,20 +364,41 @@ class HomeScreenViewModelTests: XCTestCase {
// 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
if withInvites {
switch invites {
case .rooms:
rooms += .mockInvites
case .spaces:
rooms += .mockSpaceInvites
case nil:
break
}
roomSummaryProvider = RoomSummaryProviderMock(.init(state: .loaded(rooms)))
clientProxy = ClientProxyMock(.init(userID: "@mock:client.com",
roomSummaryProvider: roomSummaryProvider))
if withInvites {
clientProxy.joinRoomViaReturnValue = .success(())
clientProxy.joinRoomAliasReturnValue = .success(())
clientProxy.roomForIdentifierClosure = { _ in .invited(InvitedRoomProxyMock(.init())) }
clientProxy.joinRoomViaReturnValue = .success(())
clientProxy.joinRoomAliasReturnValue = .success(())
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))
@@ -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 {
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)
XCTAssertNotNil(detailCoordinator)
}
@@ -86,14 +86,14 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
XCTAssertTrue((tabCoordinator?.sheetCoordinator as? NavigationStackCoordinator)?.rootCoordinator is SettingsScreenCoordinator)
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))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
}
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.")
XCTAssertTrue(detailNavigationStack.rootCoordinator is RoomScreenCoordinator)
XCTAssertNotNil(detailCoordinator)
@@ -121,14 +121,14 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
}
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)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .mediaFiles(roomID: "2", mediaFiles: [.init(url: .picturesDirectory, suggestedName: nil)])
try await process(route: .share(sharePayload),
expectedChatsState: .roomList(roomListSelectedRoomID: "2"))
expectedChatsState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
@@ -149,14 +149,14 @@ class UserSessionFlowCoordinatorTests: XCTestCase {
}
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)
XCTAssertNil(tabCoordinator?.sheetCoordinator)
XCTAssertNil(chatsSplitCoordinator?.sheetCoordinator)
let sharePayload: ShareExtensionPayload = .text(roomID: "2", text: "Important text")
try await process(route: .share(sharePayload),
expectedChatsState: .roomList(roomListSelectedRoomID: "2"))
expectedChatsState: .roomList(detailState: .room(roomID: "2")))
XCTAssertTrue(detailNavigationStack?.rootCoordinator is RoomScreenCoordinator)
XCTAssertNil(tabCoordinator?.sheetCoordinator)