SP4: space settings (#5730)

* SP4: Space Settings

- Space settings screen implemented
- No space upgrade available as per Element web
- Need more insights for the space address field
- Added settings live update
- Added local alias implementation
This commit is contained in:
Gil Eluard 2022-03-04 12:53:42 +01:00 committed by GitHub
parent 8be08afd2d
commit 1fcf96865c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 2184 additions and 118 deletions

View file

@ -1886,7 +1886,11 @@ Tap the + to start adding people.";
"spaces_add_space" = "Add space";
"space_public_join_rule_detail" = "Open to anyone, best for communities";
"space_topic" = "description";
"space_topic" = "Description";
"space_settings_access_section" = "Who can access this space?";
"space_settings_update_failed_message" = "Failed to update space settings. Do you want to retry?";
"space_settings_current_address_message" = "Your space is viewable at\n%@";
// Mark: - Space Creation

View file

@ -5543,11 +5543,23 @@ public class VectorL10n: NSObject {
public static var spacePublicJoinRuleDetail: String {
return VectorL10n.tr("Vector", "space_public_join_rule_detail")
}
/// Who can access this space?
public static var spaceSettingsAccessSection: String {
return VectorL10n.tr("Vector", "space_settings_access_section")
}
/// Your space is viewable at\n%@
public static func spaceSettingsCurrentAddressMessage(_ p1: String) -> String {
return VectorL10n.tr("Vector", "space_settings_current_address_message", p1)
}
/// Failed to update space settings. Do you want to retry?
public static var spaceSettingsUpdateFailedMessage: String {
return VectorL10n.tr("Vector", "space_settings_update_failed_message")
}
/// space
public static var spaceTag: String {
return VectorL10n.tr("Vector", "space_tag")
}
/// description
/// Description
public static var spaceTopic: String {
return VectorL10n.tr("Vector", "space_topic")
}

View file

@ -30,6 +30,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType {
private let room: MXRoom
private let parentSpaceId: String?
private let initialSection: RoomInfoSection
private let dismissOnCancel: Bool
private weak var roomSettingsViewController: RoomSettingsViewController?
private lazy var segmentedViewController: SegmentedViewController = {
@ -103,6 +104,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType {
self.room = parameters.room
self.parentSpaceId = parameters.parentSpaceId
self.initialSection = parameters.initialSection
self.dismissOnCancel = parameters.dismissOnCancel
}
// MARK: - Public methods
@ -226,6 +228,22 @@ extension RoomInfoCoordinator: RoomNotificationSettingsCoordinatorDelegate {
}
extension RoomInfoCoordinator: RoomSettingsViewControllerDelegate {
func roomSettingsViewControllerDidCancel(_ controller: RoomSettingsViewController!) {
if self.dismissOnCancel {
self.navigationRouter.dismissModule(animated: true, completion: nil)
} else {
controller.withdrawViewController(animated: true) {}
}
}
func roomSettingsViewControllerDidComplete(_ controller: RoomSettingsViewController!) {
if self.dismissOnCancel {
self.navigationRouter.dismissModule(animated: true, completion: nil)
} else {
controller.withdrawViewController(animated: true) {}
}
}
func roomSettingsViewController(_ controller: RoomSettingsViewController!, didReplaceRoomWithReplacementId newRoomId: String!) {
self.delegate?.roomInfoCoordinator(self, didReplaceRoomWithReplacementId: newRoomId)
}

View file

@ -32,16 +32,22 @@ class RoomInfoCoordinatorParameters: NSObject {
let room: MXRoom
let parentSpaceId: String?
let initialSection: RoomInfoSection
let dismissOnCancel: Bool
init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection) {
init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection, dismissOnCancel: Bool) {
self.session = session
self.room = room
self.parentSpaceId = parentSpaceId
self.initialSection = initialSection
self.dismissOnCancel = dismissOnCancel
super.init()
}
convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?) {
self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: .none)
self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: .none, dismissOnCancel: false)
}
convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection) {
self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: initialSection, dismissOnCancel: false)
}
}

View file

@ -71,4 +71,8 @@ typedef enum : NSUInteger {
- (void)roomSettingsViewController:(RoomSettingsViewController *)controller didReplaceRoomWithReplacementId:(NSString *)newRoomId;
- (void)roomSettingsViewControllerDidCancel:(RoomSettingsViewController *)controller;
- (void)roomSettingsViewControllerDidComplete:(RoomSettingsViewController *)controller;
@end

View file

@ -906,7 +906,14 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti
[self->updatedItemsDict removeAllObjects];
[self withdrawViewControllerAnimated:YES completion:nil];
if (self.delegate)
{
[self.delegate roomSettingsViewControllerDidCancel:self];
}
else
{
[self withdrawViewControllerAnimated:YES completion:nil];
}
}
}]];
@ -1435,7 +1442,14 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti
}
else
{
[self withdrawViewControllerAnimated:YES completion:nil];
if (self.delegate)
{
[self.delegate roomSettingsViewControllerDidCancel:self];
}
else
{
[self withdrawViewControllerAnimated:YES completion:nil];
}
}
}
@ -2183,7 +2197,14 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti
[self stopActivityIndicator];
[self withdrawViewControllerAnimated:YES completion:nil];
if (self.delegate)
{
[self.delegate roomSettingsViewControllerDidComplete:self];
}
else
{
[self withdrawViewControllerAnimated:YES completion:nil];
}
}
#pragma mark - UITableViewDataSource

View file

