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()