diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 997af3bc4..2911142e8 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -163,7 +163,7 @@ { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing.git", "state" : { "revision" : "cef5b3f6f11781dd4591bdd1dd0a3d22bd609334", "version" : "1.11.0" diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 3d5bb6535..85ceb6e3f 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -5,6 +5,8 @@ "ios_no" = "No"; "action_confirm" = "Confirm"; "action_match" = "Match"; +"action_copy_link" = "Copy Link"; +"action_share_link" = "Share Link"; "message" = "Message"; @@ -72,12 +74,19 @@ // Room Details "room_details_title" = "Info"; "room_details_about_section_title" = "About"; -"room_details_copy_link" = "Copy Link"; "room_details_leave_room_alert_subtitle" = "Are you sure that you want to leave the room?"; "room_details_leave_private_room_alert_subtitle" = "Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite."; "room_details_leave_empty_room_alert_subtitle" = "Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you."; "room_details_room_left_toast" = "Room left"; +// Room Member Details +"room_member_details_block_user" = "Block user"; +"room_member_details_unblock_user" = "Unblock user"; +"room_member_details_block_alert_action" = "Block"; +"room_member_details_unblock_alert_action" = "Unblock"; +"room_member_details_block_alert_description" = "Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime."; +"room_member_details_unblock_alert_description" = "On unblocking the user, you will be able to see all messages by them again."; + // Onboarding "ftue_auth_carousel_welcome_title" = "Be in your Element"; "ftue_auth_carousel_welcome_body" = "Welcome to the %@ Beta. Supercharged, for speed and simplicity."; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 22a8f2b34..5a25fa9d3 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -14,8 +14,12 @@ extension ElementL10n { public static let a11yAllChatsUserAvatarMenu = ElementL10n.tr("Untranslated", "a11y_all_chats_user_avatar_menu") /// Confirm public static let actionConfirm = ElementL10n.tr("Untranslated", "action_confirm") + /// Copy Link + public static let actionCopyLink = ElementL10n.tr("Untranslated", "action_copy_link") /// Match public static let actionMatch = ElementL10n.tr("Untranslated", "action_match") + /// Share Link + public static let actionShareLink = ElementL10n.tr("Untranslated", "action_share_link") /// Attach Screenshot public static let bugReportScreenAttachScreenshot = ElementL10n.tr("Untranslated", "bug_report_screen_attach_screenshot") /// Please describe the bug. What did you do? What did you expect to happen? What actually happened. Please go into as much detail as you can. @@ -104,8 +108,6 @@ extension ElementL10n { public static let retrievingDirectRoomError = ElementL10n.tr("Untranslated", "retrieving_direct_room_error") /// About public static let roomDetailsAboutSectionTitle = ElementL10n.tr("Untranslated", "room_details_about_section_title") - /// Copy Link - public static let roomDetailsCopyLink = ElementL10n.tr("Untranslated", "room_details_copy_link") /// Are you sure that you want to leave this room? You are the only person here. If you leave, no one will be able to join in the future, including you. public static let roomDetailsLeaveEmptyRoomAlertSubtitle = ElementL10n.tr("Untranslated", "room_details_leave_empty_room_alert_subtitle") /// Are you sure that you want to leave this room? This room is not public and you will not be able to rejoin without an invite. @@ -116,6 +118,18 @@ extension ElementL10n { public static let roomDetailsRoomLeftToast = ElementL10n.tr("Untranslated", "room_details_room_left_toast") /// Info public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title") + /// Block + public static let roomMemberDetailsBlockAlertAction = ElementL10n.tr("Untranslated", "room_member_details_block_alert_action") + /// Blocked users will not be able to send you messages and all message by them will be hidden. You can reverse this action anytime. + public static let roomMemberDetailsBlockAlertDescription = ElementL10n.tr("Untranslated", "room_member_details_block_alert_description") + /// Block user + public static let roomMemberDetailsBlockUser = ElementL10n.tr("Untranslated", "room_member_details_block_user") + /// Unblock + public static let roomMemberDetailsUnblockAlertAction = ElementL10n.tr("Untranslated", "room_member_details_unblock_alert_action") + /// On unblocking the user, you will be able to see all messages by them again. + public static let roomMemberDetailsUnblockAlertDescription = ElementL10n.tr("Untranslated", "room_member_details_unblock_alert_description") + /// Unblock user + public static let roomMemberDetailsUnblockUser = ElementL10n.tr("Untranslated", "room_member_details_unblock_user") /// Failed loading messages public static let roomTimelineBackpaginationFailure = ElementL10n.tr("Untranslated", "room_timeline_backpagination_failure") /// Retry decryption diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 9b3aab9b1..59c694ce7 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -78,7 +78,51 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol { set(value) { underlyingNormalizedPowerLevel = value } } var underlyingNormalizedPowerLevel: Int! + var isAccountOwner: Bool { + get { return underlyingIsAccountOwner } + set(value) { underlyingIsAccountOwner = value } + } + var underlyingIsAccountOwner: Bool! + var isIgnored: Bool { + get { return underlyingIsIgnored } + set(value) { underlyingIsIgnored = value } + } + var underlyingIsIgnored: Bool! + //MARK: - ignoreUser + + var ignoreUserCallsCount = 0 + var ignoreUserCalled: Bool { + return ignoreUserCallsCount > 0 + } + var ignoreUserReturnValue: Result! + var ignoreUserClosure: (() async -> Result)? + + func ignoreUser() async -> Result { + ignoreUserCallsCount += 1 + if let ignoreUserClosure = ignoreUserClosure { + return await ignoreUserClosure() + } else { + return ignoreUserReturnValue + } + } + //MARK: - unignoreUser + + var unignoreUserCallsCount = 0 + var unignoreUserCalled: Bool { + return unignoreUserCallsCount > 0 + } + var unignoreUserReturnValue: Result! + var unignoreUserClosure: (() async -> Result)? + + func unignoreUser() async -> Result { + unignoreUserCallsCount += 1 + if let unignoreUserClosure = unignoreUserClosure { + return await unignoreUserClosure() + } else { + return unignoreUserReturnValue + } + } } class RoomProxyMock: RoomProxyProtocol { var id: String { diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index f3947345d..a38028d42 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -20,11 +20,13 @@ import MatrixRustSDK struct RoomMemberProxyMockConfiguration { var userID: String var displayName: String - var avatarURL: String? + var avatarURL: URL? var membership: MembershipState var isNameAmbiguous: Bool var powerLevel: Int var normalizedPowerLevel: Int + var isAccountOwner: Bool + var isIgnored: Bool } extension RoomMemberProxyMock { @@ -32,43 +34,85 @@ extension RoomMemberProxyMock { self.init() userID = configuration.userID displayName = configuration.displayName - if let avatarURL = configuration.avatarURL { - self.avatarURL = URL(string: avatarURL) - } + avatarURL = configuration.avatarURL membership = configuration.membership isNameAmbiguous = configuration.isNameAmbiguous powerLevel = configuration.powerLevel normalizedPowerLevel = configuration.normalizedPowerLevel + isAccountOwner = configuration.isAccountOwner + isIgnored = configuration.isIgnored } // Mocks static var mockAlice: RoomMemberProxyMock { - RoomMemberProxyMock(with: .init(userID: "alice@matrix.org", + RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, membership: .join, isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50)) + normalizedPowerLevel: 50, + isAccountOwner: false, + isIgnored: false)) } static var mockBob: RoomMemberProxyMock { - RoomMemberProxyMock(with: .init(userID: "bob@matrix.org", + RoomMemberProxyMock(with: .init(userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil, membership: .join, isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50)) + normalizedPowerLevel: 50, + isAccountOwner: false, + isIgnored: false)) } static var mockCharlie: RoomMemberProxyMock { - RoomMemberProxyMock(with: .init(userID: "charlie@matrix.org", + RoomMemberProxyMock(with: .init(userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil, membership: .join, isNameAmbiguous: false, powerLevel: 50, - normalizedPowerLevel: 50)) + normalizedPowerLevel: 50, + isAccountOwner: false, + isIgnored: false)) + } + + static var mockDan: RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@dan:matrix.org", + displayName: "Dan", + avatarURL: URL.picturesDirectory, + membership: .join, + isNameAmbiguous: false, + powerLevel: 50, + normalizedPowerLevel: 50, + isAccountOwner: false, + isIgnored: false)) + } + + static var mockMe: RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@me:matrix.org", + displayName: "Me", + avatarURL: URL.picturesDirectory, + membership: .join, + isNameAmbiguous: false, + powerLevel: 50, + normalizedPowerLevel: 50, + isAccountOwner: true, + isIgnored: false)) + } + + static var mockIgnored: RoomMemberProxyMock { + RoomMemberProxyMock(with: .init(userID: "@ignored:matrix.org", + displayName: "Ignored", + avatarURL: nil, + membership: .join, + isNameAmbiguous: false, + powerLevel: 50, + normalizedPowerLevel: 50, + isAccountOwner: false, + isIgnored: true)) } } diff --git a/ElementX/Sources/Other/AvatarSize.swift b/ElementX/Sources/Other/AvatarSize.swift index f37243a48..b47199974 100644 --- a/ElementX/Sources/Other/AvatarSize.swift +++ b/ElementX/Sources/Other/AvatarSize.swift @@ -47,6 +47,7 @@ enum UserAvatarSizeOnScreen { case settings case roomDetails case startChat + case memberDetails var value: CGFloat { switch self { @@ -60,6 +61,8 @@ enum UserAvatarSizeOnScreen { return 44 case .startChat: return 36 + case .memberDetails: + return 70 } } } diff --git a/ElementX/Sources/Other/Extensions/Alert.swift b/ElementX/Sources/Other/Extensions/Alert.swift index 2b19cbe2b..eef2439cb 100644 --- a/ElementX/Sources/Other/Extensions/Alert.swift +++ b/ElementX/Sources/Other/Extensions/Alert.swift @@ -21,7 +21,7 @@ protocol AlertItem { } extension View { - func alert(item: Binding, actions: (Item) -> Actions, message: (Item) -> Message) -> some View where Item: AlertItem, Actions: View, Message: View { + func alert(item: Binding, @ViewBuilder actions: (Item) -> Actions, @ViewBuilder message: (Item) -> Message) -> some View where Item: AlertItem, Actions: View, Message: View { let binding = Binding(get: { item.wrappedValue != nil }, set: { newValue in @@ -32,7 +32,7 @@ extension View { return alert(item.wrappedValue?.title ?? "", isPresented: binding, presenting: item.wrappedValue, actions: actions, message: message) } - func alert(item: Binding, actions: (Item) -> Actions) -> some View where Item: AlertItem, Actions: View { + func alert(item: Binding, @ViewBuilder actions: (Item) -> Actions) -> some View where Item: AlertItem, Actions: View { let binding = Binding(get: { item.wrappedValue != nil }, set: { newValue in @@ -43,3 +43,29 @@ extension View { return alert(item.wrappedValue?.title ?? "", isPresented: binding, presenting: item.wrappedValue, actions: actions) } } + +// Only for Alerts that display a simple error message with a message and one or two buttons +struct ErrorAlertItem: AlertItem { + struct Action { + var title: String + var action: () -> Void + } + + var title = ElementL10n.dialogTitleError + var message = ElementL10n.unknownError + var cancelAction = Action(title: ElementL10n.ok, action: { }) + var primaryAction: Action? +} + +extension View { + func errorAlert(item: Binding) -> some View { + alert(item: item) { item in + Button(item.cancelAction.title) { item.cancelAction.action() } + if let primaryAction = item.primaryAction { + Button(primaryAction.title) { primaryAction.action() } + } + } message: { item in + Text(item.message) + } + } +} diff --git a/ElementX/Sources/Other/Extensions/Sequence.swift b/ElementX/Sources/Other/Extensions/Sequence.swift new file mode 100644 index 000000000..d53518cdb --- /dev/null +++ b/ElementX/Sources/Other/Extensions/Sequence.swift @@ -0,0 +1,37 @@ +// +// 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 Foundation + +extension Sequence { + func asyncMap(_ transform: @escaping (Element) async -> T) async -> [T] { + await withTaskGroup(of: T.self) { group in + var transformedElements = [T]() + + for element in self { + group.addTask { + await transform(element) + } + } + + for await transformedElement in group { + transformedElements.append(transformedElement) + } + + return transformedElements + } + } +} diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift index 197dc3b69..d94f1aba3 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift @@ -49,7 +49,7 @@ final class RoomDetailsCoordinator: CoordinatorProtocol { switch action { case .requestMemberDetailsPresentation(let members): - self.presentRoomMemberDetails(members) + self.presentRoomMembersList(members) case .cancel: self.callback?(.cancel) case .leftRoom: @@ -62,13 +62,11 @@ final class RoomDetailsCoordinator: CoordinatorProtocol { AnyView(RoomDetailsScreen(context: viewModel.context)) } - private func presentRoomMemberDetails(_ members: [RoomMemberProxyProtocol]) { - let params = RoomMemberDetailsCoordinatorParameters(mediaProvider: parameters.mediaProvider, - members: members) - let coordinator = RoomMemberDetailsCoordinator(parameters: params) - coordinator.callback = { [weak self] _ in - self?.navigationStackCoordinator.pop() - } + private func presentRoomMembersList(_ members: [RoomMemberProxyProtocol]) { + let params = RoomMembersListCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, + mediaProvider: parameters.mediaProvider, + members: members) + let coordinator = RoomMembersListCoordinator(parameters: params) navigationStackCoordinator.push(coordinator) } diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift index 2a442d0b7..6b3d3219a 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift @@ -86,6 +86,7 @@ struct RoomDetailsMember: Identifiable, Equatable { let name: String? let avatarURL: URL? + @MainActor init(withProxy proxy: RoomMemberProxyProtocol) { id = proxy.userID name = proxy.displayName diff --git a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift index 61aaa0218..ad32b6d97 100644 --- a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift @@ -69,7 +69,7 @@ struct RoomDetailsScreen: View { Button { context.send(viewAction: .copyRoomLink) } label: { Image(systemName: "link") } - .buttonStyle(FormActionButtonStyle(title: ElementL10n.roomDetailsCopyLink)) + .buttonStyle(FormActionButtonStyle(title: ElementL10n.actionCopyLink)) ShareLink(item: permalink) { Image(systemName: "square.and.arrow.up") diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsCoordinator.swift b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsCoordinator.swift similarity index 66% rename from ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsCoordinator.swift rename to ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsCoordinator.swift index d917cd4e3..b28ca07c9 100644 --- a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsCoordinator.swift +++ b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsCoordinator.swift @@ -17,35 +17,26 @@ import SwiftUI struct RoomMemberDetailsCoordinatorParameters { + let roomMemberProxy: RoomMemberProxyProtocol let mediaProvider: MediaProviderProtocol - let members: [RoomMemberProxyProtocol] } -enum RoomMemberDetailsCoordinatorAction { - case cancel -} +enum RoomMemberDetailsCoordinatorAction { } final class RoomMemberDetailsCoordinator: CoordinatorProtocol { + private let parameters: RoomMemberDetailsCoordinatorParameters private var viewModel: RoomMemberDetailsViewModelProtocol - + var callback: ((RoomMemberDetailsCoordinatorAction) -> Void)? - + init(parameters: RoomMemberDetailsCoordinatorParameters) { - viewModel = RoomMemberDetailsViewModel(mediaProvider: parameters.mediaProvider, - members: parameters.members) + self.parameters = parameters + + viewModel = RoomMemberDetailsViewModel(roomMemberProxy: parameters.roomMemberProxy, mediaProvider: parameters.mediaProvider) } - - func start() { - viewModel.callback = { [weak self] action in - guard let self else { return } - - switch action { - case .cancel: - self.callback?(.cancel) - } - } - } - + + func start() { } + func toPresentable() -> AnyView { AnyView(RoomMemberDetailsScreen(context: viewModel.context)) } diff --git a/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsModels.swift b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsModels.swift new file mode 100644 index 000000000..3c29acfcc --- /dev/null +++ b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsModels.swift @@ -0,0 +1,81 @@ +// +// 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 RoomMemberDetailsViewModelAction { } + +struct RoomMemberDetailsViewState: BindableState { + let userID: String + let name: String? + let avatarURL: URL? + let isAccountOwner: Bool + let permalink: URL? + var isIgnored: Bool + + var bindings: RoomMemberDetailsViewStateBindings +} + +struct RoomMemberDetailsViewStateBindings { + var ignoreUserAlert: IgnoreUserAlertItem? + var errorAlert: ErrorAlertItem? +} + +struct IgnoreUserAlertItem: AlertItem { + enum Action { + case ignore + case unignore + } + + let action: Action + let cancelTitle = ElementL10n.actionCancel + + var title: String { + switch action { + case .ignore: return ElementL10n.roomMemberDetailsBlockUser + case .unignore: return ElementL10n.roomMemberDetailsUnblockUser + } + } + + var confirmationTitle: String { + switch action { + case .ignore: return ElementL10n.roomMemberDetailsBlockAlertAction + case .unignore: return ElementL10n.roomMemberDetailsUnblockAlertAction + } + } + + var description: String { + switch action { + case .ignore: return ElementL10n.roomMemberDetailsBlockAlertDescription + case .unignore: return ElementL10n.roomMemberDetailsUnblockAlertDescription + } + } + + var viewAction: RoomMemberDetailsViewAction { + switch action { + case .ignore: return .ignoreConfirmed + case .unignore: return .unignoreConfirmed + } + } +} + +enum RoomMemberDetailsViewAction { + case showUnblockAlert + case showBlockAlert + case ignoreConfirmed + case unignoreConfirmed + case copyUserLink +} diff --git a/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsViewModel.swift b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsViewModel.swift new file mode 100644 index 000000000..ff0275bb8 --- /dev/null +++ b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsViewModel.swift @@ -0,0 +1,83 @@ +// +// 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 + +typealias RoomMemberDetailsViewModelType = StateStoreViewModel + +class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDetailsViewModelProtocol { + let roomMemberProxy: RoomMemberProxyProtocol + + var callback: ((RoomMemberDetailsViewModelAction) -> Void)? + + init(roomMemberProxy: RoomMemberProxyProtocol, mediaProvider: MediaProviderProtocol) { + self.roomMemberProxy = roomMemberProxy + let initialViewState = RoomMemberDetailsViewState(userID: roomMemberProxy.userID, + name: roomMemberProxy.displayName, + avatarURL: roomMemberProxy.avatarURL, + isAccountOwner: roomMemberProxy.isAccountOwner, + permalink: roomMemberProxy.permalink, + isIgnored: roomMemberProxy.isIgnored, + bindings: .init()) + super.init(initialViewState: initialViewState, imageProvider: mediaProvider) + } + + // MARK: - Public + + override func process(viewAction: RoomMemberDetailsViewAction) async { + switch viewAction { + case .showUnblockAlert: + state.bindings.ignoreUserAlert = .init(action: .unignore) + case .showBlockAlert: + state.bindings.ignoreUserAlert = .init(action: .ignore) + case .copyUserLink: + copyUserLink() + case .ignoreConfirmed: + await ignoreUser() + case .unignoreConfirmed: + await unignoreUser() + } + } + + // MARK: - Private + + private func copyUserLink() { + if let userLink = state.permalink { + UIPasteboard.general.url = userLink + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: ElementL10n.linkCopiedToClipboard)) + } else { + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: ElementL10n.unknownError)) + } + } + + private func ignoreUser() async { + switch await roomMemberProxy.ignoreUser() { + case .success: + state.isIgnored = true + case .failure: + state.bindings.errorAlert = .init() + } + } + + private func unignoreUser() async { + switch await roomMemberProxy.unignoreUser() { + case .success: + state.isIgnored = false + case .failure: + state.bindings.errorAlert = .init() + } + } +} diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModelProtocol.swift b/ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsViewModelProtocol.swift similarity index 100% rename from ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModelProtocol.swift rename to ElementX/Sources/Screens/RoomMemberDetails/RoomMemberDetailsViewModelProtocol.swift diff --git a/ElementX/Sources/Screens/RoomMemberDetails/View/RoomMemberDetailsScreen.swift b/ElementX/Sources/Screens/RoomMemberDetails/View/RoomMemberDetailsScreen.swift new file mode 100644 index 000000000..dfb3cadbc --- /dev/null +++ b/ElementX/Sources/Screens/RoomMemberDetails/View/RoomMemberDetailsScreen.swift @@ -0,0 +1,137 @@ +// +// 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 RoomMemberDetailsScreen: View { + @ObservedObject var context: RoomMemberDetailsViewModel.Context + + var body: some View { + Form { + headerSection + + // TODO: Uncomment when the feature is ready +// if !context.viewState.isAccountOwner { +// blockUserSection +// } + } + .scrollContentBackground(.hidden) + .background(Color.element.formBackground.ignoresSafeArea()) + .alert(item: $context.ignoreUserAlert, actions: blockUserAlertActions, message: blockUserAlertMessage) + .errorAlert(item: $context.errorAlert) + } + + // MARK: - Private + + private var headerSection: some View { + VStack(spacing: 8.0) { + LoadableAvatarImage(url: context.viewState.avatarURL, + name: context.viewState.name, + contentID: context.viewState.userID, + avatarSize: .user(on: .memberDetails), + imageProvider: context.imageProvider) + if let name = context.viewState.name { + Text(name) + .foregroundColor(.element.primaryContent) + .font(.element.title1Bold) + .multilineTextAlignment(.center) + } + Text(context.viewState.userID) + .foregroundColor(.element.secondaryContent) + .font(.element.body) + .multilineTextAlignment(.center) + + if let permalink = context.viewState.permalink { + HStack(spacing: 32) { + Button { context.send(viewAction: .copyUserLink) } label: { + Image(systemName: "link") + } + .buttonStyle(FormActionButtonStyle(title: ElementL10n.actionCopyLink)) + + ShareLink(item: permalink) { + Image(systemName: "square.and.arrow.up") + } + .buttonStyle(FormActionButtonStyle(title: ElementL10n.actionShareLink)) + } + .padding(.top, 32) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + } + + private var blockUserSection: some View { + Section { + if context.viewState.isIgnored { + Button { + context.send(viewAction: .showUnblockAlert) + } label: { + Label(ElementL10n.roomMemberDetailsUnblockUser, systemImage: "slash.circle") + } + .buttonStyle(FormButtonStyle(accessory: nil)) + } else { + Button(role: .destructive) { + context.send(viewAction: .showBlockAlert) + } label: { + Label(ElementL10n.roomMemberDetailsBlockUser, systemImage: "slash.circle") + } + .buttonStyle(FormButtonStyle(accessory: nil)) + } + } + .formSectionStyle() + } + + @ViewBuilder + private func blockUserAlertActions(_ item: IgnoreUserAlertItem) -> some View { + Button(item.cancelTitle, role: .cancel) { } + Button(item.confirmationTitle, + role: item.action == .ignore ? .destructive : nil) { + context.send(viewAction: item.viewAction) + } + } + + private func blockUserAlertMessage(_ item: IgnoreUserAlertItem) -> some View { + Text(item.description) + } +} + +// MARK: - Previews + +struct RoomMemberDetails_Previews: PreviewProvider { + static let otherUserViewModel = { + let member = RoomMemberProxyMock.mockDan + return RoomMemberDetailsViewModel(roomMemberProxy: member, mediaProvider: MockMediaProvider()) + }() + + static let accountOwnerViewModel = { + let member = RoomMemberProxyMock.mockMe + return RoomMemberDetailsViewModel(roomMemberProxy: member, mediaProvider: MockMediaProvider()) + }() + + static let ignoredUserViewModel = { + let member = RoomMemberProxyMock.mockIgnored + return RoomMemberDetailsViewModel(roomMemberProxy: member, mediaProvider: MockMediaProvider()) + }() + + static var previews: some View { + RoomMemberDetailsScreen(context: otherUserViewModel.context) + .previewDisplayName("Other User") + RoomMemberDetailsScreen(context: accountOwnerViewModel.context) + .previewDisplayName("Account Owner") + RoomMemberDetailsScreen(context: ignoredUserViewModel.context) + .previewDisplayName("Ignored User") + } +} diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMembersListCoordinator.swift b/ElementX/Sources/Screens/RoomMembers/RoomMembersListCoordinator.swift new file mode 100644 index 000000000..d88e0ab42 --- /dev/null +++ b/ElementX/Sources/Screens/RoomMembers/RoomMembersListCoordinator.swift @@ -0,0 +1,64 @@ +// +// 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 RoomMembersListCoordinatorParameters { + let navigationStackCoordinator: NavigationStackCoordinator + let mediaProvider: MediaProviderProtocol + let members: [RoomMemberProxyProtocol] +} + +enum RoomMembersListCoordinatorAction { } + +final class RoomMembersListCoordinator: CoordinatorProtocol { + private let parameters: RoomMembersListCoordinatorParameters + private var viewModel: RoomMembersListViewModelProtocol + private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator } + + var callback: ((RoomMembersListCoordinatorAction) -> Void)? + + init(parameters: RoomMembersListCoordinatorParameters) { + self.parameters = parameters + + viewModel = RoomMembersListViewModel(mediaProvider: parameters.mediaProvider, + members: parameters.members) + } + + func start() { + viewModel.callback = { [weak self] action in + guard let self else { return } + + switch action { + case let .selectMember(member): + self.selectMember(member) + } + } + } + + func toPresentable() -> AnyView { + AnyView(RoomMembersListScreen(context: viewModel.context)) + } + + // MARK: - Private + + private func selectMember(_ member: RoomMemberProxyProtocol) { + let parameters = RoomMemberDetailsCoordinatorParameters(roomMemberProxy: member, mediaProvider: parameters.mediaProvider) + let coordinator = RoomMemberDetailsCoordinator(parameters: parameters) + + navigationStackCoordinator.push(coordinator) + } +} diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift b/ElementX/Sources/Screens/RoomMembers/RoomMembersListModels.swift similarity index 81% rename from ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift rename to ElementX/Sources/Screens/RoomMembers/RoomMembersListModels.swift index 5c354f4c5..3cfdba82f 100644 --- a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsModels.swift +++ b/ElementX/Sources/Screens/RoomMembers/RoomMembersListModels.swift @@ -16,14 +16,14 @@ import Foundation -enum RoomMemberDetailsViewModelAction { - case cancel +enum RoomMembersListViewModelAction { + case selectMember(_ member: RoomMemberProxyProtocol) } -struct RoomMemberDetailsViewState: BindableState { +struct RoomMembersListViewState: BindableState { var members: [RoomDetailsMember] - var bindings: RoomMemberDetailsViewStateBindings + var bindings: RoomMembersListViewStateBindings var visibleMembers: [RoomDetailsMember] { if bindings.searchQuery.isEmpty { @@ -37,13 +37,13 @@ struct RoomMemberDetailsViewState: BindableState { } } -struct RoomMemberDetailsViewStateBindings { +struct RoomMembersListViewStateBindings { var searchQuery = "" /// Information describing the currently displayed alert. var alertInfo: AlertInfo? } -enum RoomMemberDetailsViewAction { +enum RoomMembersListViewAction { case selectMember(id: String) } diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift b/ElementX/Sources/Screens/RoomMembers/RoomMembersListViewModel.swift similarity index 63% rename from ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift rename to ElementX/Sources/Screens/RoomMembers/RoomMembersListViewModel.swift index 572671e4f..1cf680a5e 100644 --- a/ElementX/Sources/Screens/RoomMembers/RoomMemberDetailsViewModel.swift +++ b/ElementX/Sources/Screens/RoomMembers/RoomMembersListViewModel.swift @@ -16,16 +16,18 @@ import SwiftUI -typealias RoomMemberDetailsViewModelType = StateStoreViewModel +typealias RoomMembersListViewModelType = StateStoreViewModel -class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDetailsViewModelProtocol { +class RoomMembersListViewModel: RoomMembersListViewModelType, RoomMembersListViewModelProtocol { private let mediaProvider: MediaProviderProtocol + private let members: [RoomMemberProxyProtocol] - var callback: ((RoomMemberDetailsViewModelAction) -> Void)? + var callback: ((RoomMembersListViewModelAction) -> Void)? init(mediaProvider: MediaProviderProtocol, members: [RoomMemberProxyProtocol]) { self.mediaProvider = mediaProvider + self.members = members super.init(initialViewState: .init(members: members.map { RoomDetailsMember(withProxy: $0) }, bindings: .init()), imageProvider: mediaProvider) @@ -33,10 +35,14 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta // MARK: - Public - override func process(viewAction: RoomMemberDetailsViewAction) async { + override func process(viewAction: RoomMembersListViewAction) async { switch viewAction { case .selectMember(let id): - MXLog.debug("Member selected: \(id)") + guard let member = members.first(where: { $0.userID == id }) else { + MXLog.error("Selected member \(id) not found") + return + } + callback?(.selectMember(member)) } } } diff --git a/ElementX/Sources/Screens/RoomMembers/RoomMembersListViewModelProtocol.swift b/ElementX/Sources/Screens/RoomMembers/RoomMembersListViewModelProtocol.swift new file mode 100644 index 000000000..79ca038ce --- /dev/null +++ b/ElementX/Sources/Screens/RoomMembers/RoomMembersListViewModelProtocol.swift @@ -0,0 +1,23 @@ +// +// 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 + +@MainActor +protocol RoomMembersListViewModelProtocol { + var callback: ((RoomMembersListViewModelAction) -> Void)? { get set } + var context: RoomMembersListViewModelType.Context { get } +} diff --git a/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift b/ElementX/Sources/Screens/RoomMembers/View/RoomMembersListMemberCell.swift similarity index 81% rename from ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift rename to ElementX/Sources/Screens/RoomMembers/View/RoomMembersListMemberCell.swift index 7081f5711..9873e6bd7 100644 --- a/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsMemberCell.swift +++ b/ElementX/Sources/Screens/RoomMembers/View/RoomMembersListMemberCell.swift @@ -16,11 +16,11 @@ import SwiftUI -struct RoomMemberDetailsMemberCell: View { +struct RoomMembersListMemberCell: View { @ScaledMetric private var avatarSize = AvatarSize.user(on: .roomDetails).value let member: RoomDetailsMember - let context: RoomMemberDetailsViewModel.Context + let context: RoomMembersListViewModel.Context var body: some View { Button { @@ -46,19 +46,19 @@ struct RoomMemberDetailsMemberCell: View { } } -struct RoomMemberDetailsMemberCell_Previews: PreviewProvider { +struct RoomMembersListMemberCell_Previews: PreviewProvider { static var previews: some View { let members: [RoomMemberProxyMock] = [ .mockAlice, .mockBob, .mockCharlie ] - let viewModel = RoomMemberDetailsViewModel(mediaProvider: MockMediaProvider(), - members: members) + let viewModel = RoomMembersListViewModel(mediaProvider: MockMediaProvider(), + members: members) return VStack { ForEach(members, id: \.userID) { member in - RoomMemberDetailsMemberCell(member: .init(withProxy: member), context: viewModel.context) + RoomMembersListMemberCell(member: .init(withProxy: member), context: viewModel.context) } } } diff --git a/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsScreen.swift b/ElementX/Sources/Screens/RoomMembers/View/RoomMembersListScreen.swift similarity index 80% rename from ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsScreen.swift rename to ElementX/Sources/Screens/RoomMembers/View/RoomMembersListScreen.swift index 0eeadda7e..f90ddf49e 100644 --- a/ElementX/Sources/Screens/RoomMembers/View/RoomMemberDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomMembers/View/RoomMembersListScreen.swift @@ -16,17 +16,17 @@ import SwiftUI -struct RoomMemberDetailsScreen: View { +struct RoomMembersListScreen: View { @Environment(\.colorScheme) private var colorScheme - @ObservedObject var context: RoomMemberDetailsViewModel.Context + @ObservedObject var context: RoomMembersListViewModel.Context var body: some View { ScrollView { LazyVStack(alignment: .leading) { Section { ForEach(context.viewState.visibleMembers) { member in - RoomMemberDetailsMemberCell(member: member, context: context) + RoomMembersListMemberCell(member: member, context: context) .id(member.id) } } header: { @@ -48,20 +48,20 @@ struct RoomMemberDetailsScreen: View { // MARK: - Previews -struct RoomMemberDetails_Previews: PreviewProvider { +struct RoomMembersList_Previews: PreviewProvider { static let viewModel = { let members: [RoomMemberProxyMock] = [ .mockAlice, .mockBob, .mockCharlie ] - return RoomMemberDetailsViewModel(mediaProvider: MockMediaProvider(), - members: members) + return RoomMembersListViewModel(mediaProvider: MockMediaProvider(), + members: members) }() static var previews: some View { NavigationStack { - RoomMemberDetailsScreen(context: viewModel.context) + RoomMembersListScreen(context: viewModel.context) } } } diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift index 1ec5db6b4..b3e1d3fda 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift @@ -18,9 +18,16 @@ import Foundation import MatrixRustSDK final class RoomMemberProxy: RoomMemberProxyProtocol { + private let backgroundTaskService: BackgroundTaskServiceProtocol private let member: RoomMemberProtocol - init(member: RoomMemberProtocol) { + private let backgroundAccountDataTaskName = "SendAccountDataEvent" + private var sendAccountDataEventBackgroundTask: BackgroundTaskProtocol? + + private let userInitiatedDispatchQueue = DispatchQueue(label: "io.element.elementx.roommemberproxy.userinitiated", qos: .userInitiated) + + init(member: RoomMemberProtocol, backgroundTaskService: BackgroundTaskServiceProtocol) { + self.backgroundTaskService = backgroundTaskService self.member = member } @@ -51,4 +58,44 @@ final class RoomMemberProxy: RoomMemberProxyProtocol { var normalizedPowerLevel: Int { Int(member.normalizedPowerLevel()) } + + var isAccountOwner: Bool { + member.isAccountUser() + } + + var isIgnored: Bool { + member.isIgnored() + } + + func ignoreUser() async -> Result { + sendAccountDataEventBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true) + defer { + sendAccountDataEventBackgroundTask?.stop() + } + + return await Task.dispatch(on: userInitiatedDispatchQueue) { + do { + try self.member.ignore() + return .success(()) + } catch { + return .failure(.ignoreUserFailed) + } + } + } + + func unignoreUser() async -> Result { + sendAccountDataEventBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true) + defer { + sendAccountDataEventBackgroundTask?.stop() + } + + return await Task.dispatch(on: userInitiatedDispatchQueue) { + do { + try self.member.unignore() + return .success(()) + } catch { + return .failure(.unignoreUserFailed) + } + } + } } diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index f0c2394ef..1ce38bee7 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -17,6 +17,12 @@ import Foundation import MatrixRustSDK +enum RoomMemberProxyError: Error { + case ignoreUserFailed + case unignoreUserFailed +} + +@MainActor // sourcery: AutoMockable protocol RoomMemberProxyProtocol { var userID: String { get } @@ -26,4 +32,15 @@ protocol RoomMemberProxyProtocol { var isNameAmbiguous: Bool { get } var powerLevel: Int { get } var normalizedPowerLevel: Int { get } + var isAccountOwner: Bool { get } + var isIgnored: Bool { get } + + func ignoreUser() async -> Result + func unignoreUser() async -> Result +} + +extension RoomMemberProxyProtocol { + var permalink: URL? { + try? PermalinkBuilder.permalinkTo(userIdentifier: userID) + } } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index be7be9e5a..9a283bd75 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -272,13 +272,16 @@ class RoomProxy: RoomProxyProtocol { } func members() async -> Result<[RoomMemberProxyProtocol], RoomProxyError> { - await Task.dispatch(on: .global()) { - do { + do { + let members = try await Task.dispatch(on: .global()) { let members = try self.room.members() - return .success(members.map { RoomMemberProxy(member: $0) }) - } catch { - return .failure(.failedRetrievingMembers) + return members } + + let proxiedMembers = await members.asyncMap { RoomMemberProxy(member: $0, backgroundTaskService: self.backgroundTaskService) } + return .success(proxiedMembers) + } catch { + return .failure(.failedRetrievingMembers) } } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index f027b7757..e3f64edeb 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -295,11 +295,12 @@ class MockScreen: Identifiable { mediaProvider: MockMediaProvider())) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator - case .roomMemberDetailsScreen: + case .roomMembersListScreen: let navigationStackCoordinator = NavigationStackCoordinator() let members: [RoomMemberProxyMock] = [.mockAlice, .mockBob, .mockCharlie] - let coordinator = RoomMemberDetailsCoordinator(parameters: .init(mediaProvider: MockMediaProvider(), - members: members)) + let coordinator = RoomMembersListCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, + mediaProvider: MockMediaProvider(), + members: members)) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .reportContent: @@ -312,6 +313,11 @@ class MockScreen: Identifiable { let coordinator = StartChatCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider()))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator + case .roomMemberDetailsAccountOwner: + let navigationStackCoordinator = NavigationStackCoordinator() + let coordinator = RoomMemberDetailsCoordinator(parameters: .init(roomMemberProxy: RoomMemberProxyMock.mockMe, mediaProvider: MockMediaProvider())) + navigationStackCoordinator.setRootCoordinator(coordinator) + return navigationStackCoordinator } }() } diff --git a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift index aa66ef0f1..2b6de307b 100644 --- a/ElementX/Sources/UITests/UITestsScreenIdentifier.swift +++ b/ElementX/Sources/UITests/UITestsScreenIdentifier.swift @@ -42,7 +42,8 @@ enum UITestsScreenIdentifier: String { case userSessionScreen case roomDetailsScreen case roomDetailsScreenWithRoomAvatar - case roomMemberDetailsScreen + case roomMembersListScreen + case roomMemberDetailsAccountOwner case reportContent case startChat } diff --git a/UITests/Sources/RoomMemberDetailsScreenUITests.swift b/UITests/Sources/RoomMemberDetailsScreenUITests.swift index c1ccf2e26..e41a46257 100644 --- a/UITests/Sources/RoomMemberDetailsScreenUITests.swift +++ b/UITests/Sources/RoomMemberDetailsScreenUITests.swift @@ -18,9 +18,8 @@ import ElementX import XCTest class RoomMemberDetailsScreenUITests: XCTestCase { - func testInitialStateComponents() { - let app = Application.launch(.roomMemberDetailsScreen) - - app.assertScreenshot(.roomMemberDetailsScreen) + func testInitialStateComponentsForAccountOwner() { + let app = Application.launch(.roomMemberDetailsAccountOwner) + app.assertScreenshot(.roomMemberDetailsAccountOwner) } } diff --git a/UITests/Sources/RoomMembersListScreenUITests.swift b/UITests/Sources/RoomMembersListScreenUITests.swift new file mode 100644 index 000000000..67fd040b2 --- /dev/null +++ b/UITests/Sources/RoomMembersListScreenUITests.swift @@ -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 ElementX +import XCTest + +class RoomMembersListScreenUITests: XCTestCase { + func testInitialStateComponents() { + let app = Application.launch(.roomMembersListScreen) + + app.assertScreenshot(.roomMembersListScreen) + } +} diff --git a/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomMemberDetailsAccountOwner.png b/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomMemberDetailsAccountOwner.png new file mode 100644 index 000000000..62bb0292c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomMemberDetailsAccountOwner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5afc94645db66763519ca354f302e3e3ca5e380c9a7a9b6d634b4fc9ed98c31c +size 73131 diff --git a/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreenWithRoomAvatar.png b/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreenWithRoomAvatar.png index 3201b2e48..b8e82a30e 100644 --- a/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreenWithRoomAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreenWithRoomAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d6d23f2c8fb88c9baeab1181bbc1a59dc07fc36da4d9f3192f4e3d943e60a70 -size 150567 +oid sha256:abfabc7f8c5f26e407e1f6263678bff477afb1e44dff8f5d612e76cd367e41da +size 152690 diff --git a/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomMemberDetailsAccountOwner.png b/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomMemberDetailsAccountOwner.png new file mode 100644 index 000000000..aa6c23f53 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomMemberDetailsAccountOwner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65b9fb1f561d907511a365a893840e638fdab7905a16a3a67e8136ea86a7ae6d +size 77514 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreen.png index f09238ebe..429499da6 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreen.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7533db27ed76004d975897520ea841421fb54aa77c9966330e41771ef7c18dba -size 91562 +oid sha256:90e6f07709c9cad3b3c2b085eaff8f546c1ff5b67d6f010c075c2a3b7ec78570 +size 91291 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png index 9ee2ab142..9ae02ad48 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d267a4f41ee25c78e7beba699d89f67fa03da101a7b2cf30a973731cc38267f -size 116394 +oid sha256:02de3604c91bc4566ebb8a4805906f6567b6fb96c9e7d773a1e4351c57c6950e +size 116185 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomMemberDetailsAccountOwner.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomMemberDetailsAccountOwner.png new file mode 100644 index 000000000..62bb0292c --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPad-9th-generation.roomMemberDetailsAccountOwner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5afc94645db66763519ca354f302e3e3ca5e380c9a7a9b6d634b4fc9ed98c31c +size 73131 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreen.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreen.png index 6ea6703a2..252ed3099 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreen.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d52f746ba47e31fb6aaa72dc7def6cd3ce9bde9a32eb2bbe7ad0da16d2249745 -size 109874 +oid sha256:f53cb9fcc716b9d58c78a6fd31f6181c9ccee3561d53c0850fa8b0c586637241 +size 109441 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithRoomAvatar.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithRoomAvatar.png index 9a649eedd..ed5e04105 100644 --- a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithRoomAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomDetailsScreenWithRoomAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7d2db5f50221fcea17f3f0453b5098cae0dbdf16a1d531d34ad5656917481c4 -size 146955 +oid sha256:488bbb53e818a65a3b7e9aeeb362477c5550acca91296f5f994534a8271e7f7b +size 148772 diff --git a/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomMemberDetailsAccountOwner.png b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomMemberDetailsAccountOwner.png new file mode 100644 index 000000000..aa6c23f53 --- /dev/null +++ b/UITests/Sources/__Snapshots__/Application/en-GB-iPhone-14.roomMemberDetailsAccountOwner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65b9fb1f561d907511a365a893840e638fdab7905a16a3a67e8136ea86a7ae6d +size 77514 diff --git a/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift b/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift index a68deec95..b5825e825 100644 --- a/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomMemberDetailsViewModelTests.swift @@ -19,4 +19,50 @@ import XCTest @testable import ElementX @MainActor -class RoomMemberDetailsScreenViewModelTests: XCTestCase { } +class RoomMemberDetailsViewModelTests: XCTestCase { + var viewModel: RoomMemberDetailsViewModelProtocol! + var roomMemberProxyMock: RoomMemberProxyMock! + var context: RoomMemberDetailsViewModelType.Context { viewModel.context } + + func testInitialState() async { + roomMemberProxyMock = RoomMemberProxyMock.mockAlice + viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider()) + + XCTAssertEqual(context.viewState.name, "Alice") + XCTAssertFalse(context.viewState.isAccountOwner) + XCTAssertFalse(context.viewState.isIgnored) + XCTAssertEqual(context.viewState.userID, "@alice:matrix.org") + XCTAssertEqual(context.viewState.permalink, URL(string: "https://matrix.to/#/@alice:matrix.org")) + XCTAssertEqual(context.viewState.avatarURL, nil) + XCTAssertNil(context.ignoreUserAlert) + XCTAssertNil(context.errorAlert) + } + + func testInitialStateAccountOwner() async { + roomMemberProxyMock = RoomMemberProxyMock.mockMe + viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider()) + + XCTAssertEqual(context.viewState.name, "Me") + XCTAssertTrue(context.viewState.isAccountOwner) + XCTAssertFalse(context.viewState.isIgnored) + XCTAssertEqual(context.viewState.userID, "@me:matrix.org") + XCTAssertEqual(context.viewState.permalink, URL(string: "https://matrix.to/#/@me:matrix.org")) + XCTAssertEqual(context.viewState.avatarURL, URL.picturesDirectory) + XCTAssertNil(context.ignoreUserAlert) + XCTAssertNil(context.errorAlert) + } + + func testInitialStateIgnoredUser() async { + roomMemberProxyMock = RoomMemberProxyMock.mockIgnored + viewModel = RoomMemberDetailsViewModel(roomMemberProxy: roomMemberProxyMock, mediaProvider: MockMediaProvider()) + + XCTAssertEqual(context.viewState.name, "Ignored") + XCTAssertFalse(context.viewState.isAccountOwner) + XCTAssertTrue(context.viewState.isIgnored) + XCTAssertEqual(context.viewState.userID, "@ignored:matrix.org") + XCTAssertEqual(context.viewState.permalink, URL(string: "https://matrix.to/#/@ignored:matrix.org")) + XCTAssertEqual(context.viewState.avatarURL, nil) + XCTAssertNil(context.ignoreUserAlert) + XCTAssertNil(context.errorAlert) + } +} diff --git a/UnitTests/Sources/RoomMembersListViewModelTests.swift b/UnitTests/Sources/RoomMembersListViewModelTests.swift new file mode 100644 index 000000000..64976147b --- /dev/null +++ b/UnitTests/Sources/RoomMembersListViewModelTests.swift @@ -0,0 +1,22 @@ +// +// 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 XCTest + +@testable import ElementX + +@MainActor +class RoomMembersListScreenViewModelTests: XCTestCase { } diff --git a/changelog.d/723.feature b/changelog.d/723.feature new file mode 100644 index 000000000..fc6984eb6 --- /dev/null +++ b/changelog.d/723.feature @@ -0,0 +1 @@ +Added the Room Member Details Screen. \ No newline at end of file