@ -66,6 +66,7 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType {
private var membersCoordinator: SpaceMembersCoordinator?
private var createSpaceCoordinator: SpaceCreationCoordinator?
private var createRoomCoordinator: CreateRoomCoordinator?
private var spaceSettingsCoordinator: Coordinator?
// MARK: Public
@ -297,10 +298,27 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType {
presentable.presentationController?.delegate = self
toPresentable().present(presentable, animated: true, completion: nil)
createRoomCoordinator.start()
self.add(childCoordinator: createRoomCoordinator)
self.createRoomCoordinator = createRoomCoordinator
}
@available(iOS 14.0, *)
private func showSpaceSettings(spaceId: String, session: MXSession) {
let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: spaceId))
coordinator.callback = { [weak self] result in
guard let self = self else { return }
coordinator.toPresentable().dismiss(animated: true) {
self.spaceSettingsCoordinator = nil
}
}
let presentable = coordinator.toPresentable()
presentable.presentationController?.delegate = self
toPresentable().present(presentable, animated: true, completion: nil)
coordinator.start()
self.spaceSettingsCoordinator = coordinator
}
// MARK: UserSessions management
private func registerUserSessionsServiceNotifications() {
@ -405,7 +423,11 @@ extension SideMenuCoordinator: SpaceMenuPresenterDelegate {
case .addSpace:
AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.spacesAddSpace, message: VectorL10n.spacesComingSoonDetail(AppInfo.current.displayName))
case .settings:
AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.sideMenuActionSettings, message: VectorL10n.spacesComingSoonDetail(AppInfo.current.displayName))
if #available(iOS 14.0, *) {
self.showSpaceSettings(spaceId: spaceId, session: session)
} else {
AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.settingsTitle, message: VectorL10n.spacesComingSoonDetail(AppInfo.current.displayName))
}
}
}
}
@ -448,7 +470,6 @@ extension SideMenuCoordinator: SpaceMembersCoordinatorDelegate {
extension SideMenuCoordinator: CreateRoomCoordinatorDelegate {
func createRoomCoordinator(_ coordinator: CreateRoomCoordinatorType, didCreateNewRoom room: MXRoom) {
coordinator.toPresentable().dismiss(animated: true) {
self.remove(childCoordinator: coordinator)
self.createRoomCoordinator = nil
self.parameters.appNavigator.sideMenu.dismiss(animated: true) {
@ -461,7 +482,6 @@ extension SideMenuCoordinator: CreateRoomCoordinatorDelegate {
func createRoomCoordinator(_ coordinator: CreateRoomCoordinatorType, didAddRoomsWithIds roomIds: [String]) {
coordinator.toPresentable().dismiss(animated: true) {
self.remove(childCoordinator: coordinator)
self.createRoomCoordinator = nil
self.parameters.appNavigator.sideMenu.dismiss(animated: true) {
@ -474,7 +494,6 @@ extension SideMenuCoordinator: CreateRoomCoordinatorDelegate {
func createRoomCoordinatorDidCancel(_ coordinator: CreateRoomCoordinatorType) {
coordinator.toPresentable().dismiss(animated: true) {
self.remove(childCoordinator: coordinator)
self.createRoomCoordinator = nil
}
}
@ -488,5 +507,6 @@ extension SideMenuCoordinator: UIAdaptivePresentationControllerDelegate {
self.membersCoordinator = nil
self.createSpaceCoordinator = nil
self.createRoomCoordinator = nil
self.spaceSettingsCoordinator = nil
}
}

View file

@ -24,6 +24,7 @@ struct SpaceMemberDetailCoordinatorParameters {
let member: MXRoomMember
let session: MXSession
let spaceId: String
let showCancelMenuItem: Bool
}
final class SpaceMemberDetailCoordinator: NSObject, SpaceMemberDetailCoordinatorType {
@ -49,7 +50,7 @@ final class SpaceMemberDetailCoordinator: NSObject, SpaceMemberDetailCoordinator
init(parameters: SpaceMemberDetailCoordinatorParameters) {
self.parameters = parameters
let spaceMemberDetailViewModel = SpaceMemberDetailViewModel(userSessionsService: parameters.userSessionsService, session: parameters.session, member: parameters.member, spaceId: parameters.spaceId)
let spaceMemberDetailViewModel = SpaceMemberDetailViewModel(userSessionsService: parameters.userSessionsService, session: parameters.session, member: parameters.member, spaceId: parameters.spaceId, showCancelMenuItem: parameters.showCancelMenuItem)
let spaceMemberDetailViewController = SpaceMemberDetailViewController.instantiate(with: spaceMemberDetailViewModel)
spaceMemberDetailViewController.enableMention = true
spaceMemberDetailViewController.enableVoipCall = false

View file

@ -106,11 +106,13 @@ final class SpaceMemberDetailViewController: RoomMemberDetailsViewController {
}
private func setupViews() {
let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in
self?.cancelButtonAction()
if viewModel.showCancelMenuItem {
let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in
self?.cancelButtonAction()
}
self.navigationItem.rightBarButtonItem = cancelBarButtonItem
}
self.navigationItem.rightBarButtonItem = cancelBarButtonItem
}
private func render(viewState: SpaceMemberDetailViewState) {

View file

@ -29,6 +29,7 @@ final class SpaceMemberDetailViewModel: NSObject, SpaceMemberDetailViewModelType
private let member: MXRoomMember
private let spaceId: String
private var space: MXSpace?
private(set) var showCancelMenuItem: Bool
private var currentOperation: MXHTTPOperation?
@ -39,11 +40,12 @@ final class SpaceMemberDetailViewModel: NSObject, SpaceMemberDetailViewModelType
// MARK: - Setup
init(userSessionsService: UserSessionsService, session: MXSession, member: MXRoomMember, spaceId: String) {
init(userSessionsService: UserSessionsService, session: MXSession, member: MXRoomMember, spaceId: String, showCancelMenuItem: Bool) {
self.userSessionsService = userSessionsService
self.session = session
self.member = member
self.spaceId = spaceId
self.showCancelMenuItem = showCancelMenuItem
}
deinit {

View file

@ -32,6 +32,7 @@ protocol SpaceMemberDetailViewModelType {
var viewDelegate: SpaceMemberDetailViewModelViewDelegate? { get set }
var coordinatorDelegate: SpaceMemberDetailViewModelCoordinatorDelegate? { get set }
var showCancelMenuItem: Bool { get }
func process(viewAction: SpaceMemberDetailViewAction)
}

View file

@ -110,7 +110,7 @@ final class SpaceMemberListViewController: RoomParticipantsViewController {
self?.cancelButtonAction()
}
self.navigationItem.rightBarButtonItem = cancelBarButtonItem
self.navigationItem.leftBarButtonItem = cancelBarButtonItem
self.titleView = MainTitleView()
self.titleView.titleLabel.text = VectorL10n.roomDetailsPeople

View file

@ -22,6 +22,17 @@ struct SpaceMembersCoordinatorParameters {
let userSessionsService: UserSessionsService
let session: MXSession
let spaceId: String
let navigationRouter: NavigationRouterType
init(userSessionsService: UserSessionsService,
session: MXSession,
spaceId: String,
navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController())) {
self.userSessionsService = userSessionsService
self.session = session
self.spaceId = spaceId
self.navigationRouter = navigationRouter
}
}
@objcMembers
@ -46,7 +57,7 @@ final class SpaceMembersCoordinator: SpaceMembersCoordinatorType {
init(parameters: SpaceMembersCoordinatorParameters) {
self.parameters = parameters
self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController())
self.navigationRouter = parameters.navigationRouter
}
// MARK: - Public methods
@ -59,8 +70,15 @@ final class SpaceMembersCoordinator: SpaceMembersCoordinatorType {
self.add(childCoordinator: rootCoordinator)
self.navigationRouter.setRootModule(rootCoordinator)
}
if self.navigationRouter.modules.isEmpty {
self.navigationRouter.setRootModule(rootCoordinator)
} else {
self.navigationRouter.push(rootCoordinator, animated: true) {
self.remove(childCoordinator: rootCoordinator)
}
}
}
func toPresentable() -> UIViewController {
return self.navigationRouter.toPresentable()
@ -99,7 +117,7 @@ final class SpaceMembersCoordinator: SpaceMembersCoordinatorType {
}
private func createSpaceMemberDetailCoordinator(with member: MXRoomMember) -> SpaceMemberDetailCoordinator {
let parameters = SpaceMemberDetailCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, member: member, session: self.parameters.session, spaceId: self.parameters.spaceId)
let parameters = SpaceMemberDetailCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, member: member, session: self.parameters.session, spaceId: self.parameters.spaceId, showCancelMenuItem: false)
let coordinator = SpaceMemberDetailCoordinator(parameters: parameters)
coordinator.delegate = self
return coordinator
@ -158,6 +176,7 @@ extension SpaceMembersCoordinator: SpaceMemberListCoordinatorDelegate {
coordinator.delegate = self
coordinator.start()
self.childCoordinators.append(coordinator)
self.navigationRouter.present(coordinator.toPresentable(), animated: true)
}
}
}

View file

@ -47,8 +47,9 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType {
// MARK: - Setup
init(session: MXSession, spaceId: String) {
self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController())
init(session: MXSession, spaceId: String,
navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController())) {
self.navigationRouter = navigationRouter
self.session = session
self.spaceId = spaceId
self.spaceIdStack = [spaceId]
@ -65,7 +66,13 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType {
self.add(childCoordinator: rootCoordinator)
self.currentExploreRoomCoordinator = rootCoordinator
self.navigationRouter.setRootModule(rootCoordinator)
if self.navigationRouter.modules.isEmpty {
self.navigationRouter.setRootModule(rootCoordinator)
} else {
self.navigationRouter.push(rootCoordinator, animated: true) {
self.remove(childCoordinator: rootCoordinator)
}
}
}
func toPresentable() -> UIViewController {
@ -75,13 +82,17 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType {
// MARK: - Private methods
private func pushSpace(with item: SpaceExploreRoomListItemViewData) {
let coordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: item.childInfo.childRoomId, spaceName: item.childInfo.name)
pushSpace(with: item.childInfo.childRoomId, name: item.childInfo.name)
}
private func pushSpace(with spaceId: String, name: String?) {
let coordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: spaceId, spaceName: name)
coordinator.start()
self.add(childCoordinator: coordinator)
self.currentExploreRoomCoordinator = coordinator
self.spaceIdStack.append(item.childInfo.childRoomId)
self.spaceIdStack.append(spaceId)
self.navigationRouter.push(coordinator.toPresentable(), animated: true) {
self.remove(childCoordinator: coordinator)
@ -174,7 +185,7 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType {
}
}
private func pushInviteScreen(forRoomWithId roomId: String) {
private func presentInviteScreen(forRoomWithId roomId: String) {
guard let room = session.room(withRoomId: roomId) else {
MXLog.error("[ExploreRoomCoordinator] pushInviteScreen: room not found.")
return
@ -183,7 +194,44 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType {
let coordinator = ContactsPickerCoordinator(session: session, room: room, initialSearchText: nil, actualParticipants: nil, invitedParticipants: nil, userParticipant: nil, navigationRouter: navigationRouter)
coordinator.delegate = self
coordinator.start()
childCoordinators.append(coordinator)
self.add(childCoordinator: coordinator)
self.navigationRouter.present(coordinator, animated: true)
}
@available(iOS 14.0, *)
private func showSpaceSettings(of childInfo: MXSpaceChildInfo) {
let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: childInfo.childRoomId))
coordinator.callback = { [weak self] result in
guard let self = self else { return }
switch result {
case .cancel(let spaceId), .done(let spaceId):
if spaceId != childInfo.childRoomId {
// the space has been upgraded. We need to refresh the rooms list
self.currentExploreRoomCoordinator?.reloadRooms()
}
self.navigationRouter.dismissModule(animated: true) {
self.remove(childCoordinator: coordinator)
}
}
}
coordinator.start()
self.add(childCoordinator: coordinator)
self.navigationRouter.present(coordinator.toPresentable(), animated: true)
}
private func presentSettings(ofRoomWithId roomId: String) -> Bool {
guard let room = session.room(withRoomId: roomId) else {
return false
}
let coordinator = RoomInfoCoordinator(parameters: RoomInfoCoordinatorParameters(session: session, room: room, parentSpaceId: self.spaceIdStack.last, initialSection: .settings, dismissOnCancel: true))
coordinator.delegate = self
add(childCoordinator: coordinator)
coordinator.start()
self.navigationRouter.present(coordinator.toPresentable(), animated: true)
return true
}
private func startEditPollCoordinator(room: MXRoom, startEvent: MXEvent? = nil) {
@ -229,11 +277,19 @@ extension ExploreRoomCoordinator: SpaceExploreRoomCoordinatorDelegate {
}
func spaceExploreRoomCoordinator(_ coordinator: SpaceExploreRoomCoordinatorType, openSettingsOf item: SpaceExploreRoomListItemViewData) {
self.navigateTo(roomWith: item.childInfo.childRoomId, showSettingsInitially: true, animated: true)
if item.childInfo.roomType == .space {
if #available(iOS 14, *) {
self.showSpaceSettings(of: item.childInfo)
}
} else {
if !presentSettings(ofRoomWithId: item.childInfo.childRoomId) {
self.navigateTo(roomWith: item.childInfo.childRoomId, showSettingsInitially: true, animated: true)
}
}
}
func spaceExploreRoomCoordinator(_ coordinator: SpaceExploreRoomCoordinatorType, inviteTo item: SpaceExploreRoomListItemViewData) {
self.pushInviteScreen(forRoomWithId: item.childInfo.childRoomId)
self.presentInviteScreen(forRoomWithId: item.childInfo.childRoomId)
}
}
@ -407,3 +463,33 @@ extension ExploreRoomCoordinator: ContactsPickerCoordinatorDelegate {
}
}
// MARK: - RoomInfoCoordinatorDelegate
extension ExploreRoomCoordinator: RoomInfoCoordinatorDelegate {
func roomInfoCoordinatorDidComplete(_ coordinator: RoomInfoCoordinatorType) {
self.navigationRouter.dismissModule(animated: true) {
self.remove(childCoordinator: coordinator)
}
}
func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didRequestMentionForMember member: MXRoomMember) {
// Do nothing in this case
}
func roomInfoCoordinatorDidLeaveRoom(_ coordinator: RoomInfoCoordinatorType) {
self.currentExploreRoomCoordinator?.reloadRooms()
self.navigationRouter.dismissModule(animated: true) {
self.remove(childCoordinator: coordinator)
}
}
func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didReplaceRoomWithReplacementId roomId: String) {
self.currentExploreRoomCoordinator?.reloadRooms()
self.navigationRouter.dismissModule(animated: true) {
self.remove(childCoordinator: coordinator)
}
}
}

View file

@ -48,7 +48,7 @@ struct SpaceAvatarImage: View {
Image(uiImage: image)
.resizable()
.frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue))
.clipShape(Circle())
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.onChange(of: displayName, perform: { value in

View file

@ -16,6 +16,20 @@
import SwiftUI
@available(iOS 14.0, *)
extension ThemableTextField {
func showClearButton(text: Binding<String>, alignement: VerticalAlignment = .center) -> some View {
return modifier(ClearViewModifier(alignment: alignement, text: text))
}
}
@available(iOS 14.0, *)
extension ThemableTextEditor {
func showClearButton(text: Binding<String>, alignement: VerticalAlignment = .top) -> some View {
return modifier(ClearViewModifier(alignment: alignement, text: text))
}
}
/// `ClearViewModifier` aims to add a clear button (e.g. `x` button) on the right side of any text editing view
@available(iOS 14.0, *)
struct ClearViewModifier: ViewModifier

View file

@ -35,6 +35,7 @@ struct RoundedBorderTextEditor: View {
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
@Environment(\.isEnabled) private var isEnabled
// MARK: Setup
@ -73,18 +74,32 @@ struct RoundedBorderTextEditor: View {
.foregroundColor(theme.colors.tertiaryContent)
.allowsHitTesting(false)
}
ThemableTextEditor(text: $text, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.modifier(ClearViewModifier(alignment: .top, text: $text))
// Found no good solution here. Hidding next button for the moment
// .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing))
.padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0))
.onChange(of: text, perform: { newText in
onTextChanged?(newText)
})
if isEnabled {
ThemableTextEditor(text: $text, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.showClearButton(text: $text)
// Found no good solution here. Hidding next button for the moment
// .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing))
.padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0))
.onChange(of: text, perform: { newText in
onTextChanged?(newText)
})
} else {
ThemableTextEditor(text: $text, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 6))
.onChange(of: text, perform: { newText in
onTextChanged?(newText)
})
.opacity(0.5)
.allowsHitTesting(false)
}
}
.background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(editing ? theme.colors.accent : (error == nil ? theme.colors.quinaryContent : theme.colors.alert), lineWidth: editing || error != nil ? 2 : 1))
.frame(height: textMaxHeight)
@ -108,18 +123,19 @@ struct ThemableTextEditor_Previews: PreviewProvider {
static var previews: some View {
Group {
VStack(alignment: .center, spacing: 40) {
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil))
RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil))
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text"))
}
VStack(alignment: .center, spacing: 40) {
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil))
RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil))
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some text"), error: .constant("Some error text"))
}
.theme(.dark).preferredColorScheme(.dark)
sampleView.theme(.light).preferredColorScheme(.light)
sampleView.theme(.dark).preferredColorScheme(.dark)
}
.padding()
}
static var sampleView: some View {
VStack(alignment: .center, spacing: 40) {
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil))
RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil))
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text"))
RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text"))
.disabled(true)
}
}
}

