Joining a space from the room flow. (#4563)

* Add the More menu to SpaceScreen.

And a simple way to leave a space for testing.

* Start a space flow when accepting a space invite from the room flow.

* Minimise the tab bar on iOS 26 when scrolling down.

* Enable spaces by default!
This commit is contained in:
Doug
2025-10-02 10:58:38 +01:00
committed by GitHub
parent 3215f8c269
commit 621b72f162
13 changed files with 128 additions and 29 deletions
@@ -307,6 +307,7 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
.toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar)
}
}
.backportTabBarMinimizeBehaviorOnScrollDown()
.introspect(.tabView, on: .supportedVersions, customize: configureAppearance)
.sheet(item: $navigationTabCoordinator.sheetModule) { module in
module.coordinator?.toPresentable()
@@ -381,7 +381,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.threadsEnabled, defaultValue: false, storageType: .userDefaults(store))
var threadsEnabled
@UserPreference(key: UserDefaultsKeys.spacesEnabled, defaultValue: false, storageType: .userDefaults(store))
@UserPreference(key: UserDefaultsKeys.spacesEnabled, defaultValue: true, storageType: .userDefaults(store))
var spacesEnabled
@UserPreference(key: UserDefaultsKeys.nextGenHTMLParserEnabled, defaultValue: true, storageType: .userDefaults(store))
@@ -469,6 +469,8 @@ class ChatsFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.showCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.sessionVerification(.userInitiator(userID: userID)))
case .continueWithSpaceFlow(let spaceRoomListProxy):
stateMachine.processEvent(.startSpaceFlow, userInfo: .init(animated: false, spaceRoomListProxy: spaceRoomListProxy))
case .finished:
stateMachine.processEvent(.deselectRoom)
}
@@ -13,6 +13,9 @@ import UserNotifications
enum RoomFlowCoordinatorAction: Equatable {
case presentCallScreen(roomProxy: JoinedRoomProxyProtocol)
case verifyUser(userID: String)
/// The requested room was actually a space. The room flow has been dismissed
/// and a space flow should be started to continue.
case continueWithSpaceFlow(SpaceRoomListProxyProtocol)
case finished
static func == (lhs: RoomFlowCoordinatorAction, rhs: RoomFlowCoordinatorAction) -> Bool {
@@ -385,6 +388,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
presentJoinRoomScreen(via: via, animated: true)
case (_, .dismissJoinRoomScreen, .complete):
dismissFlow(animated: animated)
case (_, .joinedSpace, .complete):
guard let spaceRoomListProxy = (context.userInfo as? EventUserInfo)?.spaceRoomListProxy else {
fatalError("The space room list proxy is required to present a space.")
}
dismissFlow(animated: animated, continuingWith: spaceRoomListProxy)
case (.joinRoomScreen, .presentDeclineAndBlockScreen(let userID), .declineAndBlockScreen):
presentDeclineAndBlockScreen(userID: userID)
@@ -720,8 +728,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.dismissFlow, userInfo: EventUserInfo(animated: animated))
}
}
case .joined(.space):
#warning("The space flow should be shown here.")
case .joined(.space(let spaceRoomListProxy)):
stateMachine.tryEvent(.joinedSpace, userInfo: EventUserInfo(animated: true, spaceRoomListProxy: spaceRoomListProxy))
case .cancelled:
stateMachine.tryEvent(.dismissJoinRoomScreen)
case .presentDeclineAndBlock(let userID):
@@ -745,7 +753,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func dismissFlow(animated: Bool) {
private func dismissFlow(animated: Bool, continuingWith spaceRoomListProxy: SpaceRoomListProxyProtocol? = nil) {
childRoomFlowCoordinator?.clearRoute(animated: animated)
if isChildFlow {
@@ -757,12 +765,21 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
} else {
navigationStackCoordinator.popToRoot(animated: false)
navigationStackCoordinator.setRootCoordinator(nil, animated: false)
// Leave the root alone when it is about to be replaced by the space flow, otherwise when running on
// iPhone the compact module diffs call the dismissal callback and we present a blank space flow 🙈
if spaceRoomListProxy == nil {
navigationStackCoordinator.setRootCoordinator(nil, animated: false)
}
}
timelineController = nil
actionsSubject.send(.finished)
if let spaceRoomListProxy {
actionsSubject.send(.continueWithSpaceFlow(spaceRoomListProxy))
} else {
actionsSubject.send(.finished)
}
flowParameters.analytics.signpost.endRoomFlow()
}
@@ -1457,6 +1474,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .continueWithSpaceFlow(let spaceRoomListProxy):
#warning("Present the space as a child.")
case .finished:
stateMachine.tryEvent(.dismissChildFlow)
}
@@ -84,11 +84,13 @@ extension RoomFlowCoordinator {
struct EventUserInfo {
let animated: Bool
var timelineController: TimelineControllerProtocol?
var spaceRoomListProxy: SpaceRoomListProxyProtocol?
}
enum Event: EventType {
case presentJoinRoomScreen(via: [String])
case dismissJoinRoomScreen
case joinedSpace
case presentRoom(presentationAction: PresentationAction?)
case dismissFlow
@@ -321,6 +323,8 @@ extension RoomFlowCoordinator {
return .joinRoomScreen
case (_, .dismissJoinRoomScreen):
return .complete
case (_, .joinedSpace):
return .complete
case (.joinRoomScreen, .presentDeclineAndBlockScreen):
return .declineAndBlockScreen
@@ -52,6 +52,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
case presentingChild(childSpaceID: String, previousState: State)
/// A room flow is in progress
case roomFlow(previousState: State)
case leftSpace
}
enum Event: EventType {
@@ -62,6 +64,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
/// The join space screen joined the space.
case joinedSpace
/// The space screen left the space.
case leftSpace
/// Request the presentation of a child space flow.
///
@@ -116,7 +120,7 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
switch stateMachine.state {
case .initial:
break
case .joinSpace, .space:
case .joinSpace, .space, .leftSpace:
if isChildFlow {
navigationStackCoordinator.pop(animated: animated)
} else {
@@ -145,11 +149,18 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.addRoutes(event: .joinedSpace, transitions: [.joinSpace => .space]) { [weak self] _ in
self?.presentSpaceAfterJoining()
}
stateMachine.addRoutes(event: .leftSpace, transitions: [.space => .leftSpace]) { [weak self] _ in
self?.clearRoute(animated: true)
}
stateMachine.addRouteMapping { event, fromState, userInfo in
guard event == .startChildFlow, case .space = fromState else { return nil }
guard event == .startChildFlow else { return nil }
guard let childEntryPoint = userInfo as? SpaceFlowCoordinatorEntryPoint else { fatalError("An entry point must be provided.") }
return .presentingChild(childSpaceID: childEntryPoint.spaceID, previousState: fromState)
return switch fromState {
case .space: .presentingChild(childSpaceID: childEntryPoint.spaceID, previousState: fromState)
case .roomFlow(let previousState): .presentingChild(childSpaceID: childEntryPoint.spaceID, previousState: previousState)
default: nil
}
} handler: { [weak self] context in
guard let self, let entryPoint = context.userInfo as? SpaceFlowCoordinatorEntryPoint else { return }
startChildFlow(with: entryPoint)
@@ -205,6 +216,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.startChildFlow, userInfo: SpaceFlowCoordinatorEntryPoint.joinSpace(spaceRoomProxy))
case .selectRoom(let roomID):
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
case .leftSpace:
stateMachine.tryEvent(.leftSpace)
}
}
.store(in: &cancellables)
@@ -314,6 +327,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .continueWithSpaceFlow(let spaceRoomListProxy):
stateMachine.tryEvent(.startChildFlow, userInfo: SpaceFlowCoordinatorEntryPoint.space(spaceRoomListProxy))
case .finished:
stateMachine.tryEvent(.stopRoomFlow)
}
@@ -0,0 +1,19 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import SwiftUI
extension View {
@ViewBuilder
func backportTabBarMinimizeBehaviorOnScrollDown() -> some View {
if #available(iOS 26.0, *) {
tabBarMinimizeBehavior(.onScrollDown)
} else {
self
}
}
}
@@ -22,6 +22,7 @@ enum SpaceScreenCoordinatorAction {
case selectSpace(SpaceRoomListProxyProtocol)
case selectUnjoinedSpace(SpaceRoomProxyProtocol)
case selectRoom(roomID: String)
case leftSpace
}
final class SpaceScreenCoordinator: CoordinatorProtocol {
@@ -57,6 +58,8 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.selectUnjoinedSpace(spaceRoomProxy))
case .selectRoom(let roomID):
actionsSubject.send(.selectRoom(roomID: roomID))
case .leftSpace:
actionsSubject.send(.leftSpace)
}
}
.store(in: &cancellables)
@@ -11,11 +11,14 @@ enum SpaceScreenViewModelAction {
case selectSpace(SpaceRoomListProxyProtocol)
case selectUnjoinedSpace(SpaceRoomProxyProtocol)
case selectRoom(roomID: String)
case leftSpace
}
struct SpaceScreenViewState: BindableState {
let space: SpaceRoomProxyProtocol
var permalink: URL?
var isPaginating = false
var rooms: [SpaceRoomProxyProtocol]
var selectedSpaceRoomID: String?
@@ -30,4 +33,5 @@ struct SpaceScreenViewStateBindings { }
enum SpaceScreenViewAction {
case spaceAction(SpaceRoomCell.Action)
case leaveSpace
}
@@ -60,6 +60,13 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
selectedSpaceRoomPublisher
.weakAssign(to: \.state.selectedSpaceRoomID, on: self)
.store(in: &cancellables)
Task {
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.spaceRoomProxy.id),
case let .success(permalinkURL) = await roomProxy.matrixToPermalink() {
state.permalink = permalinkURL
}
}
}
// MARK: - Public
@@ -81,6 +88,15 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
case .spaceAction(.join(let spaceRoomProxy)):
Task { await join(spaceRoomProxy) }
case .leaveSpace:
#if DEBUG
Task { // Temporary implementation to make joining a space easier to test.
if case let .joined(roomProxy) = await clientProxy.roomForIdentifier(spaceRoomListProxy.spaceRoomProxy.id),
case .success = await roomProxy.leaveRoom() {
actionsSubject.send(.leftSpace)
}
}
#endif
}
}
@@ -42,6 +42,7 @@ struct SpaceScreen: View {
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
// Use the same trick as the RoomScreen for a leading title view that
// also hides the navigation title.
@@ -50,6 +51,22 @@ struct SpaceScreen: View {
roomAvatar: context.viewState.space.avatar,
mediaProvider: context.mediaProvider)
}
ToolbarItemGroup(placement: .secondaryAction) {
if let permalink = context.viewState.permalink {
Section {
ShareLink(item: permalink) {
Label(L10n.actionShare, icon: \.shareIos)
}
}
}
Section {
Button(role: .destructive) { context.send(viewAction: .leaveSpace) } label: {
Label(L10n.actionLeaveSpace, icon: \.leave)
}
}
}
}
}