Implement the flow for leaving a space. (#4568)

This commit is contained in:
Doug
2025-10-03 16:37:09 +01:00
committed by GitHub
parent 6377d9be67
commit a226c99492
36 changed files with 933 additions and 42 deletions
@@ -104,6 +104,7 @@ class SpaceExplorerFlowCoordinator: FlowCoordinatorProtocol {
return .spaceList(selectedSpaceID: nil)
} handler: { [weak self] _ in
guard let self else { return }
navigationSplitCoordinator.setDetailCoordinator(nil) // If we forget to do this, the tab bar remains hidden.
selectedSpaceSubject.send(nil)
spaceFlowCoordinator = nil
}
+12 -11
View File
@@ -242,6 +242,8 @@ internal enum L10n {
internal static var actionManageDevices: String { return L10n.tr("Localizable", "action_manage_devices") }
/// Message
internal static var actionMessage: String { return L10n.tr("Localizable", "action_message") }
/// Minimise
internal static var actionMinimize: String { return L10n.tr("Localizable", "action_minimize") }
/// Next
internal static var actionNext: String { return L10n.tr("Localizable", "action_next") }
/// No
@@ -336,6 +338,8 @@ internal enum L10n {
internal static var actionTryAgain: String { return L10n.tr("Localizable", "action_try_again") }
/// Unpin
internal static var actionUnpin: String { return L10n.tr("Localizable", "action_unpin") }
/// View
internal static var actionView: String { return L10n.tr("Localizable", "action_view") }
/// View in timeline
internal static var actionViewInTimeline: String { return L10n.tr("Localizable", "action_view_in_timeline") }
/// View source
@@ -484,6 +488,8 @@ internal enum L10n {
internal static var commonInviteUnknownProfile: String { return L10n.tr("Localizable", "common_invite_unknown_profile") }
/// Leaving room
internal static var commonLeavingRoom: String { return L10n.tr("Localizable", "common_leaving_room") }
/// Leaving space
internal static var commonLeavingSpace: String { return L10n.tr("Localizable", "common_leaving_space") }
/// Light
internal static var commonLight: String { return L10n.tr("Localizable", "common_light") }
/// Line copied to clipboard
@@ -1808,18 +1814,18 @@ internal enum L10n {
internal static var screenKnockRequestsListInitialLoadingTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_initial_loading_title") }
/// Requests to join
internal static var screenKnockRequestsListTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_title") }
/// (Admin)
internal static var screenLeaveSpaceLastAdminInfo: String { return L10n.tr("Localizable", "screen_leave_space_last_admin_info") }
/// %1$@ (Admin)
internal static func screenLeaveSpaceLastAdminInfo(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_leave_space_last_admin_info", String(describing: p1))
}
/// Plural format key: "%#@COUNT@"
internal static func screenLeaveSpaceSubmit(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_leave_space_submit", p1)
}
/// Select the rooms you’d like to leave which you're not the only administrator for:
internal static var screenLeaveSpaceSubtitle: String { return L10n.tr("Localizable", "screen_leave_space_subtitle") }
/// Plural format key: "%#@COUNT@"
internal static func screenLeaveSpaceSubtitleOnlyLastAdmin(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_leave_space_subtitle_only_last_admin", p1)
}
/// You will not be removed from the following room(s) because you're the only administrator:
internal static var screenLeaveSpaceSubtitleOnlyLastAdmin: String { return L10n.tr("Localizable", "screen_leave_space_subtitle_only_last_admin") }
/// Leave %1$@?
internal static func screenLeaveSpaceTitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_leave_space_title", String(describing: p1))
@@ -3340,11 +3346,6 @@ internal enum L10n {
internal static var yourAvatar: String { return L10n.tr("Localizable", "a11y.your_avatar") }
}
internal enum Action {
/// View
internal static var view: String { return L10n.tr("Localizable", "action.view") }
}
internal enum Common {
/// Add an account
internal static var addAccount: String { return L10n.tr("Localizable", "common.add_account") }
@@ -16111,6 +16111,76 @@ class SpaceServiceProxyMock: SpaceServiceProxyProtocol, @unchecked Sendable {
return spaceRoomListSpaceIDParentReturnValue
}
}
//MARK: - leaveSpace
var leaveSpaceSpaceIDUnderlyingCallsCount = 0
var leaveSpaceSpaceIDCallsCount: Int {
get {
if Thread.isMainThread {
return leaveSpaceSpaceIDUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = leaveSpaceSpaceIDUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
leaveSpaceSpaceIDUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
leaveSpaceSpaceIDUnderlyingCallsCount = newValue
}
}
}
}
var leaveSpaceSpaceIDCalled: Bool {
return leaveSpaceSpaceIDCallsCount > 0
}
var leaveSpaceSpaceIDReceivedSpaceID: String?
var leaveSpaceSpaceIDReceivedInvocations: [String] = []
var leaveSpaceSpaceIDUnderlyingReturnValue: Result<LeaveSpaceHandleProxy, SpaceServiceProxyError>!
var leaveSpaceSpaceIDReturnValue: Result<LeaveSpaceHandleProxy, SpaceServiceProxyError>! {
get {
if Thread.isMainThread {
return leaveSpaceSpaceIDUnderlyingReturnValue
} else {
var returnValue: Result<LeaveSpaceHandleProxy, SpaceServiceProxyError>? = nil
DispatchQueue.main.sync {
returnValue = leaveSpaceSpaceIDUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
leaveSpaceSpaceIDUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
leaveSpaceSpaceIDUnderlyingReturnValue = newValue
}
}
}
}
var leaveSpaceSpaceIDClosure: ((String) async -> Result<LeaveSpaceHandleProxy, SpaceServiceProxyError>)?
func leaveSpace(spaceID: String) async -> Result<LeaveSpaceHandleProxy, SpaceServiceProxyError> {
leaveSpaceSpaceIDCallsCount += 1
leaveSpaceSpaceIDReceivedSpaceID = spaceID
DispatchQueue.main.async {
self.leaveSpaceSpaceIDReceivedInvocations.append(spaceID)
}
if let leaveSpaceSpaceIDClosure = leaveSpaceSpaceIDClosure {
return await leaveSpaceSpaceIDClosure(spaceID)
} else {
return leaveSpaceSpaceIDReturnValue
}
}
}
class StaticRoomSummaryProviderMock: StaticRoomSummaryProviderProtocol, @unchecked Sendable {
var roomListPublisher: CurrentValuePublisher<[RoomSummary], Never> {
@@ -10135,6 +10135,128 @@ open class LazyTimelineItemProviderSDKMock: MatrixRustSDK.LazyTimelineItemProvid
}
}
}
open class LeaveSpaceHandleSDKMock: MatrixRustSDK.LeaveSpaceHandle, @unchecked Sendable {
init() {
super.init(noPointer: .init())
}
public required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) {
fatalError("init(unsafeFromRawPointer:) has not been implemented")
}
fileprivate var pointer: UnsafeMutableRawPointer!
//MARK: - leave
open var leaveRoomIdsThrowableError: Error?
var leaveRoomIdsUnderlyingCallsCount = 0
open var leaveRoomIdsCallsCount: Int {
get {
if Thread.isMainThread {
return leaveRoomIdsUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = leaveRoomIdsUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
leaveRoomIdsUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
leaveRoomIdsUnderlyingCallsCount = newValue
}
}
}
}
open var leaveRoomIdsCalled: Bool {
return leaveRoomIdsCallsCount > 0
}
open var leaveRoomIdsReceivedRoomIds: [String]?
open var leaveRoomIdsReceivedInvocations: [[String]] = []
open var leaveRoomIdsClosure: (([String]) async throws -> Void)?
open override func leave(roomIds: [String]) async throws {
if let error = leaveRoomIdsThrowableError {
throw error
}
leaveRoomIdsCallsCount += 1
leaveRoomIdsReceivedRoomIds = roomIds
DispatchQueue.main.async {
self.leaveRoomIdsReceivedInvocations.append(roomIds)
}
try await leaveRoomIdsClosure?(roomIds)
}
//MARK: - rooms
var roomsUnderlyingCallsCount = 0
open var roomsCallsCount: Int {
get {
if Thread.isMainThread {
return roomsUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = roomsUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomsUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
roomsUnderlyingCallsCount = newValue
}
}
}
}
open var roomsCalled: Bool {
return roomsCallsCount > 0
}
var roomsUnderlyingReturnValue: [LeaveSpaceRoom]!
open var roomsReturnValue: [LeaveSpaceRoom]! {
get {
if Thread.isMainThread {
return roomsUnderlyingReturnValue
} else {
var returnValue: [LeaveSpaceRoom]? = nil
DispatchQueue.main.sync {
returnValue = roomsUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
roomsUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
roomsUnderlyingReturnValue = newValue
}
}
}
}
open var roomsClosure: (() -> [LeaveSpaceRoom])?
open override func rooms() -> [LeaveSpaceRoom] {
roomsCallsCount += 1
if let roomsClosure = roomsClosure {
return roomsClosure()
} else {
return roomsReturnValue
}
}
}
open class MediaFileHandleSDKMock: MatrixRustSDK.MediaFileHandle, @unchecked Sendable {
init() {
super.init(noPointer: .init())
@@ -22227,6 +22349,81 @@ open class SpaceServiceSDKMock: MatrixRustSDK.SpaceService, @unchecked Sendable
}
}
//MARK: - leaveSpace
open var leaveSpaceSpaceIdThrowableError: Error?
var leaveSpaceSpaceIdUnderlyingCallsCount = 0
open var leaveSpaceSpaceIdCallsCount: Int {
get {
if Thread.isMainThread {
return leaveSpaceSpaceIdUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = leaveSpaceSpaceIdUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
leaveSpaceSpaceIdUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
leaveSpaceSpaceIdUnderlyingCallsCount = newValue
}
}
}
}
open var leaveSpaceSpaceIdCalled: Bool {
return leaveSpaceSpaceIdCallsCount > 0
}
open var leaveSpaceSpaceIdReceivedSpaceId: String?
open var leaveSpaceSpaceIdReceivedInvocations: [String] = []
var leaveSpaceSpaceIdUnderlyingReturnValue: LeaveSpaceHandle!
open var leaveSpaceSpaceIdReturnValue: LeaveSpaceHandle! {
get {
if Thread.isMainThread {
return leaveSpaceSpaceIdUnderlyingReturnValue
} else {
var returnValue: LeaveSpaceHandle? = nil
DispatchQueue.main.sync {
returnValue = leaveSpaceSpaceIdUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
leaveSpaceSpaceIdUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
leaveSpaceSpaceIdUnderlyingReturnValue = newValue
}
}
}
}
open var leaveSpaceSpaceIdClosure: ((String) async throws -> LeaveSpaceHandle)?
open override func leaveSpace(spaceId: String) async throws -> LeaveSpaceHandle {
if let error = leaveSpaceSpaceIdThrowableError {
throw error
}
leaveSpaceSpaceIdCallsCount += 1
leaveSpaceSpaceIdReceivedSpaceId = spaceId
DispatchQueue.main.async {
self.leaveSpaceSpaceIdReceivedInvocations.append(spaceId)
}
if let leaveSpaceSpaceIdClosure = leaveSpaceSpaceIdClosure {
return try await leaveSpaceSpaceIdClosure(spaceId)
} else {
return leaveSpaceSpaceIdReturnValue
}
}
//MARK: - spaceRoomList
open var spaceRoomListSpaceIdThrowableError: Error?
@@ -0,0 +1,142 @@
//
// 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 Foundation
import MatrixRustSDK
extension LeaveSpaceHandleSDKMock {
struct Configuration {
var rooms: [LeaveSpaceRoom] = .mockRooms
}
convenience init(_ configuration: Configuration) {
self.init()
roomsClosure = { configuration.rooms }
}
}
extension [LeaveSpaceRoom] {
static var mockAdminRooms: [LeaveSpaceRoom] {
mockRooms.filter(\.isLastAdmin)
}
static var mockRooms: [LeaveSpaceRoom] {
[
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "1",
name: "Lighting",
avatarURL: .mockMXCAvatar,
isSpace: false,
memberCount: 10,
joinRule: .public),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "2",
name: "Sound",
isSpace: false,
memberCount: 20,
joinRule: .private),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "3",
name: "Set & Costume",
isSpace: false,
memberCount: 25,
joinRule: .restricted(rules: [])),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "4",
name: "The Theatre",
isSpace: true,
memberCount: 100,
joinRule: .private,
childrenCount: 20),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "5",
name: "Bookings",
isSpace: false,
memberCount: 200,
joinRule: .private,
childrenCount: 0),
isLastAdmin: true),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "6",
name: "Events",
isSpace: false,
memberCount: 65,
joinRule: .restricted(rules: []),
childrenCount: 0),
isLastAdmin: true),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "7",
name: "Mario Kart",
isSpace: false,
memberCount: 123,
joinRule: .public),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "8",
name: "Tetris",
isSpace: false,
memberCount: 95,
joinRule: .public),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "9",
name: "Minecraft",
isSpace: false,
memberCount: 39,
joinRule: .public),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "10",
name: "Lemmings",
isSpace: false,
memberCount: 67,
joinRule: .public),
isLastAdmin: true),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "11",
name: "Rayman",
isSpace: false,
memberCount: 23,
joinRule: .public),
isLastAdmin: false),
LeaveSpaceRoom(spaceRoom: SpaceRoom(id: "12",
name: "Gaming",
avatarURL: .mockMXCAvatar,
isSpace: true,
memberCount: 835,
joinRule: .public,
childrenCount: 15),
isLastAdmin: true)
]
}
}
private extension SpaceRoom {
init(id: String,
canonicalAlias: String? = nil,
name: String,
topic: String? = nil,
avatarURL: URL? = nil,
isSpace: Bool,
memberCount: UInt64,
joinRule: JoinRule? = .public,
isDirect: Bool? = false,
childrenCount: UInt64 = 0,
membership: Membership? = .joined,
heroes: [RoomHero]? = [],
via: [String] = []) {
self.init(roomId: id,
canonicalAlias: canonicalAlias,
name: name,
topic: topic,
avatarUrl: avatarURL?.absoluteString,
roomType: isSpace ? .space : .room,
numJoinedMembers: memberCount,
joinRule: joinRule,
worldReadable: true,
guestCanJoin: false,
isDirect: isDirect,
childrenCount: childrenCount,
state: membership,
heroes: heroes,
via: via)
}
}
@@ -13,6 +13,7 @@ extension SpaceServiceProxyMock {
struct Configuration {
var joinedSpaces: [SpaceRoomProxyProtocol] = []
var spaceRoomLists: [String: SpaceRoomListProxyMock] = [:]
var leaveSpaceRooms: [LeaveSpaceRoom] = []
}
convenience init(_ configuration: Configuration) {
@@ -26,6 +27,10 @@ extension SpaceServiceProxyMock {
.failure(.sdkError(ClientProxyMockError.generic))
}
}
leaveSpaceSpaceIDClosure = { spaceID in
.success(LeaveSpaceHandleProxy(spaceID: spaceID,
leaveHandle: LeaveSpaceHandleSDKMock(.init(rooms: configuration.leaveSpaceRooms))))
}
}
}
+2 -1
View File
@@ -134,6 +134,7 @@ enum RoomAvatarSizeOnScreen {
case chats
case spaces
case timeline
case leaveSpace
case messageForwarding
case globalSearch
case roomSelection
@@ -148,7 +149,7 @@ enum RoomAvatarSizeOnScreen {
switch self {
case .chats, .spaces:
return 52
case .timeline:
case .timeline, .leaveSpace:
return 32
case .notificationSettings:
return 30
@@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
extension View {
@@ -55,6 +56,18 @@ extension View {
}
}
@ViewBuilder
func backportSafeAreaBar(edge: VerticalEdge,
alignment: HorizontalAlignment = .center,
spacing: CGFloat? = nil,
content: () -> some View) -> some View {
if #available(iOS 26.0, *) {
safeAreaBar(edge: edge, alignment: alignment, spacing: spacing, content: content)
} else {
safeAreaInset(edge: edge, alignment: alignment, spacing: spacing) { content().background(Color.compound.bgCanvasDefault.ignoresSafeArea()) }
}
}
@ViewBuilder func backportScrollEdgeEffectHidden() -> some View {
if #available(iOS 26, *) {
scrollEdgeEffectHidden()
@@ -68,6 +68,7 @@ enum TestablePreviewsDictionary {
"KnockRequestsBannerView_Previews" : KnockRequestsBannerView_Previews.self,
"KnockRequestsListEmptyStateView_Previews" : KnockRequestsListEmptyStateView_Previews.self,
"KnockRequestsListScreen_Previews" : KnockRequestsListScreen_Previews.self,
"LeaveSpaceView_Previews" : LeaveSpaceView_Previews.self,
"LegalInformationScreen_Previews" : LegalInformationScreen_Previews.self,
"LoadableImage_Previews" : LoadableImage_Previews.self,
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
@@ -29,9 +29,14 @@ struct SpaceScreenViewState: BindableState {
var spaceName: String { space.name ?? L10n.commonSpace }
}
struct SpaceScreenViewStateBindings { }
struct SpaceScreenViewStateBindings {
var leaveHandle: LeaveSpaceHandleProxy?
}
enum SpaceScreenViewAction {
case spaceAction(SpaceRoomCell.Action)
case leaveSpace
case deselectAllLeaveRoomDetails
case toggleLeaveSpaceRoomDetails(id: String)
case confirmLeaveSpace
}
@@ -89,14 +89,21 @@ 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)
}
Task { await showLeaveSpaceConfirmation() }
case .deselectAllLeaveRoomDetails:
guard let leaveHandle = state.bindings.leaveHandle else { fatalError("The leave handle should be available.") }
for room in leaveHandle.rooms {
room.isSelected = false
}
#endif
case .toggleLeaveSpaceRoomDetails(let spaceRoomID):
guard let room = state.bindings.leaveHandle?.rooms.first(where: { $0.spaceRoomProxy.id == spaceRoomID }) else {
fatalError("The space room to toggle is not in the list of rooms to leave.")
}
withTransaction(\.disablesAnimations, true) { // The button is adding an unwanted animation.
room.isSelected.toggle()
}
case .confirmLeaveSpace:
Task { await confirmLeaveSpace() }
}
}
@@ -136,10 +143,45 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
}
private func showLeaveSpaceConfirmation() async {
guard case let .success(leaveHandle) = await spaceServiceProxy.leaveSpace(spaceID: spaceRoomListProxy.spaceRoomProxy.id) else {
showFailureIndicator()
return
}
state.bindings.leaveHandle = leaveHandle
}
private func confirmLeaveSpace() async {
guard let leaveHandle = state.bindings.leaveHandle else { fatalError("Leaving without a handle is impossible.") }
showLeavingIndicator()
defer { hideLeavingIndicator() }
switch await leaveHandle.leave() {
case .success:
state.bindings.leaveHandle = nil
actionsSubject.send(.leftSpace)
case .failure:
showFailureIndicator()
}
}
// MARK: - Indicators
private static var leavingIndicatorID: String { "\(Self.self)-Leaving" }
private static var failureIndicatorID: String { "\(Self.self)-Failure" }
private func showLeavingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.leavingIndicatorID,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false),
title: L10n.commonLeavingSpace))
}
private func hideLeavingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.leavingIndicatorID)
}
private func showFailureIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.failureIndicatorID,
type: .toast,
@@ -0,0 +1,233 @@
//
// 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 Compound
import SwiftUI
struct LeaveSpaceView: View {
let context: SpaceScreenViewModel.Context
@State private var scrollViewHeight: CGFloat = .zero
@State private var buttonsHeight: CGFloat = .zero
private let topPadding = 19.0
var body: some View {
ScrollView {
VStack(spacing: 0) {
header
rooms
}
.readHeight($scrollViewHeight)
}
.backportSafeAreaBar(edge: .bottom, spacing: 0) {
buttons
.readHeight($buttonsHeight)
}
.scrollBounceBehavior(.basedOnSize)
.padding(.top, topPadding) // For the drag indicator
.presentationDetents([.height(scrollViewHeight + buttonsHeight + topPadding)])
.presentationDragIndicator(.visible)
.presentationBackground(.compound.bgCanvasDefault)
}
var header: some View {
VStack(spacing: 16) {
BigIcon(icon: \.errorSolid, style: .alertSolid)
VStack(spacing: 8) {
Text(L10n.screenLeaveSpaceTitle(context.viewState.spaceName))
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
switch context.leaveHandle?.mode {
case .manyRooms:
Text(L10n.screenLeaveSpaceSubtitle)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
case .onlyAdminRooms:
Text(L10n.screenLeaveSpaceSubtitleOnlyLastAdmin)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
case .noRooms, nil:
EmptyView()
}
}
}
.padding(24)
}
@ViewBuilder
var rooms: some View {
if let leaveRooms = context.leaveHandle?.rooms, !leaveRooms.isEmpty {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
Section {
ForEach(leaveRooms, id: \.spaceRoomProxy.id) { room in
LeaveSpaceRoomDetailsCell(room: room, mediaProvider: context.mediaProvider) {
context.send(viewAction: .toggleLeaveSpaceRoomDetails(id: room.spaceRoomProxy.id))
}
.disabled(room.isLastAdmin)
}
} header: {
Button(L10n.commonDeselectAll) {
context.send(viewAction: .deselectAllLeaveRoomDetails)
}
.buttonStyle(.compound(.textLink, size: .small))
.frame(maxWidth: .infinity, alignment: .trailing)
.padding(.horizontal, 16)
.padding(.bottom, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
}
}
}
}
var buttons: some View {
VStack(spacing: 16) {
Button(role: .destructive) {
context.send(viewAction: .confirmLeaveSpace)
} label: {
Label(context.leaveHandle?.confirmationTitle ?? L10n.actionLeaveSpace, icon: \.leave)
}
.buttonStyle(.compound(.primary))
Button(L10n.actionCancel) {
context.leaveHandle = nil
}
.buttonStyle(.compound(.tertiary))
}
.padding(.horizontal, 16)
.padding(.top, 16)
}
}
struct LeaveSpaceRoomDetailsCell: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
let room: LeaveSpaceRoomDetails
let mediaProvider: MediaProviderProtocol?
let action: () -> Void
private var subtitle: String? {
guard !room.spaceRoomProxy.isSpace else { return nil }
let memberCount = L10n.commonMemberCount(room.spaceRoomProxy.joinedMembersCount)
return room.isLastAdmin ? L10n.screenLeaveSpaceLastAdminInfo(memberCount) : memberCount
}
var visibilityIcon: KeyPath<CompoundIcons, Image>? {
switch room.spaceRoomProxy.visibility {
case .public: \.public
case .private: \.lockSolid
case .restricted: nil
case .none: \.lockSolid
}
}
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
if dynamicTypeSize < .accessibility3 {
RoomAvatarImage(avatar: room.spaceRoomProxy.avatar,
avatarSize: .room(on: .leaveSpace),
mediaProvider: mediaProvider)
}
VStack(alignment: .leading, spacing: 0) {
Text(room.spaceRoomProxy.computedName)
.font(.compound.bodyLGSemibold)
.foregroundStyle(.compound.textPrimary)
.lineLimit(1)
.padding(.vertical, 1)
.padding(.vertical, subtitle == nil ? 10 : 0)
subtitleLabel
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
ListRowAccessory.multiSelection(room.isSelected)
}
.padding(.horizontal, 16)
}
.buttonStyle(SpaceRoomCellButtonStyle(isSelected: false))
}
@ViewBuilder
private var subtitleLabel: some View {
if let subtitle {
Label {
Text(subtitle)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.lineLimit(1)
.padding(.vertical, 1)
} icon: {
if let visibilityIcon {
CompoundIcon(visibilityIcon,
size: .xSmall,
relativeTo: .compound.bodyMD)
.foregroundStyle(.compound.iconTertiary)
}
}
.labelStyle(.custom(spacing: 4))
}
}
}
// MARK: - Previews
import MatrixRustSDK
struct LeaveSpaceView_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel(mode: .manyRooms)
static let onlyAdminRoomsViewModel = makeViewModel(mode: .onlyAdminRooms)
static let noRoomsViewModel = makeViewModel(mode: .noRooms)
static var previews: some View {
LeaveSpaceView(context: viewModel.context)
.previewDisplayName("Many Rooms")
.snapshotPreferences(expect: viewModel.context.observe(\.leaveHandle).map { $0 != nil }.eraseToStream())
LeaveSpaceView(context: onlyAdminRoomsViewModel.context)
.previewDisplayName("Only Admin Rooms")
.snapshotPreferences(expect: viewModel.context.observe(\.leaveHandle).map { $0 != nil }.eraseToStream())
LeaveSpaceView(context: noRoomsViewModel.context)
.previewDisplayName("No Rooms")
.snapshotPreferences(expect: viewModel.context.observe(\.leaveHandle).map { $0 != nil }.eraseToStream())
}
static func makeViewModel(mode: LeaveSpaceHandleProxy.Mode) -> SpaceScreenViewModel {
let spaceRoomProxy = SpaceRoomProxyMock(.init(id: "!eng-space:matrix.org",
name: "Engineering Team",
isSpace: true,
parent: SpaceRoomProxyMock(.init(name: "MegaGroup", isSpace: true)),
childrenCount: 30,
joinedMembersCount: 76,
heroes: [.mockDan, .mockBob, .mockCharlie, .mockVerbose],
topic: "Description of the space goes right here. Lorem ipsum dolor sit amet consectetur. Leo viverra morbi habitant in.",
joinRule: .knockRestricted(rules: [.roomMembership(roomId: "")])))
let spaceRoomListProxy = SpaceRoomListProxyMock(.init(spaceRoomProxy: spaceRoomProxy,
initialSpaceRooms: .mockSpaceList))
let rooms: [LeaveSpaceRoom] = switch mode {
case .manyRooms: .mockRooms
case .onlyAdminRooms: .mockAdminRooms
case .noRooms: []
}
let spaceServiceProxy = SpaceServiceProxyMock(.init(leaveSpaceRooms: rooms))
let viewModel = SpaceScreenViewModel(spaceRoomListProxy: spaceRoomListProxy,
spaceServiceProxy: spaceServiceProxy,
selectedSpaceRoomPublisher: .init(nil),
userSession: UserSessionMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
viewModel.context.send(viewAction: .leaveSpace)
return viewModel
}
}
@@ -23,6 +23,9 @@ struct SpaceScreen: View {
.navigationTitle(context.viewState.spaceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.sheet(item: $context.leaveHandle) { _ in
LeaveSpaceView(context: context)
}
}
@ViewBuilder
@@ -0,0 +1,76 @@
//
// 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 Foundation
import MatrixRustSDK
class LeaveSpaceHandleProxy: Identifiable {
let id: String
let leaveHandle: LeaveSpaceHandleProtocol
var rooms: [LeaveSpaceRoomDetails]
enum Mode { case manyRooms, onlyAdminRooms, noRooms }
let mode: Mode
init(spaceID: String, leaveHandle: LeaveSpaceHandleProtocol) {
id = spaceID
self.leaveHandle = leaveHandle
rooms = leaveHandle.rooms()
.compactMap { room in
guard room.spaceRoom.state == .joined, // The SDK is going to do this but not yet.
room.spaceRoom.isDirect != true,
room.spaceRoom.roomId != spaceID else {
return nil
}
return .init(spaceRoomProxy: SpaceRoomProxy(spaceRoom: room.spaceRoom, parent: nil),
isLastAdmin: room.isLastAdmin,
isSelected: !room.isLastAdmin)
}
mode = if rooms.isEmpty {
.noRooms
} else if rooms.count(where: { !$0.isLastAdmin }) == 0 {
.onlyAdminRooms
} else {
.manyRooms
}
}
func leave() async -> Result<Void, SpaceServiceProxyError> {
let selectedRoomIDs = rooms.filter(\.isSelected).map(\.spaceRoomProxy.id)
do {
return try await .success(leaveHandle.leave(roomIds: selectedRoomIDs + [id]))
} catch {
MXLog.error("Failed leaving space \(id): \(error)")
rooms = rooms.filter { leaveRoom in
leaveHandle.rooms().contains { $0.spaceRoom.roomId == leaveRoom.spaceRoomProxy.id }
}
return .failure(.sdkError(error))
}
}
var selectedCount: Int { rooms.count { $0.isSelected } }
var confirmationTitle: String {
let selectedCount = selectedCount
return selectedCount > 0 ? L10n.screenLeaveSpaceSubmit(selectedCount) : L10n.actionLeaveSpace
}
}
@Observable class LeaveSpaceRoomDetails {
let spaceRoomProxy: SpaceRoomProxyProtocol
let isLastAdmin: Bool
var isSelected: Bool
init(spaceRoomProxy: SpaceRoomProxyProtocol, isLastAdmin: Bool, isSelected: Bool) {
self.spaceRoomProxy = spaceRoomProxy
self.isLastAdmin = isLastAdmin
self.isSelected = isSelected
}
}
@@ -40,6 +40,15 @@ class SpaceServiceProxy: SpaceServiceProxyProtocol {
}
}
func leaveSpace(spaceID: String) async -> Result<LeaveSpaceHandleProxy, SpaceServiceProxyError> {
do {
return try await .success(.init(spaceID: spaceID, leaveHandle: spaceService.leaveSpace(spaceId: spaceID)))
} catch {
MXLog.error("Failed to get leave handle for \(spaceID): \(error)")
return .failure(.sdkError(error))
}
}
// MARK: - Private
private func handleUpdates(_ updates: [SpaceListUpdate]) {
@@ -17,4 +17,5 @@ protocol SpaceServiceProxyProtocol {
var joinedSpacesPublisher: CurrentValuePublisher<[SpaceRoomProxyProtocol], Never> { get }
func spaceRoomList(spaceID: String, parent: SpaceRoomProxyProtocol?) async -> Result<SpaceRoomListProxyProtocol, SpaceServiceProxyError>
func leaveSpace(spaceID: String) async -> Result<LeaveSpaceHandleProxy, SpaceServiceProxyError>
}