View file

@ -39,10 +39,19 @@ struct RoundedBorderTextField: View {
@State private var editing = false
@Environment(\.theme) private var theme: ThemeSwiftUI
@Environment(\.isEnabled) private var isEnabled
// MARK: Setup
init(title: String? = nil, placeHolder: String, text: Binding<String>, footerText: Binding<String?> = .constant(nil), isError: Binding<Bool> = .constant(false), isFirstResponder: Bool = false, configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), onTextChanged: ((String) -> Void)? = nil, onEditingChanged: ((Bool) -> Void)? = nil) {
init(title: String? = nil,
placeHolder: String,
text: Binding<String>,
footerText: Binding<String?> = .constant(nil),
isError: Binding<Bool> = .constant(false),
isFirstResponder: Bool = false,
configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(),
onTextChanged: ((String) -> Void)? = nil,
onEditingChanged: ((Bool) -> Void)? = nil) {
self.title = title
self.placeHolder = placeHolder
self._text = text
@ -72,18 +81,33 @@ struct RoundedBorderTextField: View {
.foregroundColor(theme.colors.tertiaryContent)
.lineLimit(1)
}
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.makeFirstResponder(isFirstResponder)
.onChange(of: text, perform: { newText in
onTextChanged?(newText)
})
.frame(height: 30)
.modifier(ClearViewModifier(alignment: .center, text: $text))
if isEnabled {
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.makeFirstResponder(isFirstResponder)
.showClearButton(text: $text)
.onChange(of: text, perform: { newText in
onTextChanged?(newText)
})
.frame(height: 30)
} else {
ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in
self.editing = edit
onEditingChanged?(edit)
})
.makeFirstResponder(isFirstResponder)
.onChange(of: text, perform: { newText in
onTextChanged?(newText)
})
.frame(height: 30)
.allowsHitTesting(false)
.opacity(0.5)
}
}
.padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0))
.background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(editing ? theme.colors.accent : (footerText != nil && isError ? theme.colors.alert : theme.colors.quinaryContent), lineWidth: editing || (footerText != nil && isError) ? 2 : 1))
@ -107,20 +131,20 @@ struct TextFieldWithError_Previews: PreviewProvider {
static var previews: some View {
Group {
VStack(alignment: .center, spacing: 40) {
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false))
RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false))
}
VStack(alignment: .center, spacing: 20) {
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false))
RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false))
}.theme(.dark).preferredColorScheme(.dark)
sampleView.theme(.light).preferredColorScheme(.light)
sampleView.theme(.dark).preferredColorScheme(.dark)
}
.padding()
}
static var sampleView: some View {
VStack(alignment: .center, spacing: 20) {
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false))
RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false))
RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false))
.disabled(true)
}
}
}

View file

