Room's details edit screen (#956)

* Add RoomDetailsEditScreen template

* Add navigation to the edit screen

* Delete template code

* Start RoomDetailsEditScreen UI

* Manage power levels in RoomDetailsScreen

* Start RoomDetailsEditScreenViewModel logic

* Inject initial room state

* Add cancel action

* Expose set name/topic APIs in RoomProxy

* Refine RoomDetailsEditScreen UI

* Add save logic

* Add localisations

* Fix avatar image

* Update localisations

* Add “Add topic” button

* Add feature flag

* Add dismiss on save logic

* Reduce throttling

* Improve form logic

* Fix UT build errors

* Add media sheet

* Add media preprocessing

* Add LoadableEditableAvatarImage

* Add condition on delete image

* Add avatar save button logic

* Add remove avatar logic

* Cleanup

* Fix edit bug in DM

* Add upload avatar

* Add focus

* Add RoomDetailsViewModel UTs

* Fix button style

* Add UTs

* Add empty topic ui test

* Fix iPad sheet presentation

* Revert topic appearance in room’s details

* Address PR comments

* Add UI tests
This commit is contained in:
Alfonso Grillo
2023-05-26 15:47:12 +02:00
committed by GitHub
parent 524997438f
commit f2b7faa183
47 changed files with 1281 additions and 54 deletions
@@ -29,6 +29,7 @@ final class AppSettings {
case shouldCollapseRoomStateEvents
case startChatFlowEnabled
case startChatUserSuggestionsEnabled
case editRoomDetailsFlowEnabled
case invitesFlowEnabled
case inviteMorePeopleFlowEnabled
case readReceiptsEnabled
@@ -196,4 +197,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.readReceiptsEnabled, defaultValue: false, storageType: .userDefaults(store))
var readReceiptsEnabled
// MARK: Room details edit
@UserPreference(key: UserDefaultsKeys.editRoomDetailsFlowEnabled, defaultValue: false, storageType: .userDefaults(store))
var editRoomDetailsFlowEnabled
}
+11 -3
View File
@@ -154,6 +154,8 @@ public enum L10n {
public static var commonGif: String { return L10n.tr("Localizable", "common_gif") }
/// Image
public static var commonImage: String { return L10n.tr("Localizable", "common_image") }
/// We can’t validate this user’s Matrix ID. The invite might not be received.
public static var commonInviteUnknownProfile: String { return L10n.tr("Localizable", "common_invite_unknown_profile") }
/// Leaving room
public static var commonLeavingRoom: String { return L10n.tr("Localizable", "common_leaving_room") }
/// Link copied to clipboard
@@ -192,6 +194,8 @@ public enum L10n {
public static var commonReportABug: String { return L10n.tr("Localizable", "common_report_a_bug") }
/// Report submitted
public static var commonReportSubmitted: String { return L10n.tr("Localizable", "common_report_submitted") }
/// Room name
public static var commonRoomName: String { return L10n.tr("Localizable", "common_room_name") }
/// Search for someone
public static var commonSearchForSomeone: String { return L10n.tr("Localizable", "common_search_for_someone") }
/// Search results
@@ -586,12 +590,18 @@ public enum L10n {
public static var screenRoomAttachmentSourceFiles: String { return L10n.tr("Localizable", "screen_room_attachment_source_files") }
/// Photo & Video Library
public static var screenRoomAttachmentSourceGallery: String { return L10n.tr("Localizable", "screen_room_attachment_source_gallery") }
/// Add topic
public static var screenRoomDetailsAddTopicTitle: String { return L10n.tr("Localizable", "screen_room_details_add_topic_title") }
/// Already a member
public static var screenRoomDetailsAlreadyAMember: String { return L10n.tr("Localizable", "screen_room_details_already_a_member") }
/// Already invited
public static var screenRoomDetailsAlreadyInvited: String { return L10n.tr("Localizable", "screen_room_details_already_invited") }
/// An error occurred when updating the room details
/// Edit Room
public static var screenRoomDetailsEditRoomTitle: String { return L10n.tr("Localizable", "screen_room_details_edit_room_title") }
/// We were unable to update all the information for this room.
public static var screenRoomDetailsEditionError: String { return L10n.tr("Localizable", "screen_room_details_edition_error") }
/// Unable to update room
public static var screenRoomDetailsEditionErrorTitle: String { return L10n.tr("Localizable", "screen_room_details_edition_error_title") }
/// Messages are secured with locks. Only you and the recipients have the unique keys to unlock them.
public static var screenRoomDetailsEncryptionEnabledSubtitle: String { return L10n.tr("Localizable", "screen_room_details_encryption_enabled_subtitle") }
/// Message encryption enabled
@@ -680,8 +690,6 @@ public enum L10n {
public static var screenSignoutPreferenceItem: String { return L10n.tr("Localizable", "screen_signout_preference_item") }
/// An error occurred when trying to start a chat
public static var screenStartChatErrorStartingChat: String { return L10n.tr("Localizable", "screen_start_chat_error_starting_chat") }
/// We can’t validate this user’s Matrix ID. The invite might not be received.
public static var screenStartChatUnknownProfile: String { return L10n.tr("Localizable", "screen_start_chat_unknown_profile") }
/// Looks like you’re using a new device. Verify it’s you to access your encrypted messages.
public static var sessionVerificationBannerMessage: String { return L10n.tr("Localizable", "session_verification_banner_message") }
/// Access your message history
@@ -371,6 +371,27 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol {
return unignoreUserReturnValue
}
}
//MARK: - canSendStateEvent
var canSendStateEventTypeCallsCount = 0
var canSendStateEventTypeCalled: Bool {
return canSendStateEventTypeCallsCount > 0
}
var canSendStateEventTypeReceivedType: StateEventType?
var canSendStateEventTypeReceivedInvocations: [StateEventType] = []
var canSendStateEventTypeReturnValue: Bool!
var canSendStateEventTypeClosure: ((StateEventType) -> Bool)?
func canSendStateEvent(type: StateEventType) -> Bool {
canSendStateEventTypeCallsCount += 1
canSendStateEventTypeReceivedType = type
canSendStateEventTypeReceivedInvocations.append(type)
if let canSendStateEventTypeClosure = canSendStateEventTypeClosure {
return canSendStateEventTypeClosure(type)
} else {
return canSendStateEventTypeReturnValue
}
}
}
class RoomProxyMock: RoomProxyProtocol {
var id: String {
@@ -419,11 +440,6 @@ class RoomProxyMock: RoomProxyProtocol {
set(value) { underlyingMembersPublisher = value }
}
var underlyingMembersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never>!
var updatesPublisher: AnyPublisher<TimelineDiff, Never> {
get { return underlyingUpdatesPublisher }
set(value) { underlyingUpdatesPublisher = value }
}
var underlyingUpdatesPublisher: AnyPublisher<TimelineDiff, Never>!
var invitedMembersCount: UInt {
get { return underlyingInvitedMembersCount }
set(value) { underlyingInvitedMembersCount = value }
@@ -439,6 +455,11 @@ class RoomProxyMock: RoomProxyProtocol {
set(value) { underlyingActiveMembersCount = value }
}
var underlyingActiveMembersCount: UInt!
var updatesPublisher: AnyPublisher<TimelineDiff, Never> {
get { return underlyingUpdatesPublisher }
set(value) { underlyingUpdatesPublisher = value }
}
var underlyingUpdatesPublisher: AnyPublisher<TimelineDiff, Never>!
//MARK: - loadAvatarURLForUserId
@@ -884,6 +905,86 @@ class RoomProxyMock: RoomProxyProtocol {
return inviteUserIDReturnValue
}
}
//MARK: - setName
var setNameCallsCount = 0
var setNameCalled: Bool {
return setNameCallsCount > 0
}
var setNameReceivedName: String?
var setNameReceivedInvocations: [String?] = []
var setNameReturnValue: Result<Void, RoomProxyError>!
var setNameClosure: ((String?) async -> Result<Void, RoomProxyError>)?
func setName(_ name: String?) async -> Result<Void, RoomProxyError> {
setNameCallsCount += 1
setNameReceivedName = name
setNameReceivedInvocations.append(name)
if let setNameClosure = setNameClosure {
return await setNameClosure(name)
} else {
return setNameReturnValue
}
}
//MARK: - setTopic
var setTopicCallsCount = 0
var setTopicCalled: Bool {
return setTopicCallsCount > 0
}
var setTopicReceivedTopic: String?
var setTopicReceivedInvocations: [String] = []
var setTopicReturnValue: Result<Void, RoomProxyError>!
var setTopicClosure: ((String) async -> Result<Void, RoomProxyError>)?
func setTopic(_ topic: String) async -> Result<Void, RoomProxyError> {
setTopicCallsCount += 1
setTopicReceivedTopic = topic
setTopicReceivedInvocations.append(topic)
if let setTopicClosure = setTopicClosure {
return await setTopicClosure(topic)
} else {
return setTopicReturnValue
}
}
//MARK: - removeAvatar
var removeAvatarCallsCount = 0
var removeAvatarCalled: Bool {
return removeAvatarCallsCount > 0
}
var removeAvatarReturnValue: Result<Void, RoomProxyError>!
var removeAvatarClosure: (() async -> Result<Void, RoomProxyError>)?
func removeAvatar() async -> Result<Void, RoomProxyError> {
removeAvatarCallsCount += 1
if let removeAvatarClosure = removeAvatarClosure {
return await removeAvatarClosure()
} else {
return removeAvatarReturnValue
}
}
//MARK: - uploadAvatar
var uploadAvatarMediaCallsCount = 0
var uploadAvatarMediaCalled: Bool {
return uploadAvatarMediaCallsCount > 0
}
var uploadAvatarMediaReceivedMedia: MediaInfo?
var uploadAvatarMediaReceivedInvocations: [MediaInfo] = []
var uploadAvatarMediaReturnValue: Result<Void, RoomProxyError>!
var uploadAvatarMediaClosure: ((MediaInfo) async -> Result<Void, RoomProxyError>)?
func uploadAvatar(media: MediaInfo) async -> Result<Void, RoomProxyError> {
uploadAvatarMediaCallsCount += 1
uploadAvatarMediaReceivedMedia = media
uploadAvatarMediaReceivedInvocations.append(media)
if let uploadAvatarMediaClosure = uploadAvatarMediaClosure {
return await uploadAvatarMediaClosure(media)
} else {
return uploadAvatarMediaReturnValue
}
}
}
class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol {
var callbacks: PassthroughSubject<SessionVerificationControllerProxyCallback, Never> {
@@ -23,11 +23,12 @@ struct RoomMemberProxyMockConfiguration {
var avatarURL: URL?
var membership: MembershipState
var isNameAmbiguous = false
var powerLevel: Int
var normalizedPowerLevel: Int
var powerLevel = 50
var normalizedPowerLevel = 50
var isAccountOwner = false
var isIgnored = false
var canInviteUsers = false
var canSendStateEvent: (StateEventType) -> Bool = { _ in true }
}
extension RoomMemberProxyMock {
@@ -43,6 +44,7 @@ extension RoomMemberProxyMock {
isAccountOwner = configuration.isAccountOwner
isIgnored = configuration.isIgnored
canInviteUsers = configuration.canInviteUsers
canSendStateEventTypeClosure = configuration.canSendStateEvent
}
// Mocks
@@ -50,45 +52,35 @@ extension RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org",
displayName: "Alice",
avatarURL: nil,
membership: .join,
powerLevel: 50,
normalizedPowerLevel: 50))
membership: .join))
}
static var mockInvitedAlice: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org",
displayName: "Alice",
avatarURL: nil,
membership: .invite,
powerLevel: 50,
normalizedPowerLevel: 50))
membership: .invite))
}
static var mockBob: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@bob:matrix.org",
displayName: "Bob",
avatarURL: nil,
membership: .join,
powerLevel: 50,
normalizedPowerLevel: 50))
membership: .join))
}
static var mockCharlie: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@charlie:matrix.org",
displayName: "Charlie",
avatarURL: nil,
membership: .join,
powerLevel: 50,
normalizedPowerLevel: 50))
membership: .join))
}
static var mockDan: RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@dan:matrix.org",
displayName: "Dan",
avatarURL: URL.picturesDirectory,
membership: .join,
powerLevel: 50,
normalizedPowerLevel: 50))
membership: .join))
}
static var mockMe: RoomMemberProxyMock {
@@ -96,8 +88,6 @@ extension RoomMemberProxyMock {
displayName: "Me",
avatarURL: URL.picturesDirectory,
membership: .join,
powerLevel: 50,
normalizedPowerLevel: 50,
isAccountOwner: true,
canInviteUsers: true))
}
@@ -107,8 +97,14 @@ extension RoomMemberProxyMock {
displayName: "Ignored",
avatarURL: nil,
membership: .join,
powerLevel: 50,
normalizedPowerLevel: 50,
isIgnored: true))
}
static func mockOwner(allowedStateEvents: [StateEventType]) -> RoomMemberProxyMock {
RoomMemberProxyMock(with: .init(userID: "@foo:some.org",
displayName: "User owner",
membership: .join,
isAccountOwner: true,
canSendStateEvent: { allowedStateEvents.contains($0) }))
}
}
+3 -1
View File
@@ -19,7 +19,7 @@ import Foundation
struct RoomProxyMockConfiguration {
var id = UUID().uuidString
let name: String? = nil
var name: String?
let displayName: String?
var topic: String?
var avatarURL: URL?
@@ -75,5 +75,7 @@ extension RoomProxyMock {
acceptInvitationClosure = { .success(()) }
registerTimelineListenerIfNeededClosure = { .success([]) }
underlyingUpdatesPublisher = Empty(completeImmediately: false).eraseToAnyPublisher()
setNameClosure = { _ in .success(()) }
setTopicClosure = { _ in .success(()) }
}
}
@@ -94,6 +94,7 @@ struct A11yIdentifiers {
}
struct RoomDetailsScreen {
let addTopic = "room_details-add_topic"
let avatar = "room_details-avatar"
let dmAvatar = "room_details-dm_avatar"
let people = "room_details-people"
@@ -0,0 +1,59 @@
//
// Copyright 2023 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 OverridableAvatarImage: View {
private let overrideURL: URL?
private let url: URL?
private let name: String?
private let contentID: String?
private let avatarSize: AvatarSize
private let imageProvider: ImageProviderProtocol?
@ScaledMetric private var frameSize: CGFloat
init(overrideURL: URL?, url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, imageProvider: ImageProviderProtocol?) {
self.overrideURL = overrideURL
self.url = url
self.name = name
self.contentID = contentID
self.avatarSize = avatarSize
self.imageProvider = imageProvider
_frameSize = ScaledMetric(wrappedValue: avatarSize.value)
}
var body: some View {
if let overrideURL {
AsyncImage(url: overrideURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(width: frameSize, height: frameSize)
.clipShape(Circle())
} else {
LoadableAvatarImage(url: url,
name: name,
contentID: contentID,
avatarSize: avatarSize,
imageProvider: imageProvider)
}
}
}
@@ -67,7 +67,7 @@ struct UserProfileCell: View {
Text(Image(systemName: "exclamationmark.circle.fill"))
.foregroundColor(.compound.textCriticalPrimary)
Text(L10n.screenStartChatUnknownProfile)
Text(L10n.commonInviteUnknownProfile)
.foregroundColor(.secondary)
}
.font(.compound.bodyXS)
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import Foundation
class MockUserIndicatorController: UserIndicatorControllerProtocol {
@@ -23,5 +24,11 @@ class MockUserIndicatorController: UserIndicatorControllerProtocol {
func retractAllIndicators() { }
var alertInfo: AlertInfo<UUID>?
var alertInfo: AlertInfo<UUID>? {
didSet {
alertInfoPublisher.send(alertInfo)
}
}
let alertInfoPublisher: PassthroughSubject<AlertInfo<UUID>?, Never> = .init()
}
@@ -30,6 +30,7 @@ struct DeveloperOptionsScreenViewStateBindings {
var startChatUserSuggestionsEnabled: Bool
var invitesFlowEnabled: Bool
var inviteMorePeopleFlowEnabled: Bool
var editRoomDetailsFlowEnabled: Bool
}
enum DeveloperOptionsScreenViewAction {
@@ -38,5 +39,6 @@ enum DeveloperOptionsScreenViewAction {
case changedStartChatUserSuggestionsEnabled
case changedInvitesFlowEnabled
case changedInviteMorePeopleFlowEnabled
case changedEditRoomDetailsFlowEnabled
case clearCache
}
@@ -29,7 +29,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
startChatFlowEnabled: appSettings.startChatFlowEnabled,
startChatUserSuggestionsEnabled: appSettings.startChatUserSuggestionsEnabled,
invitesFlowEnabled: appSettings.invitesFlowEnabled,
inviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled)
inviteMorePeopleFlowEnabled: appSettings.inviteMorePeopleFlowEnabled,
editRoomDetailsFlowEnabled: appSettings.editRoomDetailsFlowEnabled)
let state = DeveloperOptionsScreenViewState(bindings: bindings)
super.init(initialViewState: state)
@@ -51,6 +52,8 @@ class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, Deve
appSettings.invitesFlowEnabled = state.bindings.invitesFlowEnabled
case .changedInviteMorePeopleFlowEnabled:
appSettings.inviteMorePeopleFlowEnabled = state.bindings.inviteMorePeopleFlowEnabled
case .changedEditRoomDetailsFlowEnabled:
appSettings.editRoomDetailsFlowEnabled = state.bindings.editRoomDetailsFlowEnabled
case .clearCache:
callback?(.clearCache)
}
@@ -57,6 +57,13 @@ struct DeveloperOptionsScreen: View {
.onChange(of: context.inviteMorePeopleFlowEnabled) { _ in
context.send(viewAction: .changedInviteMorePeopleFlowEnabled)
}
Toggle(isOn: $context.editRoomDetailsFlowEnabled) {
Text("Show edit room details flow")
}
.onChange(of: context.editRoomDetailsFlowEnabled) { _ in
context.send(viewAction: .changedEditRoomDetailsFlowEnabled)
}
}
Section {
@@ -0,0 +1,90 @@
//
// Copyright 2022 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 Combine
import SwiftUI
struct RoomDetailsEditScreenCoordinatorParameters {
let accountOwner: RoomMemberProxyProtocol
let mediaProvider: MediaProviderProtocol
let navigationStackCoordinator: NavigationStackCoordinator
let roomProxy: RoomProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum RoomDetailsEditScreenCoordinatorAction {
case dismiss
}
final class RoomDetailsEditScreenCoordinator: CoordinatorProtocol {
private let parameters: RoomDetailsEditScreenCoordinatorParameters
private var viewModel: RoomDetailsEditScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<RoomDetailsEditScreenCoordinatorAction, Never> = .init()
private var cancellables: Set<AnyCancellable> = .init()
var actions: AnyPublisher<RoomDetailsEditScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: RoomDetailsEditScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = RoomDetailsEditScreenViewModel(accountOwner: parameters.accountOwner,
mediaProvider: parameters.mediaProvider,
roomProxy: parameters.roomProxy,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
viewModel.actions
.sink { [weak self] action in
switch action {
case .cancel, .saveFinished:
self?.actionsSubject.send(.dismiss)
case .displayCameraPicker:
self?.displayMediaPickerWithSource(.camera)
case .displayMediaPicker:
self?.displayMediaPickerWithSource(.photoLibrary)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(RoomDetailsEditScreen(context: viewModel.context))
}
// MARK: Private
private func displayMediaPickerWithSource(_ source: MediaPickerScreenSource) {
let stackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: stackCoordinator)
let mediaPickerCoordinator = MediaPickerScreenCoordinator(userIndicatorController: userIndicatorController, source: source) { [weak self] action in
guard let self else { return }
switch action {
case .cancel:
parameters.navigationStackCoordinator.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
parameters.navigationStackCoordinator.setSheetCoordinator(nil)
viewModel.didSelectMediaUrl(url: url)
}
}
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
}
@@ -0,0 +1,73 @@
//
// Copyright 2022 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
enum RoomDetailsEditScreenViewModelAction {
case cancel
case saveFinished
case displayCameraPicker
case displayMediaPicker
}
struct RoomDetailsEditScreenViewStateBindings {
var name: String
var topic: String
var showMediaSheet = false
}
struct RoomDetailsEditScreenViewState: BindableState {
let roomID: String
let initialAvatarURL: URL?
let initialName: String
let initialTopic: String
let canEditAvatar: Bool
let canEditName: Bool
let canEditTopic: Bool
var avatarURL: URL?
var localMedia: MediaInfo?
var bindings: RoomDetailsEditScreenViewStateBindings
var nameDidChange: Bool {
bindings.name != initialName
}
var topicDidChange: Bool {
bindings.topic != initialTopic
}
var avatarDidChange: Bool {
localMedia != nil || avatarURL != initialAvatarURL
}
var canSave: Bool {
!bindings.name.isEmpty && (avatarDidChange || nameDidChange || topicDidChange)
}
var showDeleteImageAction: Bool {
localMedia != nil || avatarURL != nil
}
}
enum RoomDetailsEditScreenViewAction {
case cancel
case save
case presentMediaSource
case displayCameraPicker
case displayMediaPicker
case removeImage
}
@@ -0,0 +1,141 @@
//
// Copyright 2022 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 Combine
import SwiftUI
typealias RoomDetailsEditScreenViewModelType = StateStoreViewModel<RoomDetailsEditScreenViewState, RoomDetailsEditScreenViewAction>
class RoomDetailsEditScreenViewModel: RoomDetailsEditScreenViewModelType, RoomDetailsEditScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<RoomDetailsEditScreenViewModelAction, Never> = .init()
private let roomProxy: RoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let mediaPreprocessor: MediaUploadingPreprocessor = .init()
var actions: AnyPublisher<RoomDetailsEditScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(accountOwner: RoomMemberProxyProtocol,
mediaProvider: MediaProviderProtocol,
roomProxy: RoomProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
let roomAvatar = roomProxy.avatarURL
let roomName = roomProxy.name
let roomTopic = roomProxy.topic
super.init(initialViewState: RoomDetailsEditScreenViewState(roomID: roomProxy.id,
initialAvatarURL: roomAvatar,
initialName: roomName ?? "",
initialTopic: roomTopic ?? "",
canEditAvatar: accountOwner.canSendStateEvent(type: .roomAvatar),
canEditName: accountOwner.canSendStateEvent(type: .roomName),
canEditTopic: accountOwner.canSendStateEvent(type: .roomTopic),
avatarURL: roomAvatar,
bindings: .init(name: roomName ?? "", topic: roomTopic ?? "")), imageProvider: mediaProvider)
}
// MARK: - Public
override func process(viewAction: RoomDetailsEditScreenViewAction) {
switch viewAction {
case .cancel:
actionsSubject.send(.cancel)
case .save:
saveRoomDetails()
case .presentMediaSource:
state.bindings.showMediaSheet = true
case .displayCameraPicker:
actionsSubject.send(.displayCameraPicker)
case .displayMediaPicker:
actionsSubject.send(.displayMediaPicker)
case .removeImage:
if state.localMedia != nil {
state.localMedia = nil
} else {
state.avatarURL = nil
}
}
}
func didSelectMediaUrl(url: URL) {
Task {
let userIndicatorID = UUID().uuidString
defer {
userIndicatorController.retractIndicatorWithId(userIndicatorID)
}
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal, title: L10n.commonLoading, persistent: true))
let mediaResult = await mediaPreprocessor.processMedia(at: url)
switch mediaResult {
case .success(.image):
state.localMedia = try? mediaResult.get()
case .failure, .success:
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError, message: L10n.errorUnknown)
}
}
}
// MARK: - Private
private func saveRoomDetails() {
Task {
let userIndicatorID = UUID().uuidString
defer {
userIndicatorController.retractIndicatorWithId(userIndicatorID)
}
userIndicatorController.submitIndicator(UserIndicator(id: userIndicatorID, type: .modal, title: L10n.screenRoomDetailsUpdatingRoom, persistent: true))
do {
try await withThrowingTaskGroup(of: Void.self) { group in
if state.avatarDidChange {
group.addTask {
if let localMedia = await self.state.localMedia {
try await self.roomProxy.uploadAvatar(media: localMedia).get()
} else if await self.state.avatarURL == nil {
try await self.roomProxy.removeAvatar().get()
}
}
}
if state.nameDidChange {
group.addTask {
try await self.roomProxy.setName(self.state.bindings.name).get()
}
}
if state.topicDidChange {
group.addTask {
try await self.roomProxy.setTopic(self.state.bindings.topic).get()
}
}
try await group.waitForAll()
}
actionsSubject.send(.saveFinished)
} catch {
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenRoomDetailsEditionErrorTitle,
message: L10n.screenRoomDetailsEditionError)
}
}
}
}
@@ -0,0 +1,26 @@
//
// Copyright 2022 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 Combine
import Foundation
@MainActor
protocol RoomDetailsEditScreenViewModelProtocol {
var actions: AnyPublisher<RoomDetailsEditScreenViewModelAction, Never> { get }
var context: RoomDetailsEditScreenViewModelType.Context { get }
func didSelectMediaUrl(url: URL)
}
@@ -0,0 +1,176 @@
//
// Copyright 2022 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 RoomDetailsEditScreen: View {
@ObservedObject var context: RoomDetailsEditScreenViewModel.Context
@FocusState private var focus: Focus?
private enum Focus {
case name
case topic
}
var body: some View {
mainContent
.scrollContentBackground(.hidden)
.background(Color.element.formBackground.ignoresSafeArea())
.scrollDismissesKeyboard(.immediately)
.navigationTitle(L10n.screenRoomDetailsEditRoomTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.actionSave) {
context.send(viewAction: .save)
focus = nil
}
.disabled(!context.viewState.canSave)
}
}
}
// MARK: - Private
private var mainContent: some View {
Form {
avatar
nameSection
topicSection
}
}
private var avatar: some View {
Button {
context.send(viewAction: .presentMediaSource)
} label: {
OverridableAvatarImage(overrideURL: context.viewState.localMedia?.thumbnailURL,
url: context.viewState.avatarURL,
name: context.viewState.initialName,
contentID: context.viewState.roomID,
avatarSize: .user(on: .memberDetails),
imageProvider: context.imageProvider)
.overlay(alignment: .bottomTrailing) {
if context.viewState.canEditAvatar {
avatarOverlayIcon
}
}
.confirmationDialog("", isPresented: $context.showMediaSheet) {
mediaActionSheet
}
}
.buttonStyle(.plain)
.disabled(!context.viewState.canEditAvatar)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
}
private var nameSection: some View {
Section {
let canEditName = context.viewState.canEditName
TextField(L10n.commonRoomName,
text: $context.name,
prompt: canEditName ? Text(L10n.screenCreateRoomRoomNamePlaceholder) : nil,
axis: .horizontal)
.focused($focus, equals: .name)
.font(.compound.bodyLG)
.foregroundColor(.element.primaryContent)
.disabled(!canEditName)
.listRowBackground(canEditName ? nil : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
} header: {
Text(L10n.commonRoomName)
.formSectionHeader()
}
.formSectionStyle()
}
private var topicSection: some View {
Section {
let canEditTopic = context.viewState.canEditTopic
TextField(L10n.commonTopic,
text: $context.topic,
prompt: canEditTopic ? Text(L10n.screenCreateRoomTopicPlaceholder) : nil,
axis: .vertical)
.focused($focus, equals: .topic)
.font(.compound.bodyLG)
.foregroundColor(.element.primaryContent)
.disabled(!canEditTopic)
.listRowBackground(canEditTopic ? nil : Color.clear)
.lineLimit(3, reservesSpace: true)
} header: {
Text(L10n.commonTopic)
.formSectionHeader()
}
.formSectionStyle()
}
private var avatarOverlayIcon: some View {
Image(systemName: "camera")
.padding(2)
.imageScale(.small)
.foregroundColor(.white)
.background {
Circle()
.foregroundColor(.black)
.aspectRatio(1, contentMode: .fill)
}
}
@ViewBuilder
private var mediaActionSheet: some View {
Button {
context.send(viewAction: .displayCameraPicker)
} label: {
Text(L10n.actionTakePhoto)
}
Button {
context.send(viewAction: .displayMediaPicker)
} label: {
Text(L10n.actionChoosePhoto)
}
if context.viewState.showDeleteImageAction {
Button(role: .destructive) {
context.send(viewAction: .removeImage)
} label: {
Text(L10n.actionRemove)
}
}
}
}
// MARK: - Previews
struct RoomDetailsEditScreen_Previews: PreviewProvider {
static let viewModel = RoomDetailsEditScreenViewModel(accountOwner: RoomMemberProxyMock.mockAlice,
mediaProvider: MockMediaProvider(),
roomProxy: RoomProxyMock(with: .init(name: "Room", displayName: "Room")),
userIndicatorController: MockUserIndicatorController())
static var previews: some View {
NavigationStack {
RoomDetailsEditScreen(context: viewModel.context)
}
}
}
@@ -62,6 +62,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
self.callback?(.cancel)
case .leftRoom:
self.callback?(.leftRoom)
case .requestEditDetailsPresentation(let accountOwner):
self.presentRoomDetailsEditScreen(accountOwner: accountOwner)
}
}
}
@@ -116,6 +118,30 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func presentRoomDetailsEditScreen(accountOwner: RoomMemberProxyProtocol) {
let navigationStackCoordinator = NavigationStackCoordinator()
let userIndicatorController = UserIndicatorController(rootCoordinator: navigationStackCoordinator)
let roomDetailsEditParameters = RoomDetailsEditScreenCoordinatorParameters(accountOwner: accountOwner,
mediaProvider: parameters.mediaProvider,
navigationStackCoordinator: navigationStackCoordinator,
roomProxy: parameters.roomProxy,
userIndicatorController: userIndicatorController)
let roomDetailsEditCoordinator = RoomDetailsEditScreenCoordinator(parameters: roomDetailsEditParameters)
roomDetailsEditCoordinator.actions.sink { [weak self] action in
switch action {
case .dismiss:
self?.parameters.navigationStackCoordinator.setSheetCoordinator(nil)
}
}
.store(in: &cancellables)
navigationStackCoordinator.setRootCoordinator(roomDetailsEditCoordinator)
parameters.navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func toggleUser(_ user: UserProfile) {
var selectedUsers = selectedUsers.value
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
@@ -26,6 +26,7 @@ enum RoomDetailsScreenViewModelAction {
case requestInvitePeoplePresentation([RoomMemberProxyProtocol])
case leftRoom
case cancel
case requestEditDetailsPresentation(RoomMemberProxyProtocol)
}
// MARK: View
@@ -43,10 +44,21 @@ struct RoomDetailsScreenViewState: BindableState {
var joinedMembersCount = 0
var isProcessingIgnoreRequest = false
var canInviteUsers = false
var canEditRoomName = false
var canEditRoomTopic = false
var canEditRoomAvatar = false
var isLoadingMembers: Bool {
members.isEmpty
}
var canEdit: Bool {
!isDirect && ServiceLocator.shared.settings.editRoomDetailsFlowEnabled && (canEditRoomName || canEditRoomTopic || canEditRoomAvatar)
}
var hasTopicSection: Bool {
topic != nil || (canEdit && canEditRoomTopic)
}
var bindings: RoomDetailsScreenViewStateBindings
@@ -130,6 +142,8 @@ enum RoomDetailsScreenViewAction {
case processTapLeave
case processTapIgnore
case processTapUnignore
case processTapEdit
case processTapAddTopic
case confirmLeave
case ignoreConfirmed
case unignoreConfirmed
@@ -23,6 +23,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
private let roomProxy: RoomProxyProtocol
private var members: [RoomMemberProxyProtocol] = []
private var dmRecipient: RoomMemberProxyProtocol?
private var accountOwner: RoomMemberProxyProtocol?
@CancellableTask
private var buildMembersDetailsTask: Task<Void, Never>?
@@ -52,6 +53,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
// MARK: - Public
// swiftlint:disable:next cyclomatic_complexity
override func process(viewAction: RoomDetailsScreenViewAction) {
switch viewAction {
case .processTapPeople:
@@ -71,6 +73,12 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
state.bindings.ignoreUserRoomAlertItem = .init(action: .ignore)
case .processTapUnignore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .unignore)
case .processTapEdit, .processTapAddTopic:
guard let accountOwner else {
MXLog.error("Missing account owner when presenting the room's edit details screen")
return
}
callback?(.requestEditDetailsPresentation(accountOwner))
case .ignoreConfirmed:
Task { await ignore() }
case .unignoreConfirmed:
@@ -105,13 +113,17 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
self.state.joinedMembersCount = roomMembersDetails.joinedMembersCount
self.state.dmRecipient = self.dmRecipient.map(RoomMemberDetails.init(withProxy:))
self.state.canInviteUsers = roomMembersDetails.accountOwner?.canInviteUsers ?? false
self.state.canEditRoomName = roomMembersDetails.accountOwner?.canSendStateEvent(type: .roomName) ?? false
self.state.canEditRoomTopic = roomMembersDetails.accountOwner?.canSendStateEvent(type: .roomTopic) ?? false
self.state.canEditRoomAvatar = roomMembersDetails.accountOwner?.canSendStateEvent(type: .roomAvatar) ?? false
self.members = members
self.accountOwner = roomMembersDetails.accountOwner
}
}
.store(in: &cancellables)
roomProxy.updatesPublisher
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
guard let self else { return }
self.state.title = self.roomProxy.roomTitle
@@ -50,6 +50,15 @@ struct RoomDetailsScreen: View {
.alert(item: $context.ignoreUserRoomAlertItem,
actions: blockUserAlertActions,
message: blockUserAlertMessage)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if context.viewState.canEdit {
Button(L10n.actionEdit) {
context.send(viewAction: .processTapEdit)
}
}
}
}
}
// MARK: - Private
@@ -98,11 +107,23 @@ struct RoomDetailsScreen: View {
@ViewBuilder
private var topicSection: some View {
if let topic = context.viewState.topic {
if context.viewState.hasTopicSection {
Section {
Text(topic)
.foregroundColor(.element.secondaryContent)
.font(.compound.bodySM)
if let topic = context.viewState.topic, !topic.isEmpty {
Text(topic)
.foregroundColor(.element.secondaryContent)
.font(.compound.bodySM)
.lineLimit(3)
} else {
Button {
context.send(viewAction: .processTapAddTopic)
} label: {
Text(L10n.screenRoomDetailsAddTopicTitle)
.foregroundColor(.element.primaryContent)
.font(.compound.bodyLG)
}
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.addTopic)
}
} header: {
Text(L10n.commonTopic)
.formSectionHeader()
@@ -142,7 +163,6 @@ struct RoomDetailsScreen: View {
.listRowSeparatorTint(.element.quinaryContent)
.buttonStyle(FormButtonStyle(accessory: context.viewState.isLoadingMembers ? nil : .navigationLink))
.foregroundColor(.element.primaryContent)
.disabled(context.viewState.isLoadingMembers)
}
@@ -41,6 +41,38 @@ enum MediaInfo {
case video(videoURL: URL, thumbnailURL: URL, videoInfo: VideoInfo)
case audio(audioURL: URL, audioInfo: AudioInfo)
case file(fileURL: URL, fileInfo: FileInfo)
var mimeType: String? {
switch self {
case .image(_, _, let imageInfo):
return imageInfo.mimetype
case .video(_, _, let videoInfo):
return videoInfo.mimetype
case .audio(_, let audioInfo):
return audioInfo.mimetype
case .file(_, let fileInfo):
return fileInfo.mimetype
}
}
var url: URL {
switch self {
case .image(let url, _, _),
.video(let url, _, _),
.audio(let url, _),
.file(let url, _):
return url
}
}
var thumbnailURL: URL? {
switch self {
case .image(_, let url, _), .video(_, let url, _):
return url
case .audio, .file:
return nil
}
}
}
private struct ImageProcessingInfo {
@@ -70,6 +70,10 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
var canInviteUsers: Bool {
member.canInvite()
}
func canSendStateEvent(type: StateEventType) -> Bool {
member.canSendState(stateEvent: type)
}
func ignoreUser() async -> Result<Void, RoomMemberProxyError> {
sendAccountDataEventBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
@@ -37,6 +37,7 @@ protocol RoomMemberProxyProtocol {
func ignoreUser() async -> Result<Void, RoomMemberProxyError>
func unignoreUser() async -> Result<Void, RoomMemberProxyError>
func canSendStateEvent(type: StateEventType) -> Bool
}
extension RoomMemberProxyProtocol {
+52 -7
View File
@@ -130,7 +130,19 @@ class RoomProxy: RoomProxyProtocol {
// return trusted image for now, should be updated after verification status known
return Asset.Images.encryptionTrusted.image
}
var invitedMembersCount: UInt {
UInt(room.invitedMembersCount())
}
var joinedMembersCount: UInt {
UInt(room.joinedMembersCount())
}
var activeMembersCount: UInt {
UInt(room.activeMembersCount())
}
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError> {
do {
guard let urlString = try await Task.dispatch(on: lowPriorityDispatchQueue, {
@@ -486,18 +498,51 @@ class RoomProxy: RoomProxyProtocol {
}
}
var invitedMembersCount: UInt {
UInt(room.invitedMembersCount())
func setName(_ name: String?) async -> Result<Void, RoomProxyError> {
await Task.dispatch(on: .global()) {
do {
return try .success(self.room.setName(name: name))
} catch {
return .failure(.failedSettingRoomName)
}
}
}
func setTopic(_ topic: String) async -> Result<Void, RoomProxyError> {
await Task.dispatch(on: .global()) {
do {
return try .success(self.room.setTopic(topic: topic))
} catch {
return .failure(.failedSettingRoomTopic)
}
}
}
var joinedMembersCount: UInt {
UInt(room.joinedMembersCount())
func removeAvatar() async -> Result<Void, RoomProxyError> {
await Task.dispatch(on: .global()) {
do {
return try .success(self.room.removeAvatar())
} catch {
return .failure(.failedRemovingAvatar)
}
}
}
var activeMembersCount: UInt {
UInt(room.activeMembersCount())
func uploadAvatar(media: MediaInfo) async -> Result<Void, RoomProxyError> {
await Task.dispatch(on: .global()) {
guard case let .image(imageURL, _, _) = media, let mimeType = media.mimeType else {
return .failure(.failedUploadingAvatar)
}
do {
let data = try Data(contentsOf: imageURL)
return try .success(self.room.uploadAvatar(mimeType: mimeType, data: [UInt8](data)))
} catch {
return .failure(.failedUploadingAvatar)
}
}
}
// MARK: - Private
/// Force the timeline to load member details so it can populate sender profiles whenever we add a timeline listener
@@ -37,6 +37,10 @@ enum RoomProxyError: Error {
case failedAcceptingInvite
case failedRejectingInvite
case failedInvitingUser
case failedSettingRoomName
case failedSettingRoomTopic
case failedRemovingAvatar
case failedUploadingAvatar
}
@MainActor
@@ -61,6 +65,12 @@ protocol RoomProxyProtocol {
var membersPublisher: AnyPublisher<[RoomMemberProxyProtocol], Never> { get }
var invitedMembersCount: UInt { get }
var joinedMembersCount: UInt { get }
var activeMembersCount: UInt { get }
/// Publishes the room's updates.
/// The publisher starts publishing after the first call to `registerTimelineListenerIfNeeded()`
/// The thread on which this publisher sends the output isn't defined.
@@ -114,11 +124,13 @@ protocol RoomProxyProtocol {
func invite(userID: String) async -> Result<Void, RoomProxyError>
var invitedMembersCount: UInt { get }
func setName(_ name: String?) async -> Result<Void, RoomProxyError>
var joinedMembersCount: UInt { get }
func setTopic(_ topic: String) async -> Result<Void, RoomProxyError>
var activeMembersCount: UInt { get }
func removeAvatar() async -> Result<Void, RoomProxyError>
func uploadAvatar(media: MediaInfo) async -> Result<Void, RoomProxyError>
}
extension RoomProxyProtocol {
@@ -15,6 +15,7 @@
//
import Combine
import MatrixRustSDK
import SwiftUI
import UIKit
@@ -321,6 +322,24 @@ class MockScreen: Identifiable {
userDiscoveryService: UserDiscoveryServiceMock()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomDetailsScreenWithEmptyTopic:
ServiceLocator.shared.settings.editRoomDetailsFlowEnabled = true
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockOwner(allowedStateEvents: [.roomTopic]), .mockBob, .mockCharlie]
let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier",
displayName: "Room",
topic: nil,
avatarURL: URL.picturesDirectory,
isDirect: false,
isEncrypted: true,
canonicalAlias: "#mock:room.org",
members: members))
let coordinator = RoomDetailsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
roomProxy: roomProxy,
mediaProvider: MockMediaProvider(),
userDiscoveryService: UserDiscoveryServiceMock()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomDetailsScreenWithInvite:
ServiceLocator.shared.settings.inviteMorePeopleFlowEnabled = true
let navigationStackCoordinator = NavigationStackCoordinator()
@@ -335,6 +354,21 @@ class MockScreen: Identifiable {
userDiscoveryService: UserDiscoveryServiceMock()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomEditDetails, .roomEditDetailsReadOnly:
let allowedStateEvents: [StateEventType] = id == .roomEditDetails ? [.roomAvatar, .roomName, .roomTopic] : []
let navigationStackCoordinator = NavigationStackCoordinator()
let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier",
name: "Room",
displayName: "Room",
topic: "What a cool topic!",
avatarURL: .picturesDirectory))
let coordinator = RoomDetailsEditScreenCoordinator(parameters: .init(accountOwner: RoomMemberProxyMock.mockOwner(allowedStateEvents: allowedStateEvents),
mediaProvider: MockMediaProvider(),
navigationStackCoordinator: navigationStackCoordinator,
roomProxy: roomProxy,
userIndicatorController: MockUserIndicatorController()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomMembersListScreen:
let navigationStackCoordinator = NavigationStackCoordinator()
let members: [RoomMemberProxyMock] = [.mockAlice, .mockBob, .mockCharlie]
@@ -43,8 +43,11 @@ enum UITestsScreenIdentifier: String {
case userSessionScreen
case roomDetailsScreen
case roomDetailsScreenWithRoomAvatar
case roomDetailsScreenWithEmptyTopic
case roomDetailsScreenWithInvite
case roomDetailsScreenDmDetails
case roomEditDetails
case roomEditDetailsReadOnly
case roomMembersListScreen
case roomMembersListScreenPendingInvites
case roomMemberDetailsAccountOwner