Basic implementation of the UI, navigation wil be implemented in the next PR

This commit is contained in:
Mauro Romito
2025-10-29 18:59:45 +01:00
committed by Mauro
parent dfd757f2b6
commit b4f3e0c860
36 changed files with 250 additions and 135 deletions
@@ -87,7 +87,8 @@ final class SpaceSettingsFlowCoordinator: FlowCoordinatorProtocol {
}
private func presentSpaceSettings(animated: Bool) {
let coordinator = SpaceSettingsScreenCoordinator(parameters: .init())
let coordinator = SpaceSettingsScreenCoordinator(parameters: .init(roomProxy: roomProxy,
userSession: flowParameters.userSession))
coordinator.actionsPublisher.sink { [weak self] action in
switch action { }
+9 -3
View File
@@ -208,6 +208,8 @@ internal enum L10n {
internal static var actionForward: String { return L10n.tr("Localizable", "action_forward") }
/// Go back
internal static var actionGoBack: String { return L10n.tr("Localizable", "action_go_back") }
/// Go to roles & permissions
internal static var actionGoToRolesAndPermissions: String { return L10n.tr("Localizable", "action_go_to_roles_and_permissions") }
/// Go to settings
internal static var actionGoToSettings: String { return L10n.tr("Localizable", "action_go_to_settings") }
/// Ignore
@@ -390,8 +392,6 @@ internal enum L10n {
internal static var commonAnImage: String { return L10n.tr("Localizable", "common_an_image") }
/// Analytics
internal static var commonAnalytics: String { return L10n.tr("Localizable", "common_analytics") }
/// Fetching notifications...
internal static var commonAndroidNotificationSyncNotificationsForegroundServiceTitle: String { return L10n.tr("Localizable", "common_android_notification_sync_notifications_foreground_service_title") }
/// You left the room
internal static var commonAndroidShortcutsRemoveReasonLeftRoom: String { return L10n.tr("Localizable", "common_android_shortcuts_remove_reason_left_room") }
/// You were logged out of the session
@@ -2464,7 +2464,7 @@ internal enum L10n {
internal static func screenRoomMemberDetailsVerifyButtonTitle(_ p1: Any) -> String {
return L10n.tr("Localizable", "screen_room_member_details_verify_button_title", String(describing: p1))
}
/// There are no banned users in this room.
/// There are no banned users.
internal static var screenRoomMemberListBannedEmpty: String { return L10n.tr("Localizable", "screen_room_member_list_banned_empty") }
/// Plural format key: "%#@COUNT@"
internal static func screenRoomMemberListHeaderTitle(_ p1: Int) -> String {
@@ -2964,6 +2964,12 @@ internal enum L10n {
internal static var screenSpaceListTitle: String { return L10n.tr("Localizable", "screen_space_list_title") }
/// View members
internal static var screenSpaceMenuActionMembers: String { return L10n.tr("Localizable", "screen_space_menu_action_members") }
/// Leave space
internal static var screenSpaceSettingsLeaveSpace: String { return L10n.tr("Localizable", "screen_space_settings_leave_space") }
/// Roles & permissions
internal static var screenSpaceSettingsRolesAndPermissions: String { return L10n.tr("Localizable", "screen_space_settings_roles_and_permissions") }
/// Security & privacy
internal static var screenSpaceSettingsSecurityAndPrivacy: String { return L10n.tr("Localizable", "screen_space_settings_security_and_privacy") }
/// An error occurred when trying to start a chat
internal static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") }
/// Join room by address
+2 -1
View File
@@ -134,6 +134,7 @@ enum UserAvatarSizeOnScreen {
enum RoomAvatarSizeOnScreen {
case chats
case spaces
case spaceSettings
case timeline
case leaveSpace
case messageForwarding
@@ -148,7 +149,7 @@ enum RoomAvatarSizeOnScreen {
var value: CGFloat {
switch self {
case .chats, .spaces:
case .chats, .spaces, .spaceSettings:
return 52
case .timeline, .leaveSpace:
return 32
@@ -45,5 +45,6 @@ enum SpaceScreenViewAction {
case toggleLeaveSpaceRoomDetails(id: String)
case confirmLeaveSpace
case spaceSettings
case rolesAndPermissions
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
}
@@ -76,19 +76,7 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
appSettings.$spaceSettingsEnabled
.combineLatest(roomProxy.infoPublisher)
.sink { [weak self] isEnabled, info in
guard let self else { return }
guard isEnabled, let powerLevels = info.powerLevels else {
state.isSpaceManagementEnabled = false
return
}
state.isSpaceManagementEnabled = powerLevels.canOwnUserEditRolesAndPermissions() ||
powerLevels.canOwnUser(sendStateEvent: .roomName) ||
powerLevels.canOwnUser(sendStateEvent: .roomTopic) ||
powerLevels.canOwnUser(sendStateEvent: .roomAvatar)
}
.weakAssign(to: \.state.isSpaceManagementEnabled, on: self)
.store(in: &cancellables)
}
}
@@ -141,6 +129,8 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
fatalError("Always available when the space settings button is tapped.")
}
actionsSubject.send(.displaySpaceSettings(roomProxy: roomProxy))
case .rolesAndPermissions:
break // Not implemented yet
}
}
@@ -99,9 +99,9 @@ struct LeaveSpaceView: View {
.buttonStyle(.compound(.primary))
} else if context.viewState.isSpaceManagementEnabled {
Button {
context.send(viewAction: .spaceSettings)
context.send(viewAction: .rolesAndPermissions)
} label: {
Label(L10n.actionGoToSettings, icon: \.settings)
Label(L10n.actionGoToRolesAndPermissions, icon: \.settings)
}
.buttonStyle(.compound(.primary))
}
@@ -8,7 +8,10 @@
import Combine
import SwiftUI
struct SpaceSettingsScreenCoordinatorParameters { }
struct SpaceSettingsScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
let userSession: UserSessionProtocol
}
enum SpaceSettingsScreenCoordinatorAction { }
@@ -26,7 +29,8 @@ final class SpaceSettingsScreenCoordinator: CoordinatorProtocol {
init(parameters: SpaceSettingsScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SpaceSettingsScreenViewModel()
viewModel = SpaceSettingsScreenViewModel(roomProxy: parameters.roomProxy,
userSession: parameters.userSession)
}
func start() {
@@ -10,23 +10,18 @@ import Foundation
enum SpaceSettingsScreenViewModelAction { }
struct SpaceSettingsScreenViewState: BindableState {
var title: String
var placeholder: String
var counter = 0
var details: RoomDetails
var bindings: SpaceSettingsScreenViewStateBindings
}
struct SpaceSettingsScreenViewStateBindings {
var composerText: String
var joinedMembersCount: Int
var hasMemberIdentityVerificationStateViolations = false
var canEditRolesOrPermissions = false
}
enum SpaceSettingsScreenViewAction {
case done
case textChanged
case incrementCounter
case decrementCounter
// Consider adding CustomStringConvertible conformance if the actions contain PII
case processTapEdit
case processTapSecurity
case processTapPeople
case processTapRolesAndPermissions
case processTapLeave
}
@@ -11,15 +11,25 @@ import SwiftUI
typealias SpaceSettingsScreenViewModelType = StateStoreViewModelV2<SpaceSettingsScreenViewState, SpaceSettingsScreenViewAction>
class SpaceSettingsScreenViewModel: SpaceSettingsScreenViewModelType, SpaceSettingsScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userSession: UserSessionProtocol
private let actionsSubject: PassthroughSubject<SpaceSettingsScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceSettingsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init() {
super.init(initialViewState: SpaceSettingsScreenViewState(title: "SpaceSettings title",
placeholder: "Enter something here",
bindings: .init(composerText: "Initial composer text")))
init(roomProxy: JoinedRoomProxyProtocol, userSession: UserSessionProtocol) {
self.roomProxy = roomProxy
self.userSession = userSession
super.init(initialViewState: .init(details: roomProxy.details,
joinedMembersCount: roomProxy.infoPublisher.value.joinedMembersCount),
mediaProvider: userSession.mediaProvider)
updateRoomInfo(roomProxy.infoPublisher.value)
setupRoomSubscription()
Task { await roomProxy.updateMembers() }
}
// MARK: - Public
@@ -28,20 +38,58 @@ class SpaceSettingsScreenViewModel: SpaceSettingsScreenViewModelType, SpaceSetti
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .done:
case .processTapEdit:
break
case .processTapSecurity:
break
case .processTapPeople:
break
case .processTapRolesAndPermissions:
break
case .processTapLeave:
break
case .textChanged:
MXLog.info("View model: composer text changed to: \(state.bindings.composerText)")
case .incrementCounter:
Task {
try await Task.sleep(for: .seconds(.random(in: 1.0...2.0)))
state.counter += 1
}
case .decrementCounter:
Task {
try await Task.sleep(for: .seconds(.random(in: 1.0...2.0)))
state.counter -= 1
}
}
}
private func updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
state.joinedMembersCount = roomInfo.joinedMembersCount
state.details = roomProxy.details
if let powerLevels = roomInfo.powerLevels {
state.canEditRolesOrPermissions = powerLevels.canOwnUserEditRolesAndPermissions()
}
}
private func setupRoomSubscription() {
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] roomInfo in
self?.updateRoomInfo(roomInfo)
}
.store(in: &cancellables)
roomProxy.membersPublisher.combineLatest(roomProxy.identityStatusChangesPublisher)
.sink { [weak self] _ in
Task { await self?.updateMemberIdentityVerificationStates() }
}
.store(in: &cancellables)
}
private func updateMemberIdentityVerificationStates() async {
guard roomProxy.infoPublisher.value.isEncrypted else {
// We don't care about identity statuses on non-encrypted rooms
return
}
for member in roomProxy.membersPublisher.value {
if case let .success(userIdentity) = await userSession.clientProxy.userIdentity(for: member.userID) {
if userIdentity?.verificationState == .verificationViolation {
state.hasMemberIdentityVerificationStateViolations = true
return
}
}
}
state.hasMemberIdentityVerificationStateViolations = false
}
}
@@ -13,59 +13,112 @@ struct SpaceSettingsScreen: View {
var body: some View {
Form {
Section {
ListRow(label: .plain(title: context.viewState.placeholder),
kind: .textField(text: $context.composerText))
ListRow(label: .centeredAction(title: L10n.actionDone,
icon: \.leave),
kind: .button { context.send(viewAction: .done) })
}
Section {
ListRow(label: .default(title: "Counter", icon: \.chart),
details: .counter(context.viewState.counter),
kind: .label)
ListRow(label: .default(title: "Increment", icon: \.plus),
kind: .button { context.send(viewAction: .incrementCounter) })
ListRow(label: .default(title: "Decrement", icon: \.minus),
kind: .button { context.send(viewAction: .decrementCounter) })
editSection
if context.viewState.canEditRolesOrPermissions {
securitySection
}
peopleSection
leaveSpaceSection
}
.compoundList()
.navigationTitle(context.viewState.title)
.onChange(of: context.composerText) {
context.send(viewAction: .textChanged)
.navigationTitle(L10n.commonSettings)
}
private var editSection: some View {
Section {
ListRow(kind: .custom {
Button {
context.send(viewAction: .processTapEdit)
} label: {
editSectionContent
}
})
}
}
private var editSectionContent: some View {
HStack(spacing: 12) {
RoomAvatarImage(avatar: context.viewState.details.avatar,
avatarSize: .room(on: .spaceSettings),
mediaProvider: context.mediaProvider)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(context.viewState.details.name ?? context.viewState.details.id)
.lineLimit(1)
.font(.compound.headingMD)
.foregroundStyle(.compound.textPrimary)
if let alias = context.viewState.details.canonicalAlias {
Text(alias)
.lineLimit(1)
.font(.compound.bodySM)
.foregroundStyle(.compound.textSecondary)
}
}
Spacer()
ListRowAccessory.navigationLink
}
.padding(.horizontal, ListRowPadding.horizontal)
.padding(.vertical, 16)
}
private var securitySection: some View {
Section {
ListRow(label: .default(title: L10n.screenSpaceSettingsSecurityAndPrivacy, icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .processTapSecurity)
})
}
}
private var peopleSection: some View {
Section {
if context.viewState.hasMemberIdentityVerificationStateViolations {
ListRow(label: .default(title: L10n.commonPeople, icon: \.user),
details: .icon(CompoundIcon(\.infoSolid).foregroundStyle(.compound.iconCriticalPrimary)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
} else {
ListRow(label: .default(title: L10n.commonPeople, icon: \.user),
details: .title(String(context.viewState.joinedMembersCount)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
}
if context.viewState.canEditRolesOrPermissions {
ListRow(label: .default(title: L10n.screenSpaceSettingsRolesAndPermissions, icon: \.admin),
kind: .navigationLink {
context.send(viewAction: .processTapRolesAndPermissions)
})
}
}
}
private var leaveSpaceSection: some View {
ListRow(label: .action(title: L10n.screenSpaceSettingsLeaveSpace,
icon: \.leave,
role: .destructive),
kind: .button { context.send(viewAction: .processTapLeave) })
}
}
// MARK: - Previews
struct SpaceSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static let incrementedViewModel = makeViewModel(counterValue: 1)
static let viewModel = SpaceSettingsScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Space",
avatarURL: .mockMXCAvatar,
isSpace: true,
canonicalAlias: "#space:matrix.org",
members: .allMembersAsCreator)),
userSession: UserSessionMock(.init()))
static var previews: some View {
NavigationStack {
SpaceSettingsScreen(context: viewModel.context)
}
.previewDisplayName("Initial")
NavigationStack {
SpaceSettingsScreen(context: incrementedViewModel.context)
}
.previewDisplayName("Incremented")
.snapshotPreferences(expect: incrementedViewModel.context.observe(\.viewState.counter).map { $0 == 1 }.eraseToStream())
}
static func makeViewModel(counterValue: Int = 0) -> SpaceSettingsScreenViewModel {
let viewModel = SpaceSettingsScreenViewModel()
for _ in 0..<counterValue {
viewModel.context.send(viewAction: .incrementCounter)
}
return viewModel
}
}