Flescio/add room image (#961)

* image media upload for room creation

* fix remove duplicate logic

* use Async Image, add Focusable Fields in create room

* remove alert message which doesn't have a copy

* add remove duplicates on crete room image

* add button style for preventing undesired touches

* add changelog, add error case for file too large

* Fix iPad sheet presentation

* add error media preprocess

* dismissing focus on image picker
This commit is contained in:
Flescio
2023-05-30 14:55:29 +02:00
committed by GitHub
parent eba0fe5ce3
commit 03ba211e6c
11 changed files with 255 additions and 32 deletions
@@ -28,6 +28,8 @@ enum CreateRoomCoordinatorAction {
case openRoom(withIdentifier: String) case openRoom(withIdentifier: String)
case deselectUser(UserProfileProxy) case deselectUser(UserProfileProxy)
case updateDetails(CreateRoomFlowParameters) case updateDetails(CreateRoomFlowParameters)
case displayMediaPickerWithSource(MediaPickerScreenSource)
case removeImage
} }
final class CreateRoomCoordinator: CoordinatorProtocol { final class CreateRoomCoordinator: CoordinatorProtocol {
@@ -53,11 +55,17 @@ final class CreateRoomCoordinator: CoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .deselectUser(let user): case .deselectUser(let user):
self.actionsSubject.send(.deselectUser(user)) actionsSubject.send(.deselectUser(user))
case .openRoom(let identifier): case .openRoom(let identifier):
self.actionsSubject.send(.openRoom(withIdentifier: identifier)) actionsSubject.send(.openRoom(withIdentifier: identifier))
case .updateDetails(let details): case .updateDetails(let details):
self.actionsSubject.send(.updateDetails(details)) actionsSubject.send(.updateDetails(details))
case .displayCameraPicker:
actionsSubject.send(.displayMediaPickerWithSource(.camera))
case .displayMediaPicker:
actionsSubject.send(.displayMediaPickerWithSource(.photoLibrary))
case .removeImage:
actionsSubject.send(.removeImage)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -18,6 +18,9 @@ import Foundation
enum CreateRoomScreenErrorType: Error { enum CreateRoomScreenErrorType: Error {
case failedCreatingRoom case failedCreatingRoom
case failedUploadingMedia
case fileTooLarge
case mediaFileError
case unknown case unknown
} }
@@ -25,12 +28,15 @@ enum CreateRoomViewModelAction {
case openRoom(withIdentifier: String) case openRoom(withIdentifier: String)
case deselectUser(UserProfileProxy) case deselectUser(UserProfileProxy)
case updateDetails(CreateRoomFlowParameters) case updateDetails(CreateRoomFlowParameters)
case displayMediaPicker
case displayCameraPicker
case removeImage
} }
struct CreateRoomViewState: BindableState { struct CreateRoomViewState: BindableState {
var selectedUsers: [UserProfileProxy] var selectedUsers: [UserProfileProxy]
var bindings: CreateRoomViewStateBindings var bindings: CreateRoomViewStateBindings
var avatarURL: URL?
var canCreateRoom: Bool { var canCreateRoom: Bool {
!bindings.roomName.isEmpty !bindings.roomName.isEmpty
} }
@@ -40,6 +46,7 @@ struct CreateRoomViewStateBindings {
var roomName: String var roomName: String
var roomTopic: String var roomTopic: String
var isRoomPrivate: Bool var isRoomPrivate: Bool
var showAttachmentConfirmationDialog = false
/// Information describing the currently displayed alert. /// Information describing the currently displayed alert.
var alertInfo: AlertInfo<CreateRoomScreenErrorType>? var alertInfo: AlertInfo<CreateRoomScreenErrorType>?
@@ -48,4 +55,7 @@ struct CreateRoomViewStateBindings {
enum CreateRoomViewAction { enum CreateRoomViewAction {
case createRoom case createRoom
case deselectUser(UserProfileProxy) case deselectUser(UserProfileProxy)
case displayCameraPicker
case displayMediaPicker
case removeImage
} }
@@ -41,6 +41,22 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
super.init(initialViewState: CreateRoomViewState(selectedUsers: selectedUsers.value, bindings: bindings), imageProvider: userSession.mediaProvider) super.init(initialViewState: CreateRoomViewState(selectedUsers: selectedUsers.value, bindings: bindings), imageProvider: userSession.mediaProvider)
createRoomParameters
.map(\.avatarImageMedia)
.removeDuplicates { $0?.url == $1?.url }
.sink { [weak self] mediaInfo in
self?.createRoomParameters.avatarImageMedia = mediaInfo
switch mediaInfo {
case .image(_, let thumbnailURL, _):
self?.state.avatarURL = thumbnailURL
case nil:
self?.state.avatarURL = nil
default:
break
}
}
.store(in: &cancellables)
selectedUsers selectedUsers
.sink { [weak self] users in .sink { [weak self] users in
self?.state.selectedUsers = users self?.state.selectedUsers = users
@@ -60,6 +76,12 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
} }
case .deselectUser(let user): case .deselectUser(let user):
actionsSubject.send(.deselectUser(user)) actionsSubject.send(.deselectUser(user))
case .displayCameraPicker:
actionsSubject.send(.displayCameraPicker)
case .displayMediaPicker:
actionsSubject.send(.displayMediaPicker)
case .removeImage:
actionsSubject.send(.removeImage)
} }
} }
@@ -69,6 +91,9 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
context.$viewState context.$viewState
.map(\.bindings) .map(\.bindings)
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.removeDuplicates { old, new in
old.roomName == new.roomName && old.roomTopic == new.roomTopic && old.isRoomPrivate == new.isRoomPrivate
}
.sink { [weak self] bindings in .sink { [weak self] bindings in
guard let self else { return } guard let self else { return }
createRoomParameters.name = bindings.roomName createRoomParameters.name = bindings.roomName
@@ -87,6 +112,16 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
message: L10n.screenStartChatErrorStartingChat) message: L10n.screenStartChatErrorStartingChat)
case .failedSearchingUsers: case .failedSearchingUsers:
state.bindings.alertInfo = AlertInfo(id: .unknown) state.bindings.alertInfo = AlertInfo(id: .unknown)
case .failedUploadingMedia(let matrixError):
switch matrixError {
case .fileTooLarge:
// waiting for proper copy
state.bindings.alertInfo = AlertInfo(id: .fileTooLarge)
default:
state.bindings.alertInfo = AlertInfo(id: .failedUploadingMedia)
}
case .mediaFileError:
state.bindings.alertInfo = AlertInfo(id: .mediaFileError)
default: default:
break break
} }
@@ -101,7 +136,25 @@ class CreateRoomViewModel: CreateRoomViewModelType, CreateRoomViewModelProtocol
hideLoadingIndicator() hideLoadingIndicator()
} }
showLoadingIndicator() showLoadingIndicator()
switch await clientProxy.createRoom(with: createRoomParameters, userIDs: state.selectedUsers.map(\.userID)) {
let avatarURL: URL?
if let media = createRoomParameters.avatarImageMedia {
switch await clientProxy.uploadMedia(media) {
case .success(let url):
avatarURL = URL(string: url)
case .failure(let error):
displayError(error)
return
}
} else {
avatarURL = nil
}
switch await clientProxy.createRoom(name: createRoomParameters.name,
topic: createRoomParameters.topic,
isRoomPrivate: createRoomParameters.isRoomPrivate,
userIDs: state.selectedUsers.map(\.userID),
avatarURL: avatarURL) {
case .success(let roomId): case .success(let roomId):
actionsSubject.send(.openRoom(withIdentifier: roomId)) actionsSubject.send(.openRoom(withIdentifier: roomId))
case .failure(let failure): case .failure(let failure):
@@ -18,7 +18,13 @@ import SwiftUI
struct CreateRoomScreen: View { struct CreateRoomScreen: View {
@ObservedObject var context: CreateRoomViewModel.Context @ObservedObject var context: CreateRoomViewModel.Context
@FocusState private var focus: Focus?
private enum Focus {
case name
case topic
}
var body: some View { var body: some View {
mainContent mainContent
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
@@ -32,6 +38,7 @@ struct CreateRoomScreen: View {
} }
} }
.background(ViewFrameReader(frame: $frame)) .background(ViewFrameReader(frame: $frame))
.alert(item: $context.alertInfo) { $0.alert }
} }
/// The main content of the view to be shown in a scroll view. /// The main content of the view to be shown in a scroll view.
@@ -49,11 +56,38 @@ struct CreateRoomScreen: View {
private var roomSection: some View { private var roomSection: some View {
Section { Section {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: 16) {
Image(systemName: "camera") Button {
.foregroundColor(.element.secondaryContent) focus = nil
.frame(width: roomIconSize, height: roomIconSize) context.showAttachmentConfirmationDialog = true
.background(Color.element.quinaryContent) } label: {
.clipShape(Circle()) if let url = context.viewState.avatarURL {
AsyncImage(url: url) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(width: roomIconSize, height: roomIconSize)
.clipShape(Circle())
} else {
cameraImage
}
}
.buttonStyle(.plain)
.confirmationDialog("", isPresented: $context.showAttachmentConfirmationDialog) {
Button(L10n.actionTakePhoto) {
context.send(viewAction: .displayCameraPicker)
}
Button(L10n.actionChoosePhoto) {
context.send(viewAction: .displayMediaPicker)
}
if context.viewState.avatarURL != nil {
Button(L10n.actionRemove, role: .destructive) {
context.send(viewAction: .removeImage)
}
}
}
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(L10n.screenCreateRoomRoomNameLabel.uppercased()) Text(L10n.screenCreateRoomRoomNameLabel.uppercased())
.font(.compound.bodyXS) .font(.compound.bodyXS)
@@ -63,6 +97,7 @@ struct CreateRoomScreen: View {
text: $context.roomName, text: $context.roomName,
prompt: Text(L10n.screenCreateRoomRoomNamePlaceholder), prompt: Text(L10n.screenCreateRoomRoomNamePlaceholder),
axis: .horizontal) axis: .horizontal)
.focused($focus, equals: .name)
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomName) .accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomName)
.padding(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) .padding(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
.background(Color.element.formRowBackground) .background(Color.element.formRowBackground)
@@ -75,12 +110,21 @@ struct CreateRoomScreen: View {
.formSectionStyle() .formSectionStyle()
} }
private var cameraImage: some View {
Image(systemName: "camera")
.foregroundColor(.element.secondaryContent)
.frame(width: roomIconSize, height: roomIconSize)
.background(Color.element.quinaryContent)
.clipShape(Circle())
}
private var topicSection: some View { private var topicSection: some View {
Section { Section {
TextField(L10n.screenCreateRoomTopicLabel, TextField(L10n.screenCreateRoomTopicLabel,
text: $context.roomTopic, text: $context.roomTopic,
prompt: Text(L10n.screenCreateRoomTopicPlaceholder), prompt: Text(L10n.screenCreateRoomTopicPlaceholder),
axis: .vertical) axis: .vertical)
.focused($focus, equals: .topic)
.accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomTopic) .accessibilityIdentifier(A11yIdentifiers.createRoomScreen.roomTopic)
.lineLimit(3, reservesSpace: false) .lineLimit(3, reservesSpace: false)
} header: { } header: {
@@ -150,7 +194,10 @@ struct CreateRoomScreen: View {
} }
private var createButton: some View { private var createButton: some View {
Button { context.send(viewAction: .createRoom) } label: { Button {
focus = nil
context.send(viewAction: .createRoom)
} label: {
Text(L10n.actionCreate) Text(L10n.actionCreate)
} }
.disabled(!context.viewState.canCreateRoom) .disabled(!context.viewState.canCreateRoom)
@@ -20,7 +20,7 @@ import SwiftUI
struct StartChatScreenCoordinatorParameters { struct StartChatScreenCoordinatorParameters {
let userSession: UserSessionProtocol let userSession: UserSessionProtocol
weak var userIndicatorController: UserIndicatorControllerProtocol? weak var userIndicatorController: UserIndicatorControllerProtocol?
let navigationStackCoordinator: NavigationStackCoordinator? let navigationStackCoordinator: NavigationStackCoordinator
let userDiscoveryService: UserDiscoveryServiceProtocol let userDiscoveryService: UserDiscoveryServiceProtocol
} }
@@ -45,6 +45,14 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
selectedUsers.asCurrentValuePublisher() selectedUsers.asCurrentValuePublisher()
} }
private var navigationStackCoordinator: NavigationStackCoordinator {
parameters.navigationStackCoordinator
}
private var userIndicatorController: UserIndicatorControllerProtocol? {
parameters.userIndicatorController
}
var actions: AnyPublisher<StartChatScreenCoordinatorAction, Never> { var actions: AnyPublisher<StartChatScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
@@ -60,12 +68,12 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
guard let self else { return } guard let self else { return }
switch action { switch action {
case .close: case .close:
self.actionsSubject.send(.close) actionsSubject.send(.close)
case .createRoom: case .createRoom:
// before creating a room we select the users we would like to invite in that room // before creating a room we select the users we would like to invite in that room
self.presentInviteUsersScreen() presentInviteUsersScreen()
case .openRoom(let identifier): case .openRoom(let identifier):
self.actionsSubject.send(.openRoom(withIdentifier: identifier)) actionsSubject.send(.openRoom(withIdentifier: identifier))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -99,7 +107,7 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
} }
.store(in: &cancellables) .store(in: &cancellables)
parameters.navigationStackCoordinator?.push(coordinator) { [weak self] in navigationStackCoordinator.push(coordinator) { [weak self] in
self?.createRoomParameters.send(.init()) self?.createRoomParameters.send(.init())
self?.selectedUsers.send([]) self?.selectedUsers.send([])
} }
@@ -107,27 +115,71 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
private func openCreateRoomScreen() { private func openCreateRoomScreen() {
let createParameters = CreateRoomCoordinatorParameters(userSession: parameters.userSession, let createParameters = CreateRoomCoordinatorParameters(userSession: parameters.userSession,
userIndicatorController: parameters.userIndicatorController, userIndicatorController: userIndicatorController,
createRoomParameters: createRoomParametersPublisher, createRoomParameters: createRoomParametersPublisher,
selectedUsers: selectedUsersPublisher) selectedUsers: selectedUsersPublisher)
let coordinator = CreateRoomCoordinator(parameters: createParameters) let coordinator = CreateRoomCoordinator(parameters: createParameters)
coordinator.actions.sink { [weak self] result in coordinator.actions.sink { [weak self] result in
guard let self else { return }
switch result { switch result {
case .deselectUser(let user): case .deselectUser(let user):
self?.toggleUser(user) self.toggleUser(user)
case .updateDetails(let details): case .updateDetails(let details):
self?.createRoomParameters.send(details) self.createRoomParameters.send(details)
case .openRoom(let identifier): case .openRoom(let identifier):
self?.actionsSubject.send(.openRoom(withIdentifier: identifier)) self.actionsSubject.send(.openRoom(withIdentifier: identifier))
case .displayMediaPickerWithSource(let source):
self.displayMediaPickerWithSource(source)
case .removeImage:
var parameters = self.createRoomParameters.value
parameters.avatarImageMedia = nil
self.createRoomParameters.send(parameters)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
parameters.navigationStackCoordinator?.push(coordinator) navigationStackCoordinator.push(coordinator)
} }
// MARK: - Private // MARK: - Private
let mediaUploadingPreprocessor = MediaUploadingPreprocessor()
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:
navigationStackCoordinator.setSheetCoordinator(nil)
case .selectMediaAtURL(let url):
processAvatar(from: url)
}
}
stackCoordinator.setRootCoordinator(mediaPickerCoordinator)
navigationStackCoordinator.setSheetCoordinator(userIndicatorController)
}
private func processAvatar(from url: URL) {
navigationStackCoordinator.setSheetCoordinator(nil)
showLoadingIndicator()
Task { [weak self] in
guard let self else { return }
do {
let media = try await mediaUploadingPreprocessor.processMedia(at: url).get()
var parameters = createRoomParameters.value
parameters.avatarImageMedia = media
createRoomParameters.send(parameters)
} catch {
userIndicatorController?.alertInfo = AlertInfo(id: .init(), title: L10n.commonError, message: L10n.errorUnknown)
}
hideLoadingIndicator()
}
}
private func toggleUser(_ user: UserProfileProxy) { private func toggleUser(_ user: UserProfileProxy) {
var selectedUsers = selectedUsers.value var selectedUsers = selectedUsers.value
if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) { if let index = selectedUsers.firstIndex(where: { $0.userID == user.userID }) {
@@ -137,4 +189,19 @@ final class StartChatScreenCoordinator: CoordinatorProtocol {
} }
self.selectedUsers.send(selectedUsers) self.selectedUsers.send(selectedUsers)
} }
// MARK: Loading indicator
private static let loadingIndicatorIdentifier = "StartChatCoordinatorLoading"
private func showLoadingIndicator() {
userIndicatorController?.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func hideLoadingIndicator() {
userIndicatorController?.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
} }
@@ -21,6 +21,19 @@ enum MatrixErrorCode: String, CaseIterable {
case unknown = "M_UNKNOWN" case unknown = "M_UNKNOWN"
case userDeactivated = "M_USER_DEACTIVATED" case userDeactivated = "M_USER_DEACTIVATED"
case forbidden = "M_FORBIDDEN" case forbidden = "M_FORBIDDEN"
case fileTooLarge = "M_TOO_LARGE"
}
extension ClientError {
var code: MatrixErrorCode {
guard case let .Generic(message) = self else { return .unknown }
guard let first = MatrixErrorCode.allCases.first(where: { message.contains($0.rawValue) }) else {
return .unknown
}
return first
}
} }
extension AuthenticationError { extension AuthenticationError {
@@ -197,17 +197,17 @@ class ClientProxy: ClientProxyProtocol {
return await waitForRoomSummary(with: result, name: expectedRoomName) return await waitForRoomSummary(with: result, name: expectedRoomName)
} }
func createRoom(with parameters: CreateRoomFlowParameters, userIDs: [String]) async -> Result<String, ClientProxyError> { func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError> {
let result: Result<String, ClientProxyError> = await Task.dispatch(on: clientQueue) { let result: Result<String, ClientProxyError> = await Task.dispatch(on: clientQueue) {
do { do {
let parameters = CreateRoomParameters(name: parameters.name, let parameters = CreateRoomParameters(name: name,
topic: parameters.topic, topic: topic,
isEncrypted: parameters.isRoomPrivate, isEncrypted: isRoomPrivate,
isDirect: false, isDirect: false,
visibility: parameters.isRoomPrivate ? .private : .public, visibility: isRoomPrivate ? .private : .public,
preset: parameters.isRoomPrivate ? .privateChat : .publicChat, preset: isRoomPrivate ? .privateChat : .publicChat,
invite: userIDs, invite: userIDs,
avatar: nil) avatar: avatarURL?.absoluteString)
let roomId = try self.client.createRoom(request: parameters) let roomId = try self.client.createRoom(request: parameters)
return .success(roomId) return .success(roomId)
} catch { } catch {
@@ -215,7 +215,22 @@ class ClientProxy: ClientProxyProtocol {
} }
} }
return await waitForRoomSummary(with: result, name: parameters.name) return await waitForRoomSummary(with: result, name: name)
}
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError> {
guard let mimeType = media.mimeType else { return .failure(ClientProxyError.mediaFileError) }
return await Task.dispatch(on: clientQueue) {
do {
let data = try Data(contentsOf: media.url)
let matrixUrl = try self.client.uploadMedia(mimeType: mimeType, data: [UInt8](data))
return .success(matrixUrl)
} catch let error as ClientError {
return .failure(ClientProxyError.failedUploadingMedia(error.code))
} catch {
return .failure(ClientProxyError.mediaFileError)
}
}
} }
/// Await the room to be available in the room summary list /// Await the room to be available in the room summary list
@@ -41,6 +41,8 @@ enum ClientProxyError: Error {
case failedSettingAccountData case failedSettingAccountData
case failedRetrievingSessionVerificationController case failedRetrievingSessionVerificationController
case failedLoadingMedia case failedLoadingMedia
case mediaFileError
case failedUploadingMedia(MatrixErrorCode)
case failedSearchingUsers case failedSearchingUsers
case failedGettingUserProfile case failedGettingUserProfile
} }
@@ -92,7 +94,9 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError> func createDirectRoom(with userID: String, expectedRoomName: String?) async -> Result<String, ClientProxyError>
func createRoom(with parameters: CreateRoomFlowParameters, userIDs: [String]) async -> Result<String, ClientProxyError> func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError>
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError>
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol?
@@ -57,10 +57,14 @@ class MockClientProxy: ClientProxyProtocol {
.failure(.failedCreatingRoom) .failure(.failedCreatingRoom)
} }
func createRoom(with parameters: CreateRoomFlowParameters, userIDs: [String]) async -> Result<String, ClientProxyError> { func createRoom(name: String, topic: String?, isRoomPrivate: Bool, userIDs: [String], avatarURL: URL?) async -> Result<String, ClientProxyError> {
.failure(.failedCreatingRoom) .failure(.failedCreatingRoom)
} }
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError> {
.failure(.failedUploadingMedia(.unknown))
}
var roomForIdentifierMocks: [String: RoomProxyMock] = .init() var roomForIdentifierMocks: [String: RoomProxyMock] = .init()
@MainActor @MainActor
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? { func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? {
@@ -21,4 +21,5 @@ struct CreateRoomFlowParameters {
var name = "" var name = ""
var topic = "" var topic = ""
var isRoomPrivate = true var isRoomPrivate = true
var avatarImageMedia: MediaInfo?
} }
+1
View File
@@ -0,0 +1 @@
Add the image picker flow for the creation of a room