Allow joined rooms to be pushed within a space. (#4460)

* Allow joined rooms to be pushed within a space.

* Push a room in the space flow tests.

Also fixes some snapshots stale snapshots.

* Show the selected space/room within a space and set a custom title view.
This commit is contained in:
Doug
2025-09-03 17:48:49 +01:00
committed by GitHub
parent 8d069fb74c
commit 039084966a
28 changed files with 170 additions and 44 deletions

View File

@@ -9,8 +9,10 @@ import Combine
import Foundation
import SwiftState
enum SpaceExplorerFlowCoordinatorAction: Equatable {
enum SpaceExplorerFlowCoordinatorAction {
case showSettings
case presentCallScreen(roomProxy: JoinedRoomProxyProtocol)
case verifyUser(userID: String)
}
class SpaceExplorerFlowCoordinator: FlowCoordinatorProtocol {
@@ -113,7 +115,7 @@ class SpaceExplorerFlowCoordinator: FlowCoordinatorProtocol {
private func presentSpaceList() {
let parameters = SpaceListScreenCoordinatorParameters(userSession: userSession,
selectedSpaceSubject: selectedSpaceSubject.asCurrentValuePublisher(),
selectedSpacePublisher: selectedSpaceSubject.asCurrentValuePublisher(),
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = SpaceListScreenCoordinator(parameters: parameters)
coordinator.actionsPublisher
@@ -143,6 +145,10 @@ class SpaceExplorerFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .finished:
stateMachine.tryEvent(.deselectSpace)
}

View File

@@ -9,7 +9,9 @@ import Combine
import Foundation
import SwiftState
enum SpaceFlowCoordinatorAction: Equatable {
enum SpaceFlowCoordinatorAction {
case presentCallScreen(roomProxy: JoinedRoomProxyProtocol)
case verifyUser(userID: String)
case finished
}
@@ -20,17 +22,22 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator
private var childSpaceFlowCoordinator: SpaceFlowCoordinator?
private let flowParameters: CommonFlowParameters
private let selectedSpaceRoomSubject: CurrentValueSubject<String?, Never> = .init(nil)
private var childSpaceFlowCoordinator: SpaceFlowCoordinator?
private var roomFlowCoordinator: RoomFlowCoordinator?
indirect enum State: StateType {
/// The state machine hasn't started.
case initial
/// The root screen for this flow.
case space
/// A child flow is in progress.
/// A child (space) flow is in progress.
case presentingChild(childSpaceID: String, previousState: State)
/// A room flow is in progress
case roomFlow(previousState: State)
}
enum Event: EventType {
@@ -43,6 +50,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
case startChildFlow
/// Tidy-up the child flow after it has dismissed itself.
case stopChildFlow
case startRoomFlow(roomID: String)
case stopRoomFlow
}
private let stateMachine: StateMachine<State, Event>
@@ -91,6 +101,9 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
case .presentingChild:
childSpaceFlowCoordinator?.clearRoute(animated: animated)
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
case .roomFlow:
roomFlowCoordinator?.clearRoute(animated: animated)
clearRoute(animated: animated) // Re-run with the state machine back in the .space state.
}
}
@@ -116,6 +129,24 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
} handler: { [weak self] _ in
guard let self else { return }
childSpaceFlowCoordinator = nil
selectedSpaceRoomSubject.send(nil)
}
stateMachine.addRouteMapping { event, fromState, _ in
guard case .startRoomFlow = event, case .space = fromState else { return nil }
return .roomFlow(previousState: fromState)
} handler: { [weak self] context in
guard let self, case let .startRoomFlow(roomID) = context.event else { return }
startRoomFlow(roomID: roomID)
}
stateMachine.addRouteMapping { event, fromState, _ in
guard event == .stopRoomFlow, case let .roomFlow(previousState) = fromState else { return nil }
return previousState
} handler: { [weak self] _ in
guard let self else { return }
roomFlowCoordinator = nil
selectedSpaceRoomSubject.send(nil)
}
stateMachine.addErrorHandler { context in
@@ -126,6 +157,7 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
private func presentSpace() {
let parameters = SpaceScreenCoordinatorParameters(spaceRoomListProxy: spaceRoomListProxy,
spaceServiceProxy: spaceServiceProxy,
selectedSpaceRoomPublisher: selectedSpaceRoomSubject.asCurrentValuePublisher(),
mediaProvider: flowParameters.userSession.mediaProvider,
userIndicatorController: flowParameters.userIndicatorController)
let coordinator = SpaceScreenCoordinator(parameters: parameters)
@@ -135,6 +167,8 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .selectSpace(let spaceRoomListProxy):
stateMachine.tryEvent(.startChildFlow, userInfo: spaceRoomListProxy)
case .selectRoom(let roomID):
stateMachine.tryEvent(.startRoomFlow(roomID: roomID))
}
}
.store(in: &cancellables)
@@ -164,6 +198,10 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
guard let self else { return }
switch action {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .finished:
stateMachine.tryEvent(.stopChildFlow)
}
@@ -172,5 +210,31 @@ class SpaceFlowCoordinator: FlowCoordinatorProtocol {
childSpaceFlowCoordinator = coordinator
coordinator.start()
selectedSpaceRoomSubject.send(spaceRoomListProxy.spaceRoomProxy.id)
}
private func startRoomFlow(roomID: String) {
let coordinator = RoomFlowCoordinator(roomID: roomID,
isChildFlow: true,
navigationStackCoordinator: navigationStackCoordinator,
flowParameters: flowParameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentCallScreen(let roomProxy):
actionsSubject.send(.presentCallScreen(roomProxy: roomProxy))
case .verifyUser(let userID):
actionsSubject.send(.verifyUser(userID: userID))
case .finished:
stateMachine.tryEvent(.stopRoomFlow)
}
}
.store(in: &cancellables)
roomFlowCoordinator = coordinator
coordinator.handleAppRoute(.room(roomID: roomID, via: []), animated: true)
selectedSpaceRoomSubject.send(roomID)
}
}

View File

@@ -217,6 +217,10 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
.sink { [weak self] action in
guard let self else { return }
switch action {
case .presentCallScreen(let roomProxy):
presentCallScreen(roomProxy: roomProxy)
case .verifyUser(let userID):
presentSessionVerificationScreen(flow: .userInitiator(userID: userID))
case .showSettings:
stateMachine.tryEvent(.showSettingsScreen)
}

View File

@@ -14,6 +14,7 @@ struct ClientProxyMockConfiguration {
var userID: String = RoomMemberProxyMock.mockMe.userID
var deviceID: String?
var roomSummaryProvider: RoomSummaryProviderProtocol = RoomSummaryProviderMock(.init())
var joinedSpaceRooms: [SpaceRoomProxyProtocol] = []
var roomDirectorySearchProxy: RoomDirectorySearchProxyProtocol?
var recoveryState: SecureBackupRecoveryState = .enabled
@@ -92,11 +93,13 @@ extension ClientProxyMock {
spaceService = SpaceServiceProxyMock(.init())
roomForIdentifierClosure = { [weak self] identifier in
guard let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) else {
return nil
if let room = self?.roomSummaryProvider.roomListPublisher.value.first(where: { $0.id == identifier }) {
await .joined(JoinedRoomProxyMock(.init(id: room.id, name: room.name)))
} else if let spaceRoom = configuration.joinedSpaceRooms.first(where: { $0.id == identifier }) {
await .joined(JoinedRoomProxyMock(.init(id: spaceRoom.id, name: spaceRoom.name)))
} else {
nil
}
return await .joined(JoinedRoomProxyMock(.init(id: room.id, name: room.name)))
}
userIdentityForReturnValue = .success(UserIdentityProxyMock(configuration: .init()))

View File

@@ -104,7 +104,8 @@ extension [SpaceRoomProxyProtocol] {
name: "Management",
isSpace: false,
joinedMembersCount: 12,
topic: "This is where everything gets organised 📋."))]
topic: "This is where everything gets organised 📋.",
state: .joined))]
}
private static func makeSpaceRooms(isSpace: Bool) -> [SpaceRoomProxyMock] {

View File

@@ -12,7 +12,7 @@ import SwiftUI
struct SpaceListScreenCoordinatorParameters {
let userSession: UserSessionProtocol
let selectedSpaceSubject: CurrentValuePublisher<String?, Never>
let selectedSpacePublisher: CurrentValuePublisher<String?, Never>
let userIndicatorController: UserIndicatorControllerProtocol
}
@@ -36,7 +36,7 @@ final class SpaceListScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = SpaceListScreenViewModel(userSession: parameters.userSession,
selectedSpaceSubject: parameters.selectedSpaceSubject,
selectedSpacePublisher: parameters.selectedSpacePublisher,
userIndicatorController: parameters.userIndicatorController)
}