@ -110,7 +110,7 @@ final class RoomAccessCoordinator: Coordinator {
}
private func createRoomAccessTypeCoordinator() -> RoomAccessTypeChooserCoordinator {
let coordinator: RoomAccessTypeChooserCoordinator = RoomAccessTypeChooserCoordinator(parameters: RoomAccessTypeChooserCoordinatorParameters(roomId: parameters.room.roomId, session: parameters.room.mxSession))
let coordinator: RoomAccessTypeChooserCoordinator = RoomAccessTypeChooserCoordinator(parameters: RoomAccessTypeChooserCoordinatorParameters(roomId: parameters.room.roomId, allowsRoomUpgrade: parameters.allowsRoomUpgrade, session: parameters.room.mxSession))
coordinator.callback = { [weak self] result in
guard let self = self else { return }

View file

@ -34,6 +34,7 @@ final class RoomAccessCoordinatorBridgePresenter: NSObject {
// MARK: Private
private let room: MXRoom
private let allowsRoomUpgrade: Bool
private var coordinator: RoomAccessCoordinator?
// MARK: Public
@ -42,16 +43,22 @@ final class RoomAccessCoordinatorBridgePresenter: NSObject {
// MARK: - Setup
init(room: MXRoom) {
init(room: MXRoom,
allowsRoomUpgrade: Bool) {
self.room = room
self.allowsRoomUpgrade = allowsRoomUpgrade
super.init()
}
convenience init(room: MXRoom) {
self.init(room: room, allowsRoomUpgrade: true)
}
// MARK: - Public
func present(from viewController: UIViewController, animated: Bool) {
let navigationRouter = NavigationRouter()
let coordinator = RoomAccessCoordinator(parameters: RoomAccessCoordinatorParameters(room: room, navigationRouter: navigationRouter))
let coordinator = RoomAccessCoordinator(parameters: RoomAccessCoordinatorParameters(room: room, allowsRoomUpgrade: allowsRoomUpgrade, navigationRouter: navigationRouter))
coordinator.callback = { [weak self] result in
guard let self = self else { return }

View file

@ -23,13 +23,18 @@ struct RoomAccessCoordinatorParameters {
/// The Matrix room
let room: MXRoom
/// Set this value to false if you want to avoid room to be upgraded
let allowsRoomUpgrade: Bool
/// The navigation router that manage physical navigation
let navigationRouter: NavigationRouterType
init(room: MXRoom,
allowsRoomUpgrade: Bool = true,
navigationRouter: NavigationRouterType? = nil) {
self.room = room
self.allowsRoomUpgrade = allowsRoomUpgrade
self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController())
}
}

View file

@ -18,6 +18,7 @@ import SwiftUI
struct RoomAccessTypeChooserCoordinatorParameters {
let roomId: String
let allowsRoomUpgrade: Bool
let session: MXSession
}
@ -42,7 +43,7 @@ final class RoomAccessTypeChooserCoordinator: Coordinator, Presentable {
@available(iOS 14.0, *)
init(parameters: RoomAccessTypeChooserCoordinatorParameters) {
self.parameters = parameters
let viewModel = RoomAccessTypeChooserViewModel(roomAccessTypeChooserService: RoomAccessTypeChooserService(roomId: parameters.roomId, session: parameters.session))
let viewModel = RoomAccessTypeChooserViewModel(roomAccessTypeChooserService: RoomAccessTypeChooserService(roomId: parameters.roomId, allowsRoomUpgrade: parameters.allowsRoomUpgrade, session: parameters.session))
let room = parameters.session.room(withRoomId: parameters.roomId)
let view = RoomAccessTypeChooser(viewModel: viewModel.context, roomName: room?.displayName ?? "")
roomAccessTypeChooserViewModel = viewModel

View file

@ -26,6 +26,7 @@ class RoomAccessTypeChooserService: RoomAccessTypeChooserServiceProtocol {
// MARK: Private
private let roomId: String
private let allowsRoomUpgrade: Bool
private let session: MXSession
private var replacementRoom: MXRoom?
private var didBuildSpaceGraphObserver: Any?
@ -63,8 +64,9 @@ class RoomAccessTypeChooserService: RoomAccessTypeChooserServiceProtocol {
// MARK: - Setup
init(roomId: String, session: MXSession) {
init(roomId: String, allowsRoomUpgrade: Bool, session: MXSession) {
self.roomId = roomId
self.allowsRoomUpgrade = allowsRoomUpgrade
self.session = session
self.currentRoomId = roomId
self.versionOverride = session.homeserverCapabilitiesService.versionOverrideForFeature(.restricted)
@ -144,7 +146,7 @@ class RoomAccessTypeChooserService: RoomAccessTypeChooserServiceProtocol {
// MARK: - Private
private func setupAccessItems() {
guard let spaceService = session.spaceService, let ancestors = spaceService.ancestorsPerRoomId[currentRoomId], !ancestors.isEmpty else {
guard let spaceService = session.spaceService, let ancestors = spaceService.ancestorsPerRoomId[currentRoomId], !ancestors.isEmpty, allowsRoomUpgrade || !roomUpgradeRequired else {
self.accessItems = [
RoomAccessTypeChooserAccessItem(id: .private, isSelected: false, title: VectorL10n.private, detail: VectorL10n.roomAccessSettingsScreenPrivateMessage, badgeText: nil),
RoomAccessTypeChooserAccessItem(id: .public, isSelected: false, title: VectorL10n.public, detail: VectorL10n.roomAccessSettingsScreenPublicMessage, badgeText: nil),

View file

@ -37,13 +37,13 @@ class MatrixItemChooserRoomRestrictedAllowedParentsDataSource: MatrixItemChooser
guard let self = self else { return }
let joinRuleEvent = state?.stateEvents(with: .roomJoinRules)?.last
let allowContent: [[String: String]] = joinRuleEvent?.wireContent["allow"] as? [[String: String]] ?? []
let allowContent: [[String: String]] = joinRuleEvent?.wireContent[kMXJoinRulesContentKeyAllow] as? [[String: String]] ?? []
self.allowedParentIds = allowContent.compactMap { allowDictionnary in
guard let type = allowDictionnary["type"], type == "m.room_membership" else {
guard let type = allowDictionnary[kMXJoinRulesContentKeyType], type == kMXEventTypeStringRoomMembership else {
return nil
}
return allowDictionnary["room_id"]
return allowDictionnary[kMXJoinRulesContentKeyRoomId]
}
let ancestorsId = session.spaceService.ancestorsPerRoomId[self.roomId] ?? []

View file

@ -18,7 +18,32 @@ import Foundation
enum SpaceCreationSettingsAddressValidationStatus {
case none(_ address: String)
case current(_ address: String)
case valid(_ address: String)
case alreadyExists(_ address: String)
case invalidCharacters(_ address: String)
var message: String {
switch self {
case .none(let fullAddress):
return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress)
case .current(let fullAddress):
return VectorL10n.spaceSettingsCurrentAddressMessage(fullAddress)
case .valid(let fullAddress):
return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress)
case .alreadyExists(let fullAddress):
return VectorL10n.spacesCreationAddressAlreadyExists(fullAddress)
case .invalidCharacters(let fullAddress):
return VectorL10n.spacesCreationAddressInvalidCharacters(fullAddress)
}
}
var isValid: Bool {
switch self {
case .none, .current, .valid:
return true
default:
return false
}
}
}

View file

@ -94,7 +94,7 @@ class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol {
}
private func updateDefaultAddress() {
defaultAddress = roomName.toValidAliasLocalPart()
defaultAddress = MXTools.validAliasLocalPart(from: roomName)
}
private func validateAddress() {
@ -102,7 +102,7 @@ class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol {
currentOperation = nil
guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else {
let fullAddress = defaultAddress.fullLocalAlias(with: session)
let fullAddress = MXTools.fullLocalAlias(from: defaultAddress, with: session)
if defaultAddress.isEmpty {
addressValidationSubject.send(.none(fullAddress))
@ -116,7 +116,7 @@ class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol {
}
private func validate(_ aliasLocalPart: String) {
let fullAddress = aliasLocalPart.fullLocalAlias(with: session)
let fullAddress = MXTools.fullLocalAlias(from: aliasLocalPart, with: session)
currentOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in
guard let self = self else { return }

View file

@ -76,8 +76,8 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC
showRoomAddress: creationParameters.showAddress,
defaultAddress: creationParameters.address ?? "",
roomNameError: nil,
addressMessage: addressMessage(with: validationStatus),
isAddressValid: isAddressValid(with: validationStatus),
addressMessage: validationStatus.message,
isAddressValid: validationStatus.isValid,
avatar: AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: nil),
avatarImage: creationParameters.userSelectedAvatar,
bindings: bindings)
@ -120,8 +120,8 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC
case .updateRoomDefaultAddress(let defaultAddress):
state.defaultAddress = defaultAddress
case .updateAddressValidationStatus(let validationStatus):
state.addressMessage = Self.addressMessage(with: validationStatus)
state.isAddressValid = Self.isAddressValid(with: validationStatus)
state.addressMessage = validationStatus.message
state.isAddressValid = validationStatus.isValid
case .updateAvatar(let avatar):
state.avatar = avatar
case .updateAvatarImage(let image):
@ -161,26 +161,4 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC
private func pickImage(from sourceRect: CGRect) {
callback?(.pickImage(sourceRect))
}
private static func addressMessage(with validationStatus: SpaceCreationSettingsAddressValidationStatus) -> String {
switch validationStatus {
case .none(let fullAddress):
return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress)
case .valid(let fullAddress):
return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress)
case .alreadyExists(let fullAddress):
return VectorL10n.spacesCreationAddressAlreadyExists(fullAddress)
case .invalidCharacters(let fullAddress):
return VectorL10n.spacesCreationAddressInvalidCharacters(fullAddress)
}
}
private static func isAddressValid(with validationStatus: SpaceCreationSettingsAddressValidationStatus) -> Bool {
switch validationStatus {
case .none, .valid:
return true
default:
return false
}
}
}

View file

@ -0,0 +1,183 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
/// Actions returned by the coordinator callback
enum SpaceSettingsModalCoordinatorAction {
case done(_ spaceId: String)
case cancel(_ spaceId: String)
}
@objcMembers
@available(iOS 14.0, *)
final class SpaceSettingsModalCoordinator: Coordinator {
// MARK: - Properties
// MARK: Private
private let parameters: SpaceSettingsModalCoordinatorParameters
private var upgradedRoomId: String?
private var currentRoomId: String {
upgradedRoomId ?? parameters.spaceId
}
private var navigationRouter: NavigationRouterType {
return self.parameters.navigationRouter
}
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((SpaceSettingsModalCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: SpaceSettingsModalCoordinatorParameters) {
self.parameters = parameters
}
// MARK: - Public
func start() {
MXLog.debug("[SpaceSettingsModalCoordinator] did start.")
let rootCoordinator = self.createSpaceSettingsCoordinator()
rootCoordinator.start()
self.add(childCoordinator: rootCoordinator)
if self.navigationRouter.modules.isEmpty == false {
self.navigationRouter.push(rootCoordinator, animated: true, popCompletion: { [weak self] in
self?.remove(childCoordinator: rootCoordinator)
})
} else {
self.navigationRouter.setRootModule(rootCoordinator) { [weak self] in
self?.remove(childCoordinator: rootCoordinator)
}
}
}
func toPresentable() -> UIViewController {
return self.navigationRouter.toPresentable()
}
// MARK: - Private
@available(iOS 14.0, *)
func pushScreen(with coordinator: Coordinator & Presentable) {
add(childCoordinator: coordinator)
self.navigationRouter.push(coordinator, animated: true, popCompletion: { [weak self] in
self?.remove(childCoordinator: coordinator)
})
coordinator.start()
}
private func createSpaceSettingsCoordinator() -> SpaceSettingsCoordinator {
let coordinator = SpaceSettingsCoordinator(parameters: SpaceSettingsCoordinatorParameters(session: parameters.session, spaceId: parameters.spaceId))
coordinator.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .cancel:
self.callback?(.cancel(self.currentRoomId))
case .done:
self.callback?(.done(self.currentRoomId))
case .optionScreen(let optionType):
self.pushOptionScreen(ofType: optionType)
}
}
return coordinator
}
private func pushOptionScreen(ofType optionType: SpaceSettingsOptionType) {
switch optionType {
case .rooms:
exploreRooms(ofSpaceWithId: self.parameters.spaceId)
case .members:
showMembers(ofSpaceWithId: self.parameters.spaceId)
case .visibility:
showAccess(ofSpaceWithId: self.parameters.spaceId)
}
}
private func exploreRooms(ofSpaceWithId spaceId: String) {
let coordinator = ExploreRoomCoordinator(session: parameters.session, spaceId: spaceId)
coordinator.delegate = self
add(childCoordinator: coordinator)
coordinator.start()
self.navigationRouter.present(coordinator.toPresentable(), animated: true)
}
private func showMembers(ofSpaceWithId spaceId: String) {
let coordinator = SpaceMembersCoordinator(parameters: SpaceMembersCoordinatorParameters(userSessionsService: UserSessionsService.shared, session: parameters.session, spaceId: spaceId))
coordinator.delegate = self
add(childCoordinator: coordinator)
coordinator.start()
self.navigationRouter.present(coordinator.toPresentable(), animated: true)
}
private func showAccess(ofSpaceWithId spaceId: String) {
guard let room = parameters.session.room(withRoomId: spaceId) else {
return
}
// Needed more tests on synaose side before starting space upgrade implementation
let coordinator = RoomAccessCoordinator(parameters: RoomAccessCoordinatorParameters(room: room, allowsRoomUpgrade: false))
coordinator.callback = { [weak self] result in
guard let self = self else { return }
switch result {
case .cancel(let roomId), .done(let roomId):
if roomId != spaceId {
// TODO: room has been upgraded
self.upgradedRoomId = roomId
}
self.navigationRouter.dismissModule(animated: true, completion: {
self.remove(childCoordinator: coordinator)
})
}
}
add(childCoordinator: coordinator)
coordinator.start()
self.navigationRouter.present(coordinator.toPresentable(), animated: true)
}
}
// MARK: - ExploreRoomCoordinatorDelegate
@available(iOS 14.0, *)
extension SpaceSettingsModalCoordinator: ExploreRoomCoordinatorDelegate {
func exploreRoomCoordinatorDidComplete(_ coordinator: ExploreRoomCoordinatorType) {
self.navigationRouter.dismissModule(animated: true, completion: {
self.remove(childCoordinator: coordinator)
})
}
}
// MARK: - SpaceMembersCoordinatorDelegate
@available(iOS 14.0, *)
extension SpaceSettingsModalCoordinator: SpaceMembersCoordinatorDelegate {
func spaceMembersCoordinatorDidCancel(_ coordinator: SpaceMembersCoordinatorType) {
self.navigationRouter.dismissModule(animated: true, completion: {
self.remove(childCoordinator: coordinator)
})
}
}

View file

@ -0,0 +1,100 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
@available(iOS 14.0, *)
@objc protocol SpaceSettingsModalCoordinatorBridgePresenterDelegate {
func spaceSettingsModalCoordinatorBridgePresenterDelegateDidCancel(_ coordinatorBridgePresenter: SpaceSettingsModalCoordinatorBridgePresenter)
func spaceSettingsModalCoordinatorBridgePresenterDelegateDidFinish(_ coordinatorBridgePresenter: SpaceSettingsModalCoordinatorBridgePresenter)
}
/// SpaceSettingsModalCoordinatorBridgePresenter enables to start SpaceSettingsModalCoordinator from a view controller.
/// This bridge is used while waiting for global usage of coordinator pattern.
/// It breaks the Coordinator abstraction and it has been introduced for Objective-C compatibility (mainly for integration in legacy view controllers).
/// Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator.
@objcMembers
@available(iOS 14.0, *)
final class SpaceSettingsModalCoordinatorBridgePresenter: NSObject {
// MARK: - Properties
// MARK: Private
private let spaceId: String
private let session: MXSession
private var coordinator: SpaceSettingsModalCoordinator?
// MARK: Public
weak var delegate: SpaceSettingsModalCoordinatorBridgePresenterDelegate?
// MARK: - Setup
init(spaceId: String, session: MXSession) {
self.spaceId = spaceId
self.session = session
super.init()
}
// MARK: - Public
func present(from viewController: UIViewController, animated: Bool) {
let navigationRouter = NavigationRouter()
let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: spaceId, navigationRouter: navigationRouter))
coordinator.callback = { [weak self] result in
guard let self = self else { return }
switch result {
case .cancel:
self.delegate?.spaceSettingsModalCoordinatorBridgePresenterDelegateDidCancel(self)
case .done:
self.delegate?.spaceSettingsModalCoordinatorBridgePresenterDelegateDidFinish(self)
}
}
let presentable = coordinator.toPresentable()
presentable.presentationController?.delegate = self
navigationRouter.setRootModule(presentable)
viewController.present(navigationRouter.toPresentable(), animated: animated, completion: nil)
coordinator.start()
self.coordinator = coordinator
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
guard let coordinator = self.coordinator else {
return
}
coordinator.toPresentable().dismiss(animated: animated) {
self.coordinator = nil
if let completion = completion {
completion()
}
}
}
}
// MARK: - UIAdaptivePresentationControllerDelegate
@available(iOS 14.0, *)
extension SpaceSettingsModalCoordinatorBridgePresenter: UIAdaptivePresentationControllerDelegate {
func roomNotificationSettingsCoordinatorDidComplete(_ presentationController: UIPresentationController) {
self.delegate?.spaceSettingsModalCoordinatorBridgePresenterDelegateDidCancel(self)
}
}

View file

@ -0,0 +1,38 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// SpaceSettingsModalCoordinator input parameters
struct SpaceSettingsModalCoordinatorParameters {
/// The Matrix session
let session: MXSession
/// The ID of the space
let spaceId: String
/// The navigation router that manage physical navigation
let navigationRouter: NavigationRouterType
init(session: MXSession,
spaceId: String,
navigationRouter: NavigationRouterType? = nil) {
self.session = session
self.spaceId = spaceId
self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController())
}
}

View file

@ -0,0 +1,101 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct SpaceSettingsCoordinatorParameters {
let session: MXSession
let spaceId: String
}
final class SpaceSettingsCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: SpaceSettingsCoordinatorParameters
private let spaceSettingsHostingController: UIViewController
private var spaceSettingsViewModel: SpaceSettingsViewModelProtocol
private lazy var singleImagePickerPresenter: SingleImagePickerPresenter = {
let presenter = SingleImagePickerPresenter(session: parameters.session)
presenter.delegate = self
return presenter
}()
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((SpaceSettingsCoordinatorResult) -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: SpaceSettingsCoordinatorParameters) {
self.parameters = parameters
let viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: SpaceSettingsService(session: parameters.session, spaceId: parameters.spaceId))
let view = SpaceSettings(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
spaceSettingsViewModel = viewModel
spaceSettingsHostingController = VectorHostingController(rootView: view)
}
// MARK: - Public
func start() {
MXLog.debug("[SpaceSettingsCoordinator] did start.")
spaceSettingsViewModel.completion = { [weak self] result in
MXLog.debug("[SpaceSettingsCoordinator] SpaceSettingsViewModel did complete with result: \(result).")
guard let self = self else { return }
switch result {
case .cancel:
self.completion?(.cancel)
case .done:
self.completion?(.done)
case .optionScreen(let optionType):
self.completion?(.optionScreen(optionType))
case .pickImage(let sourceRect):
self.pickImage(from: sourceRect)
}
}
}
func toPresentable() -> UIViewController {
return self.spaceSettingsHostingController
}
// MARK: - Private
private func pickImage(from sourceRect: CGRect) {
let controller = toPresentable()
let adjustedRect = controller.view.convert(sourceRect, from: nil)
singleImagePickerPresenter.present(from: controller, sourceView: controller.view, sourceRect: adjustedRect, animated: true)
}
}
// MARK: - SingleImagePickerPresenterDelegate
extension SpaceSettingsCoordinator: SingleImagePickerPresenterDelegate {
func singleImagePickerPresenter(_ presenter: SingleImagePickerPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) {
spaceSettingsViewModel.updateAvatarImage(with: UIImage(data: imageData))
presenter.dismiss(animated: true, completion: nil)
}
func singleImagePickerPresenterDidCancel(_ presenter: SingleImagePickerPresenter) {
presenter.dismiss(animated: true, completion: nil)
}
}

View file

@ -0,0 +1,84 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
@available(iOS 14.0, *)
enum MockSpaceSettingsScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case visibility(SpaceSettingsVisibility)
case notEditable
/// The associated screen
var screenType: Any.Type {
SpaceSettings.self
}
/// A list of screen state definitions
static var allCases: [MockSpaceSettingsScreenState] {
SpaceSettingsVisibility.allCases.map(MockSpaceSettingsScreenState.visibility)
+ [.notEditable]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let service: MockSpaceSettingsService
switch self {
case .visibility(let visibility):
let roomProperties = SpaceSettingsRoomProperties(
name: "Space Name",
topic: "Sapce topic",
address: nil,
avatarUrl: nil,
visibility: visibility,
allowedParentIds: [],
isAvatarEditable: true,
isNameEditable: true,
isTopicEditable: true,
isAddressEditable: true,
isAccessEditable: true)
service = MockSpaceSettingsService(roomProperties: roomProperties)
case .notEditable:
let roomProperties = SpaceSettingsRoomProperties(
name: "Space Name",
topic: "Sapce topic",
address: nil,
avatarUrl: nil,
visibility: .public,
allowedParentIds: [],
isAvatarEditable: false,
isNameEditable: false,
isTopicEditable: false,
isAddressEditable: false,
isAccessEditable: false)
service = MockSpaceSettingsService(roomProperties: roomProperties)
}
let viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: service)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
AnyView(SpaceSettings(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
)
}
}

View file

@ -0,0 +1,474 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
import MatrixSDK
@available(iOS 14.0, *)
class SpaceSettingsService: SpaceSettingsServiceProtocol {
// MARK: - Properties
var userDefinedAddress: String? {
didSet {
validateAddress()
}
}
// MARK: Private
private let session: MXSession
private var roomState: MXRoomState? {
didSet {
updateRoomProperties()
}
}
private let room: MXRoom?
private var roomEventListener: Any?
private var publicAddress: String? {
didSet {
validateAddress()
}
}
private var defaultAddress: String {
didSet {
validateAddress()
}
}
// MARK: Public
var displayName: String? {
room?.displayName
}
private(set) var spaceId: String
private(set) var roomProperties: SpaceSettingsRoomProperties? {
didSet {
roomPropertiesSubject.send(roomProperties)
}
}
private(set) var isLoadingSubject: CurrentValueSubject<Bool, Never>
private(set) var roomPropertiesSubject: CurrentValueSubject<SpaceSettingsRoomProperties?, Never>
private(set) var showPostProcessAlert: CurrentValueSubject<Bool, Never>
private(set) var addressValidationSubject: CurrentValueSubject<SpaceCreationSettingsAddressValidationStatus, Never>
var isAddressValid: Bool {
switch addressValidationSubject.value {
case .none, .valid:
return true
default:
return false
}
}
private var currentOperation: MXHTTPOperation?
private var addressValidationOperation: MXHTTPOperation?
private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: session, initialRange: 0, andRange: 1.0)
// MARK: - Setup
init(session: MXSession, spaceId: String) {
self.session = session
self.spaceId = spaceId
self.room = session.room(withRoomId: spaceId)
self.isLoadingSubject = CurrentValueSubject(false)
self.showPostProcessAlert = CurrentValueSubject(false)
self.roomPropertiesSubject = CurrentValueSubject(self.roomProperties)
self.addressValidationSubject = CurrentValueSubject(.none("#"))
self.defaultAddress = ""
readRoomState()
}
deinit {
if let roomEventListener = self.roomEventListener {
self.room?.removeListener(roomEventListener)
}
currentOperation?.cancel()
addressValidationOperation?.cancel()
}
// MARK: - Public
func addressDidChange(_ newValue: String) {
userDefinedAddress = newValue
}
// MARK: - Private
private func readRoomState() {
isLoadingSubject.send(true)
self.room?.state { [weak self] roomState in
self?.roomState = roomState
self?.isLoadingSubject.send(false)
}
roomEventListener = self.room?.listen(toEvents: { [weak self] event, direction, state in
self?.room?.state({ [weak self] roomState in
self?.roomState = roomState
})
})
}
private func visibility(with roomState: MXRoomState) -> SpaceSettingsVisibility {
switch roomState.joinRule {
case .public:
return .public
case .restricted:
return .restricted
default:
return .private
}
}
private func allowedParentIds(with roomState: MXRoomState) -> [String] {
var allowedParentIds: [String] = []
if roomState.joinRule == .restricted, let joinRuleEvent = roomState.stateEvents(with: .roomJoinRules)?.last {
let allowContent: [[String: String]] = joinRuleEvent.wireContent[kMXJoinRulesContentKeyAllow] as? [[String: String]] ?? []
allowedParentIds = allowContent.compactMap { allowDictionnary in
guard let type = allowDictionnary[kMXJoinRulesContentKeyType], type == kMXEventTypeStringRoomMembership else {
return nil
}
return allowDictionnary[kMXJoinRulesContentKeyRoomId]
}
}
return allowedParentIds
}
private func isField(ofType notification: String, editableWith powerLevels: MXRoomPowerLevels?) -> Bool {
guard let powerLevels = powerLevels else {
return false
}
let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.session.myUserId)
return userPowerLevel >= powerLevels.minimumPowerLevel(forNotifications: notification, defaultPower: powerLevels.stateDefault)
}
private func validateAddress() {
addressValidationOperation?.cancel()
addressValidationOperation = nil
guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else {
let fullAddress = MXTools.fullLocalAlias(from: defaultAddress, with: session)
if let publicAddress = self.publicAddress, !publicAddress.isEmpty {
addressValidationSubject.send(.current(fullAddress))
} else if defaultAddress.isEmpty {
addressValidationSubject.send(.none(fullAddress))
} else {
validate(defaultAddress)
}
return
}
validate(userDefinedAddress)
}
private func validate(_ aliasLocalPart: String) {
let fullAddress = MXTools.fullLocalAlias(from: aliasLocalPart, with: session)
if let publicAddress = self.publicAddress, publicAddress == aliasLocalPart {
self.addressValidationSubject.send(.current(fullAddress))
return
}
addressValidationOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in
guard let self = self else { return }
self.addressValidationOperation = nil
switch result {
case .available:
self.addressValidationSubject.send(.valid(fullAddress))
case .invalid:
self.addressValidationSubject.send(.invalidCharacters(fullAddress))
case .notAvailable:
self.addressValidationSubject.send(.alreadyExists(fullAddress))
case .serverError:
self.addressValidationSubject.send(.none(fullAddress))
}
}
}
private func updateRoomProperties() {
guard let roomState = roomState else {
return
}
if let canonicalAlias = roomState.canonicalAlias {
let localAliasPart = MXTools.extractLocalAliasPart(from: canonicalAlias)
self.publicAddress = localAliasPart
self.defaultAddress = localAliasPart
} else {
self.publicAddress = nil
self.defaultAddress = MXTools.validAliasLocalPart(from: roomState.name)
}
self.roomProperties = SpaceSettingsRoomProperties(
name: roomState.name,
topic: roomState.topic,
address: self.defaultAddress,
avatarUrl: roomState.avatar,
visibility: visibility(with: roomState),
allowedParentIds: allowedParentIds(with: roomState),
isAvatarEditable: isField(ofType: kMXEventTypeStringRoomAvatar, editableWith: roomState.powerLevels),
isNameEditable: isField(ofType: kMXEventTypeStringRoomName, editableWith: roomState.powerLevels),
isTopicEditable: isField(ofType: kMXEventTypeStringRoomTopic, editableWith: roomState.powerLevels),
isAddressEditable: isField(ofType: kMXEventTypeStringRoomAliases, editableWith: roomState.powerLevels),
isAccessEditable: isField(ofType: kMXEventTypeStringRoomJoinRules, editableWith: roomState.powerLevels))
}
// MARK: - Post process
private var currentTaskIndex: Int = 0
private var tasks: [PostProcessTask] = []
private var lastError: Error?
private var completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?
private enum PostProcessTaskType: Equatable {
case updateName(String)
case updateTopic(String)
case updateAlias(String)
case uploadAvatar(UIImage)
}
private enum PostProcessTaskState: CaseIterable, Equatable {
case none
case started
case success
case failure
}
private struct PostProcessTask: Equatable {
let type: PostProcessTaskType
var state: PostProcessTaskState = .none
var isFinished: Bool {
return state == .failure || state == .success
}
static func == (lhs: PostProcessTask, rhs: PostProcessTask) -> Bool {
return lhs.type == rhs.type && lhs.state == rhs.state
}
}
func update(roomName: String, topic: String, address: String, avatar: UIImage?,
completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?) {
// First attempt
if self.tasks.isEmpty {
var tasks: [PostProcessTask] = []
if roomProperties?.name ?? "" != roomName {
tasks.append(PostProcessTask(type: .updateName(roomName)))
}
if roomProperties?.topic ?? "" != topic {
tasks.append(PostProcessTask(type: .updateTopic(topic)))
}
if roomProperties?.address ?? "" != address {
tasks.append(PostProcessTask(type: .updateAlias(address)))
}
if let avatarImage = avatar {
tasks.append(PostProcessTask(type: .uploadAvatar(avatarImage)))
}
self.tasks = tasks
} else {
// Retry -> restart failed tasks
self.tasks = tasks.map({ task in
if task.state == .failure {
return PostProcessTask(type: task.type, state: .none)
}
return task
})
}
self.isLoadingSubject.send(true)
self.completion = completion
self.lastError = nil
currentTaskIndex = -1
runNextTask()
}
private func runNextTask() {
currentTaskIndex += 1
guard currentTaskIndex < tasks.count else {
self.isLoadingSubject.send(false)
if let error = lastError {
showPostProcessAlert.send(true)
completion?(.failure(error))
} else {
completion?(.success)
}
return
}
let task = tasks[currentTaskIndex]
guard !task.isFinished else {
runNextTask()
return
}
switch task.type {
case .updateName(let roomName):
update(roomName: roomName)
case .updateTopic(let topic):
update(topic: topic)
case .updateAlias(let address):
update(canonicalAlias: address)
case .uploadAvatar(let image):
upload(avatar: image)
}
}
private func updateCurrentTaskState(with state: PostProcessTaskState) {
guard currentTaskIndex < tasks.count else {
return
}
tasks[currentTaskIndex].state = state
}
private func update(roomName: String) {
updateCurrentTaskState(with: .started)
currentOperation = room?.setName(roomName, completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
})
}
private func update(topic: String) {
updateCurrentTaskState(with: .started)
currentOperation = room?.setTopic(topic, completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
})
}
private func update(canonicalAlias: String) {
updateCurrentTaskState(with: .started)
currentOperation = room?.addAlias(MXTools.fullLocalAlias(from: canonicalAlias, with: session), completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
if let publicAddress = self.publicAddress {
self.currentOperation = self.room?.removeAlias(MXTools.fullLocalAlias(from: publicAddress, with: self.session), completion: { [weak self] response in
guard let self = self else { return }
self.setup(canonicalAlias: canonicalAlias)
})
} else {
self.setup(canonicalAlias: canonicalAlias)
}
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
}
})
}
private func setup(canonicalAlias: String) {
currentOperation = room?.setCanonicalAlias(MXTools.fullLocalAlias(from: canonicalAlias, with: session), completion: { [weak self] response in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
})
}
private func upload(avatar: UIImage) {
updateCurrentTaskState(with: .started)
let avatarUp = MXKTools.forceImageOrientationUp(avatar)
mediaUploader.uploadData(avatarUp?.jpegData(compressionQuality: 0.5), filename: nil, mimeType: "image/jpeg",
success: { [weak self] (urlString) in
guard let self = self else { return }
guard let urlString = urlString else {
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
return
}
guard let url = URL(string: urlString) else {
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
return
}
self.setAvatar(withURL: url)
},
failure: { [weak self] (error) in
guard let self = self else { return }
self.lastError = error
self.updateCurrentTaskState(with: .failure)
self.runNextTask()
})
}
private func setAvatar(withURL url: URL) {
currentOperation = room?.setAvatar(url: url) { [weak self] (response) in
guard let self = self else { return }
switch response {
case .success:
self.updateCurrentTaskState(with: .success)
case .failure(let error):
self.lastError = error
self.updateCurrentTaskState(with: .failure)
}
self.runNextTask()
}
}
}

View file

@ -0,0 +1,56 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
@available(iOS 14.0, *)
class MockSpaceSettingsService: SpaceSettingsServiceProtocol {
var spaceId: String
var roomProperties: SpaceSettingsRoomProperties?
private(set) var displayName: String?
var roomPropertiesSubject: CurrentValueSubject<SpaceSettingsRoomProperties?, Never>
private(set) var isLoadingSubject: CurrentValueSubject<Bool, Never>
private(set) var showPostProcessAlert: CurrentValueSubject<Bool, Never>
private(set) var addressValidationSubject: CurrentValueSubject<SpaceCreationSettingsAddressValidationStatus, Never>
init(spaceId: String = "!\(UUID().uuidString):matrix.org",
roomProperties: SpaceSettingsRoomProperties? = nil,
displayName: String? = nil,
isLoading: Bool = false,
showPostProcessAlert: Bool = false) {
self.spaceId = spaceId
self.roomProperties = roomProperties
self.displayName = displayName
self.isLoadingSubject = CurrentValueSubject(isLoading)
self.showPostProcessAlert = CurrentValueSubject(showPostProcessAlert)
self.roomPropertiesSubject = CurrentValueSubject(roomProperties)
self.addressValidationSubject = CurrentValueSubject(.none(spaceId))
}
func update(roomName: String, topic: String, address: String, avatar: UIImage?, completion: ((SpaceSettingsServiceCompletionResult) -> Void)?) {
}
func addressDidChange(_ newValue: String) {
}
func simulateUpdate(addressValidationStatus: SpaceCreationSettingsAddressValidationStatus) {
self.addressValidationSubject.value = addressValidationStatus
}
}

View file

@ -0,0 +1,49 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import Combine
enum SpaceSettingsServiceCompletionResult {
case success
case failure(Error)
}
@available(iOS 14.0, *)
protocol SpaceSettingsServiceProtocol: Avatarable {
var spaceId: String { get }
var roomProperties: SpaceSettingsRoomProperties? { get }
var isLoadingSubject: CurrentValueSubject<Bool, Never> { get }
var roomPropertiesSubject: CurrentValueSubject<SpaceSettingsRoomProperties?, Never> { get }
var showPostProcessAlert: CurrentValueSubject<Bool, Never> { get }
var addressValidationSubject: CurrentValueSubject<SpaceCreationSettingsAddressValidationStatus, Never> { get }
func update(roomName: String, topic: String, address: String, avatar: UIImage?, completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?)
func addressDidChange(_ newValue: String)
}
// MARK: Avatarable
@available(iOS 14.0, *)
extension SpaceSettingsServiceProtocol {
var mxContentUri: String? {
roomProperties?.avatarUrl
}
var matrixItemId: String {
spaceId
}
}

View file

@ -0,0 +1,123 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
// MARK: - Coordinator
enum SpaceSettingsCoordinatorResult {
case cancel
case done
case optionScreen(_ optionType: SpaceSettingsOptionType)
}
// MARK: View model
enum SpaceSettingsViewModelResult {
case cancel
case done
case optionScreen(_ optionType: SpaceSettingsOptionType)
case pickImage(_ sourceRect: CGRect)
}
// MARK: View
enum SpaceSettingsVisibility: CaseIterable {
case `private`
case restricted
case `public`
var stringValue: String {
switch self {
case .private:
return VectorL10n.private
case .public:
return VectorL10n.public
case .restricted:
return VectorL10n.createRoomTypeRestricted
}
}
}
struct SpaceSettingsRoomProperties {
let name: String?
let topic: String?
let address: String?
let avatarUrl: String?
let visibility: SpaceSettingsVisibility
let allowedParentIds: [String]
let isAvatarEditable: Bool
let isNameEditable: Bool
let isTopicEditable: Bool
let isAddressEditable: Bool
let isAccessEditable: Bool
}
struct SpaceSettingsViewState: BindableState {
let defaultAddress: String
let avatar: AvatarInputProtocol
var roomProperties: SpaceSettingsRoomProperties?
var userSelectedAvatar: UIImage?
var showRoomAddress: Bool
let roomNameError: String?
var addressMessage: String?
var isAddressValid: Bool
var isLoading: Bool
var visibilityString: String
var options: [SpaceSettingsOption]
var isModified: Bool {
userSelectedAvatar != nil || isRoomNameModified || isTopicModified || isAddressModified
}
var isRoomNameModified: Bool {
(roomProperties?.name ?? "") != bindings.name
}
var isTopicModified: Bool {
(roomProperties?.topic ?? "") != bindings.topic
}
var isAddressModified: Bool {
(roomProperties?.address ?? "") != bindings.address
}
var bindings: SpaceSettingsViewModelBindings
}
struct SpaceSettingsViewModelBindings {
var name: String
var topic: String
var address: String
var showPostProcessAlert: Bool
}
struct SpaceSettingsOption: Identifiable {
let id: SpaceSettingsOptionType
let icon: UIImage?
let title: String?
let value: String?
let isEnabled: Bool
}
enum SpaceSettingsOptionType {
case visibility
case rooms
case members
}
enum SpaceSettingsViewAction {
case done(_ name: String, _ topic: String, _ address: String, _ userSelectedAvatar: UIImage?)
case cancel
case pickImage(_ sourceRect: CGRect)
case optionSelected(_ optionType: SpaceSettingsOptionType)
case addressChanged(_ newValue: String)
}

View file

@ -0,0 +1,155 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
import Combine
@available(iOS 14, *)
typealias SpaceSettingsViewModelType = StateStoreViewModel<SpaceSettingsViewState,
Never,
SpaceSettingsViewAction>
@available(iOS 14, *)
class SpaceSettingsViewModel: SpaceSettingsViewModelType, SpaceSettingsViewModelProtocol {
// MARK: - Properties
private static let options: [SpaceSettingsOption] = [
SpaceSettingsOption(id: .rooms, icon: Asset.Images.spaceRoomIcon.image, title: VectorL10n.titleRooms, value: nil, isEnabled: true),
SpaceSettingsOption(id: .members, icon: Asset.Images.spaceMenuMembers.image, title: VectorL10n.roomDetailsPeople, value: nil, isEnabled: true)
]
// MARK: Private
private let service: SpaceSettingsServiceProtocol
// MARK: Public
var completion: ((SpaceSettingsViewModelResult) -> Void)?
// MARK: - Setup
static func makeSpaceSettingsViewModel(service: SpaceSettingsServiceProtocol) -> SpaceSettingsViewModelProtocol {
return SpaceSettingsViewModel(service: service)
}
private init(service: SpaceSettingsServiceProtocol) {
self.service = service
super.init(initialViewState: Self.defaultState(with: service, validationStatus: service.addressValidationSubject.value))
setupObservers()
}
private static func defaultState(with service: SpaceSettingsServiceProtocol, validationStatus: SpaceCreationSettingsAddressValidationStatus) -> SpaceSettingsViewState {
let bindings = SpaceSettingsViewModelBindings(
name: service.roomProperties?.name ?? "",
topic: service.roomProperties?.topic ?? "",
address: service.roomProperties?.address ?? "",
showPostProcessAlert: service.showPostProcessAlert.value)
return SpaceSettingsViewState(
defaultAddress: service.roomProperties?.address ?? "",
avatar: AvatarInput(mxContentUri: service.mxContentUri, matrixItemId: service.matrixItemId, displayName: service.displayName),
roomProperties: service.roomProperties,
userSelectedAvatar: nil,
showRoomAddress: service.roomProperties?.visibility == .public,
roomNameError: nil,
addressMessage: validationStatus.message,
isAddressValid: validationStatus.isValid,
isLoading: service.isLoadingSubject.value,
visibilityString: (service.roomProperties?.visibility ?? .private).stringValue,
options: options,
bindings: bindings)
}
private func setupObservers() {
service.isLoadingSubject.sink { [weak self] isLoading in
self?.state.isLoading = isLoading
}
.store(in: &cancellables)
service.showPostProcessAlert.sink { [weak self] showPostProcessAlert in
self?.state.bindings.showPostProcessAlert = showPostProcessAlert
}
.store(in: &cancellables)
service.roomPropertiesSubject.sink { [weak self] roomProperties in
guard let roomProperties = roomProperties, let self = self else {
return
}
self.propertiesUpdated(roomProperties)
}
.store(in: &cancellables)
service.addressValidationSubject.sink { [weak self] validationStatus in
self?.state.addressMessage = validationStatus.message
self?.state.isAddressValid = validationStatus.isValid
}
.store(in: &cancellables)
}
// MARK: - Public
override func process(viewAction: SpaceSettingsViewAction) {
switch viewAction {
case .cancel:
cancel()
case .pickImage(let sourceRect):
completion?(.pickImage(sourceRect))
case .optionSelected(let optionType):
completion?(.optionScreen(optionType))
case .done(let name, let topic, let address, let userSelectedAvatar):
service.update(roomName: name, topic: topic, address: address, avatar: userSelectedAvatar) { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.done()
case .failure:
break
}
}
case .addressChanged(let newValue):
service.addressDidChange(newValue)
}
}
func updateAvatarImage(with image: UIImage?) {
state.userSelectedAvatar = image
}
private func propertiesUpdated(_ roomProperties: SpaceSettingsRoomProperties) {
state.roomProperties = roomProperties
if !state.isRoomNameModified {
state.bindings.name = roomProperties.name ?? ""
}
if !state.isTopicModified {
state.bindings.topic = roomProperties.topic ?? ""
}
if !state.isAddressModified {
state.bindings.address = roomProperties.address ?? ""
}
state.visibilityString = roomProperties.visibility.stringValue
state.showRoomAddress = roomProperties.visibility == .public
}
private func done() {
completion?(.done)
}
private func cancel() {
completion?(.cancel)
}
}

View file

@ -0,0 +1,27 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol SpaceSettingsViewModelProtocol {
var completion: ((SpaceSettingsViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
static func makeSpaceSettingsViewModel(service: SpaceSettingsServiceProtocol) -> SpaceSettingsViewModelProtocol
@available(iOS 14, *)
var context: SpaceSettingsViewModelType.Context { get }
func updateAvatarImage(with image: UIImage?)
}

View file

@ -0,0 +1,35 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import RiotSwiftUI
@available(iOS 14.0, *)
class SpaceSettingsUITests: MockScreenTest {
override class var screenType: MockScreenState.Type {
return MockSpaceSettingsScreenState.self
}
override class func createTest() -> MockScreenTest {
return SpaceSettingsUITests(selector: #selector(verifySpaceSettingsScreen))
}
func verifySpaceSettingsScreen() throws {
guard let screenState = screenState as? MockSpaceSettingsScreenState else { fatalError("no screen") }
}
}

View file

@ -0,0 +1,60 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
import Combine
@testable import RiotSwiftUI
@available(iOS 14.0, *)
class SpaceSettingsViewModelTests: XCTestCase {
let creationParameters = SpaceCreationParameters()
var service: MockSpaceSettingsService!
var viewModel: SpaceSettingsViewModelProtocol!
var context: SpaceSettingsViewModelType.Context!
var cancellables = Set<AnyCancellable>()
override func setUpWithError() throws {
let roomProperties = SpaceSettingsRoomProperties(
name: "Space Name",
topic: "Sapce topic",
address: "#fake:matrix.org",
avatarUrl: nil,
visibility: .public,
allowedParentIds: [],
isAvatarEditable: true,
isNameEditable: true,
isTopicEditable: true,
isAddressEditable: true,
isAccessEditable: true)
service = MockSpaceSettingsService(roomProperties: roomProperties, displayName: roomProperties.name, isLoading: false, showPostProcessAlert: false)
viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: service)
context = viewModel.context
}
func testAddressAlready() throws {
service.simulateUpdate(addressValidationStatus: .alreadyExists("#fake:matrix.org"))
XCTAssertEqual(context.viewState.isAddressValid, false)
XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressAlreadyExists("#fake:matrix.org"))
}
func testInvalidAddress() throws {
service.simulateUpdate(addressValidationStatus: .invalidCharacters("#fake:matrix.org"))
XCTAssertEqual(context.viewState.isAddressValid, false)
XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressInvalidCharacters("#fake:matrix.org"))
}
}

View file

@ -0,0 +1,206 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct SpaceSettings: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: SpaceSettingsViewModel.Context
var body: some View {
ScrollView {
VStack {
avatarView
Spacer().frame(height:32)
formView
roomAccess
options
.padding(.bottom, 32)
}
}
.background(theme.colors.navigation)
.waitOverlay(show: viewModel.viewState.isLoading, allowUserInteraction: false)
.ignoresSafeArea(.container, edges: .bottom)
.frame(maxHeight: .infinity)
.navigationBarBackButtonHidden(true)
.navigationTitle(VectorL10n.settingsTitle)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(VectorL10n.done) {
updateSpace()
}
.disabled(!viewModel.viewState.isModified || !viewModel.viewState.isAddressValid)
}
ToolbarItem(placement: .cancellationAction) {
Button(VectorL10n.cancel) {
viewModel.send(viewAction: .cancel)
}
}
}
.accentColor(theme.colors.accent)
.alert(isPresented: $viewModel.showPostProcessAlert, content: {
Alert(title: Text(VectorL10n.settingsTitle),
message: Text(VectorL10n.spaceSettingsUpdateFailedMessage),
primaryButton: .default(Text(VectorL10n.retry), action: {
updateSpace()
}),
secondaryButton: .cancel())
})
}
// MARK: - Private
@ViewBuilder
private var avatarView: some View {
ZStack(alignment: .bottomTrailing) {
GeometryReader { reader in
ZStack {
SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge)
.padding(6)
if let image = viewModel.viewState.userSelectedAvatar {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(width: 80, height: 80, alignment: .center)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}.padding(10)
.onTapGesture {
guard viewModel.viewState.roomProperties?.isAvatarEditable == true else {
return
}
ResponderManager.resignFirstResponder()
viewModel.send(viewAction: .pickImage(reader.frame(in: .global)))
}
}
if viewModel.viewState.roomProperties?.isAvatarEditable == true {
Image(uiImage: Asset.Images.spaceCreationCamera.image)
.renderingMode(.template)
.foregroundColor(theme.colors.secondaryContent)
.frame(width: 32, height: 32, alignment: .center)
.background(theme.colors.background)
.clipShape(Circle())
}
}.frame(width: 104, height: 104)
}
@ViewBuilder
private var formView: some View {
VStack{
RoundedBorderTextField(
title: VectorL10n.createRoomPlaceholderName,
placeHolder: "",
text: $viewModel.name,
footerText: .constant(viewModel.viewState.roomNameError),
isError: .constant(true),
configuration: UIKitTextInputConfiguration( returnKeyType: .next))
.padding(.horizontal, 2)
.padding(.bottom, 20)
.disabled(viewModel.viewState.roomProperties?.isNameEditable != true)
RoundedBorderTextEditor(
title: VectorL10n.spaceTopic,
placeHolder: VectorL10n.spaceTopic,
text: $viewModel.topic,
textMaxHeight: 72,
error: .constant(nil))
.padding(.horizontal, 2)
.padding(.bottom, viewModel.viewState.showRoomAddress ? 20 : 3)
.disabled(viewModel.viewState.roomProperties?.isTopicEditable != true)
if viewModel.viewState.showRoomAddress {
RoundedBorderTextField(
title: VectorL10n.spacesCreationAddress,
placeHolder: "# \(viewModel.viewState.defaultAddress)",
text: $viewModel.address,
footerText: .constant(viewModel.viewState.addressMessage),
isError: .constant(!viewModel.viewState.isAddressValid),
configuration: UIKitTextInputConfiguration(keyboardType: .URL, returnKeyType: .done, autocapitalizationType: .none), onTextChanged: {
newText in
viewModel.send(viewAction: .addressChanged(newText))
})
.disabled(viewModel.viewState.roomProperties?.isAddressEditable != true)
.padding(.horizontal, 2)
.padding(.bottom, 3)
.accessibility(identifier: "addressTextField")
}
}
.padding(.horizontal)
}
@ViewBuilder
private var roomAccess: some View {
VStack(alignment: .leading) {
Spacer().frame(height:24)
Text(VectorL10n.spaceSettingsAccessSection)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.leading)
.padding(.bottom, 4)
SpaceSettingsOptionListItem(
title: VectorL10n.roomDetailsAccessRowTitle,
value: viewModel.viewState.visibilityString) {
ResponderManager.resignFirstResponder()
viewModel.send(viewAction: .optionSelected(.visibility))
}
.disabled(viewModel.viewState.roomProperties?.isAccessEditable != true)
}
}
@ViewBuilder
private var options: some View {
VStack(alignment: .leading, spacing: 1) {
Spacer().frame(height: 50)
Text(VectorL10n.settingsTitle.uppercased())
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.leading)
.padding(.bottom, 8)
ForEach(viewModel.viewState.options) { option in
SpaceSettingsOptionListItem(
icon: option.icon,
title: option.title,
value: option.value) {
ResponderManager.resignFirstResponder()
viewModel.send(viewAction: .optionSelected(option.id))
}
.disabled(!option.isEnabled)
}
}
}
private func updateSpace() {
viewModel.send(viewAction: .done(viewModel.name, viewModel.topic, viewModel.address, viewModel.viewState.userSelectedAvatar))
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct SpaceSettings_Previews: PreviewProvider {
static let stateRenderer = MockSpaceSettingsScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
stateRenderer.screenGroup().theme(.dark).preferredColorScheme(.dark)
}
}

