mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +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:
@@ -69,6 +69,7 @@
|
||||
"common_file" = "File";
|
||||
"common_gif" = "GIF";
|
||||
"common_image" = "Image";
|
||||
"common_invite_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received.";
|
||||
"common_leaving_room" = "Leaving room";
|
||||
"common_link_copied_to_clipboard" = "Link copied to clipboard";
|
||||
"common_loading" = "Loading…";
|
||||
@@ -85,6 +86,7 @@
|
||||
"common_replying_to" = "Replying to %1$@";
|
||||
"common_report_a_bug" = "Report a bug";
|
||||
"common_report_submitted" = "Report submitted";
|
||||
"common_room_name" = "Room name";
|
||||
"common_search_for_someone" = "Search for someone";
|
||||
"common_search_results" = "Search results";
|
||||
"common_security" = "Security";
|
||||
@@ -224,9 +226,12 @@
|
||||
"screen_room_attachment_source_camera_video" = "Record a video";
|
||||
"screen_room_attachment_source_files" = "Attachment";
|
||||
"screen_room_attachment_source_gallery" = "Photo & Video Library";
|
||||
"screen_room_details_add_topic_title" = "Add topic";
|
||||
"screen_room_details_already_a_member" = "Already a member";
|
||||
"screen_room_details_already_invited" = "Already invited";
|
||||
"screen_room_details_edition_error" = "An error occurred when updating the room details";
|
||||
"screen_room_details_edit_room_title" = "Edit Room";
|
||||
"screen_room_details_edition_error" = "We were unable to update all the information for this room.";
|
||||
"screen_room_details_edition_error_title" = "Unable to update room";
|
||||
"screen_room_details_encryption_enabled_subtitle" = "Messages are secured with locks. Only you and the recipients have the unique keys to unlock them.";
|
||||
"screen_room_details_encryption_enabled_title" = "Message encryption enabled";
|
||||
"screen_room_details_share_room_title" = "Share room";
|
||||
@@ -259,7 +264,6 @@
|
||||
"screen_signout_confirmation_dialog_title" = "Sign out";
|
||||
"screen_signout_in_progress_dialog_content" = "Signing out…";
|
||||
"screen_start_chat_error_starting_chat" = "An error occurred when trying to start a chat";
|
||||
"screen_start_chat_unknown_profile" = "We can’t validate this user’s Matrix ID. The invite might not be received.";
|
||||
"session_verification_banner_message" = "Looks like you’re using a new device. Verify it’s you to access your encrypted messages.";
|
||||
"session_verification_banner_title" = "Access your message history";
|
||||
"settings_rageshake" = "Rageshake";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user