View File

@@ -20,7 +20,7 @@ class SpaceListScreenViewModel: SpaceListScreenViewModelType, SpaceListScreenVie
}
init(userSession: UserSessionProtocol,
selectedSpaceSubject: CurrentValuePublisher<String?, Never>,
selectedSpacePublisher: CurrentValuePublisher<String?, Never>,
userIndicatorController: UserIndicatorControllerProtocol) {
spaceServiceProxy = userSession.clientProxy.spaceService
self.userIndicatorController = userIndicatorController
@@ -35,7 +35,7 @@ class SpaceListScreenViewModel: SpaceListScreenViewModelType, SpaceListScreenVie
.weakAssign(to: \.state.joinedSpaces, on: self)
.store(in: &cancellables)
selectedSpaceSubject
selectedSpacePublisher
.weakAssign(to: \.state.selectedSpaceID, on: self)
.store(in: &cancellables)

View File

@@ -107,7 +107,7 @@ struct SpaceListScreen_Previews: PreviewProvider, TestablePreview {
clientProxy.spaceService = SpaceServiceProxyMock(.init(joinedSpaces: .mockJoinedSpaces))
let viewModel = SpaceListScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
selectedSpaceSubject: .init(nil),
selectedSpacePublisher: .init(nil),
userIndicatorController: UserIndicatorControllerMock())
return viewModel

View File

@@ -13,12 +13,14 @@ import SwiftUI
struct SpaceScreenCoordinatorParameters {
let spaceRoomListProxy: SpaceRoomListProxyProtocol
let spaceServiceProxy: SpaceServiceProxyProtocol
let selectedSpaceRoomPublisher: CurrentValuePublisher<String?, Never>
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum SpaceScreenCoordinatorAction {
case selectSpace(SpaceRoomListProxyProtocol)
case selectRoom(roomID: String)
}
final class SpaceScreenCoordinator: CoordinatorProtocol {
@@ -37,6 +39,7 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
viewModel = SpaceScreenViewModel(spaceRoomListProxy: parameters.spaceRoomListProxy,
spaceServiceProxy: parameters.spaceServiceProxy,
selectedSpaceRoomPublisher: parameters.selectedSpaceRoomPublisher,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
}
@@ -49,6 +52,8 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
switch action {
case .selectSpace(let spaceRoomListProxy):
actionsSubject.send(.selectSpace(spaceRoomListProxy))
case .selectRoom(let roomID):
actionsSubject.send(.selectRoom(roomID: roomID))
}
}
.store(in: &cancellables)

View File

@@ -9,6 +9,7 @@ import Foundation
enum SpaceScreenViewModelAction {
case selectSpace(SpaceRoomListProxyProtocol)
case selectRoom(roomID: String)
}
struct SpaceScreenViewState: BindableState {
@@ -16,8 +17,11 @@ struct SpaceScreenViewState: BindableState {
var isPaginating = false
var rooms: [SpaceRoomProxyProtocol]
var selectedSpaceRoomID: String?
var bindings = SpaceScreenViewStateBindings()
var spaceName: String { space.name ?? L10n.commonSpace }
}
struct SpaceScreenViewStateBindings { }

View File

@@ -21,13 +21,15 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
init(spaceRoomListProxy: SpaceRoomListProxyProtocol,
spaceServiceProxy: SpaceServiceProxyProtocol,
selectedSpaceRoomPublisher: CurrentValuePublisher<String?, Never>,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.spaceServiceProxy = spaceServiceProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: SpaceScreenViewState(space: spaceRoomListProxy.spaceRoomProxy,
rooms: spaceRoomListProxy.spaceRoomsPublisher.value),
rooms: spaceRoomListProxy.spaceRoomsPublisher.value,
selectedSpaceRoomID: selectedSpaceRoomPublisher.value),
mediaProvider: mediaProvider)
spaceRoomListProxy.spaceRoomsPublisher
@@ -50,6 +52,10 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
}
.store(in: &cancellables)
selectedSpaceRoomPublisher
.weakAssign(to: \.state.selectedSpaceRoomID, on: self)
.store(in: &cancellables)
}
// MARK: - Public
@@ -61,8 +67,10 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
case .spaceAction(.select(let spaceRoomProxy)):
if spaceRoomProxy.isSpace {
Task { await selectSpace(spaceRoomProxy) }
} else {
#warning("Implement room flow")
} else if spaceRoomProxy.state == .joined {
// This probably doesn't need the state condition as the room flow will show a join screen,
// but we can allow this later, once we've updated the design to indicate the parent space.
actionsSubject.send(.selectRoom(roomID: spaceRoomProxy.id))
}
case .spaceAction(.join(let spaceID)):
#warning("Implement joining.")