View file

@ -0,0 +1,106 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14.0, *)
struct SpaceSettingsOptionListItem: View {
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
@Environment(\.isEnabled) private var isEnabled
// MARK: - Properties
let icon: UIImage?
let title: String?
let value: String?
let action: (() -> Void)?
// MARK: - Setup
init(icon: UIImage? = nil,
title: String? = nil,
value: String? = nil,
action: (() -> Void)? = nil) {
self.icon = icon
self.title = title
self.value = value
self.action = action
}
// MARK: - Public
var body: some View {
ZStack {
HStack(alignment: .center, spacing: 16) {
if let icon = icon {
Image(uiImage: icon)
.renderingMode(.template)
.frame(width: 22, height: 22)
.foregroundColor(theme.colors.tertiaryContent)
}
if let title = title {
Text(title)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
}
Spacer()
if let value = value {
Text(value)
.font(theme.fonts.body)
.foregroundColor(theme.colors.tertiaryContent)
}
Image(systemName: "chevron.right")
.renderingMode(.template)
.font(.system(size: 16, weight: .regular))
.foregroundColor(theme.colors.quarterlyContent)
}
.opacity(isEnabled ? 1 : 0.5)
}
.frame(height: 44)
.padding(.horizontal)
.background(theme.colors.background)
.onTapGesture {
if isEnabled {
action?()
}
}
}
}
// MARK: - Previews
@available(iOS 14.0, *)
struct SpaceSettingsOptionListItem_Previews: PreviewProvider {
static var previews: some View {
sampleView.theme(.light).preferredColorScheme(.light)
sampleView.theme(.dark).preferredColorScheme(.dark)
}
static var sampleView: some View {
VStack(spacing: 8) {
SpaceSettingsOptionListItem(icon: nil, title: "Some Title", value: nil)
SpaceSettingsOptionListItem(icon: nil, title: "Some Title", value: "Some value")
SpaceSettingsOptionListItem(icon: Asset.Images.spaceRoomIcon.image, title: "Some Title", value: "Some value")
SpaceSettingsOptionListItem(icon: Asset.Images.spaceRoomIcon.image, title: "Some Title", value: "Some value")
.disabled(true)
}
}
}

1
changelog.d/5233.feature Normal file
View file

@ -0,0 +1 @@
Space Settings