mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user