View File

@@ -19,16 +19,17 @@ struct SpaceScreen: View {
rooms
}
}
.navigationTitle(context.viewState.space.name ?? L10n.commonSpace)
.navigationBarTitleDisplayMode(.inline)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.navigationTitle(context.viewState.spaceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
@ViewBuilder
var rooms: some View {
ForEach(context.viewState.rooms, id: \.id) { spaceRoomProxy in
SpaceRoomCell(spaceRoomProxy: spaceRoomProxy,
isSelected: false,
isSelected: spaceRoomProxy.id == context.viewState.selectedSpaceRoomID,
mediaProvider: context.mediaProvider) { action in
context.send(viewAction: .spaceAction(action))
}
@@ -39,6 +40,16 @@ struct SpaceScreen: View {
.padding()
}
}
var toolbar: some ToolbarContent {
// Use the same trick as the RoomScreen for a leading title view that
// also hides the navigation title.
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: context.viewState.spaceName,
roomAvatar: context.viewState.space.avatar,
mediaProvider: context.mediaProvider)
}
}
}
// MARK: - Previews
@@ -66,6 +77,7 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
let viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy,
spaceServiceProxy: SpaceServiceProxyMock(.init()),
selectedSpaceRoomPublisher: .init(nil),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
return viewModel

View File

@@ -571,7 +571,10 @@ class MockScreen: Identifiable {
appSettings.analyticsConsentState = .optedOut
appSettings.spacesEnabled = true
let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com", deviceID: "MOCKCLIENT", roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms)))))
let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com",
deviceID: "MOCKCLIENT",
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))),
joinedSpaceRooms: .mockSingleRoom))
// The tab bar remains hidden for the non-spaces tests as we don't supply any mock spaces.
let spaceServiceProxy = SpaceServiceProxyMock(id == .userSessionSpacesFlow ? .populated : .init())

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a9a4c1899fffb9c101f87eeeb2413c962f72daddb2bdf10b23c1aa8267a6707
size 241775
oid sha256:aa43fefcbe0406d7c84752152b5f4ad6951175fcc98a9fb48b0f6cad24d0f012
size 242481

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4debd3d362dfdcec1eebbade1159b3cbbafad46e2daacb1511ad2d08c847926e
size 279363
oid sha256:949fa7494236c24c00438329892fb75750eb08b310bf1c4e56e918e8a68feec4
size 280034

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e1cac5e788e68553c910fcf9d10213c025c593763e72e448db359b2a50f06bd0
size 186591
oid sha256:80f8c4e7b7c90e6f6ef159c7642aaed70f93f4788686d39e6518f20b98e7f5ab
size 187209

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:90a7af41076c73f8fc3671d6402bdc9e93e23ea592c7efa6647242c44541a900
size 206847
oid sha256:75524c40db1a8c622b3a37cde5ac93c022b44f807e5d18b986b906fe6e47d2e8
size 207423

View File

@@ -12,6 +12,7 @@ class UserSessionScreenTests: XCTestCase {
let firstRoomName = "Foundation 🔭🪐🌌"
let firstSpaceName = "The Foundation"
let firstSubspaceName = "Company Space"
let firstSubspaceRoomName = "Management"
enum Step {
static let homeScreen = 1
@@ -21,6 +22,7 @@ class UserSessionScreenTests: XCTestCase {
static let spaceList = 5
static let spaceScreen = 6
static let subspaceScreen = 7
static let subspaceRoomScreen = 8
}
func testUserSessionFlows() async throws {
@@ -69,6 +71,8 @@ class UserSessionScreenTests: XCTestCase {
func testSpaceExploration() async throws {
let app = Application.launch(.userSessionSpacesFlow)
app.swipeDown() // Make sure the header shows a large title
try await app.assertScreenshot(step: Step.spacesTabBar)
// app.tabBars doesn't work on iPadOS 18 😐
@@ -85,5 +89,10 @@ class UserSessionScreenTests: XCTestCase {
XCTAssert(app.staticTexts[firstSubspaceName].waitForExistence(timeout: 5.0))
try await Task.sleep(for: .seconds(1))
try await app.assertScreenshot(step: Step.subspaceScreen)
app.buttons[A11yIdentifiers.spaceListScreen.spaceRoomName(firstSubspaceRoomName)].tap()
XCTAssert(app.staticTexts[firstSubspaceRoomName].waitForExistence(timeout: 5.0))
try await Task.sleep(for: .seconds(1))
try await app.assertScreenshot(step: Step.subspaceRoomScreen)
}
}

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dee57958e736136e2598db20aae58486bf68d56352c68097319e3f4e2cea6dd9
size 452748
oid sha256:ccfe2cd51a3d032410862ded6a179f8cf4987f9c27ad7cdfaa4c0a2748c3b1f5
size 452759

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:63044eae82b5caf0e61eaa7e65f2fff30e2dca8fe282ce7c7fd2e4edd562811d
size 373691
oid sha256:22815607d190c5027b125360a511384eccbc8c997516e0a349d38b316d37502d
size 374015

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d16db9f4d2cab604bc19a76585202586df21749d48da09c8e969c2624637ccf
size 277095
oid sha256:ef257a3d7f03a0eaee4e4354c847c90cbc9d0f9d5c52767d1d49d200232337b0
size 274101

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b71980e1f2646b261902d4ba50240ee92c99d7e6287a3392ccfbaaf074a20ace
size 477038

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8f3668e91005046ed8ee295d34c4f160a1501e427337c6bf96cdcdf34aa83c65
size 484663
oid sha256:01ef09ce91a3b76182fdf9ea27ac181d5a1584f5dac6b2826d7fc00cfdbbc86d
size 509366

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d966e5361b6c386668228ee436c1fbf4a2b53065169120aaca669ac485736c5f
size 402376
oid sha256:cd6229bda0aba7184a460f5156157f13a09d346688ea1cb3b252584919e6b3d5
size 293006

View File

@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10c0b735d009691762e8719ff2dc3c01ce8e1c984e080caebe322336a6d378bd
size 117024
oid sha256:869be3a8f6ed1705de9fa6d37b6ce0a2e143a6f53a5b439f7cf67e93cf2d082f
size 109237

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a62d21a23c9c6bdfeb73fbe24cff464aaf41d9b0252ec68130788ca660f2de9
size 324914

View File

@@ -76,7 +76,7 @@ class SpaceListScreenViewModelTests: XCTestCase {
clientProxy.spaceService = spaceServiceProxy
viewModel = SpaceListScreenViewModel(userSession: userSession,
selectedSpaceSubject: .init(nil),
selectedSpacePublisher: .init(nil),
userIndicatorController: UserIndicatorControllerMock())
}
}

View File

@@ -117,6 +117,7 @@ class SpaceScreenViewModelTests: XCTestCase {
viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy,
spaceServiceProxy: spaceServiceProxy,
selectedSpaceRoomPublisher: .init(nil),
mediaProvider: MediaProviderMock(configuration: .init()),
userIndicatorController: UserIndicatorControllerMock())
}