From 61d42a24ba64133ce136c4783a9693f1c4d8afd8 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Fri, 17 Mar 2023 14:57:08 +0100 Subject: [PATCH] Leave Room (#699) * created the row in the view and the alert, and added the new function to the RoomProxy * fixed an issue with the alert function * handling the navigation * fixed a bug with the detail coordinators being dismissed incorrectly when inside a stack * implementation completed * replaced UI screenshots * added a test for the fixed bug of the coordinators * trying to increase the wait time for the expectation * improved the test * improved the buttons UI * uploading artifacts for unit tests * added result bundle true * improved the tests * added a new test * pr suggestions * updating mock * PR suggestions * improved tests * fixed UI tests * pr should be ready now * removed testing code * reduced complexity * fixed test * added a an assert to the new test case * more tests and messages cases * pr comments addressed * completed --- .github/workflows/unit_tests.yml | 9 + .../en.lproj/Untranslated.strings | 4 + .../Navigation/NavigationCoordinators.swift | 2 +- .../Generated/Strings+Untranslated.swift | 8 + .../Mocks/Generated/GeneratedMocks.swift | 317 ++++++++++++++++++ ElementX/Sources/Mocks/RoomProxyMock.swift | 61 ++++ ElementX/Sources/Other/Extensions/Alert.swift | 4 +- .../Form Styles/FormButtonStyles.swift | 16 +- .../Form Styles/FormRowLabelStyle.swift | 36 +- .../UserIndicatorToastView.swift | 2 +- .../View/ReportContentScreen.swift | 2 +- .../RoomDetails/RoomDetailsCoordinator.swift | 3 + .../RoomDetails/RoomDetailsModels.swift | 33 +- .../RoomDetails/RoomDetailsViewModel.swift | 24 ++ .../RoomDetails/View/RoomDetailsScreen.swift | 41 ++- .../RoomScreen/RoomScreenCoordinator.swift | 15 +- .../Services/Client/MockClientProxy.swift | 4 +- .../Sources/Services/Room/MockRoomProxy.swift | 89 ----- .../Sources/Services/Room/RoomProxy.swift | 17 + .../Services/Room/RoomProxyProtocol.swift | 8 +- .../UserSessionFlowCoordinator.swift | 11 + .../UITests/UITestsAppCoordinator.swift | 40 +-- .../Sourcery/sourcery_automockable_config.yml | 2 +- ...-iPad-9th-generation.roomDetailsScreen.png | 4 +- ...ration.roomDetailsScreenWithRoomAvatar.png | 4 +- .../de-DE-iPhone-14.roomDetailsScreen.png | 4 +- ...one-14.roomDetailsScreenWithRoomAvatar.png | 4 +- ...-iPad-9th-generation.roomDetailsScreen.png | 4 +- ...ration.roomDetailsScreenWithRoomAvatar.png | 4 +- .../en-GB-iPhone-14.roomDetailsScreen.png | 4 +- ...one-14.roomDetailsScreenWithRoomAvatar.png | 4 +- .../NavigationSplitCoordinatorTests.swift | 26 ++ .../Sources/ReportContentViewModelTests.swift | 2 +- .../Sources/RoomDetailsViewModelTests.swift | 63 +++- fastlane/Fastfile | 3 +- 35 files changed, 719 insertions(+), 155 deletions(-) create mode 100644 ElementX/Sources/Mocks/RoomProxyMock.swift delete mode 100644 ElementX/Sources/Services/Room/MockRoomProxy.swift diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index de37ae5c4..8295daba4 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -41,6 +41,15 @@ jobs: - name: Run tests run: bundle exec fastlane unit_tests + + - name: Archive artifacts + uses: actions/upload-artifact@v3 + if: always() + with: + name: test-output + path: fastlane/test_output + retention-days: 7 + if-no-files-found: ignore - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index a861c61f3..fff0c17de 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -73,6 +73,10 @@ "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"; // Onboarding "ftue_auth_carousel_welcome_title" = "Be in your Element"; diff --git a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift index 08de762ba..50fbd12ce 100644 --- a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift +++ b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift @@ -528,7 +528,7 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS } popToRoot(animated: false) - + rootModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback) } diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 2b4176ea7..9b549705e 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -102,6 +102,14 @@ extension ElementL10n { 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. + public static let roomDetailsLeavePrivateRoomAlertSubtitle = ElementL10n.tr("Untranslated", "room_details_leave_private_room_alert_subtitle") + /// Are you sure that you want to leave the room? + public static let roomDetailsLeaveRoomAlertSubtitle = ElementL10n.tr("Untranslated", "room_details_leave_room_alert_subtitle") + /// Room left + public static let roomDetailsRoomLeftToast = ElementL10n.tr("Untranslated", "room_details_room_left_toast") /// Info public static let roomDetailsTitle = ElementL10n.tr("Untranslated", "room_details_title") /// Failed loading messages diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 0e2f0167e..c5d21e794 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -3,6 +3,7 @@ // swiftlint:disable all import Combine +import Foundation import MatrixRustSDK class BugReportServiceMock: BugReportServiceProtocol { var crashedLastRun: Bool { @@ -49,6 +50,322 @@ class BugReportServiceMock: BugReportServiceProtocol { } } } +class RoomProxyMock: RoomProxyProtocol { + var id: String { + get { return underlyingId } + set(value) { underlyingId = value } + } + var underlyingId: String! + var isDirect: Bool { + get { return underlyingIsDirect } + set(value) { underlyingIsDirect = value } + } + var underlyingIsDirect: Bool! + var isPublic: Bool { + get { return underlyingIsPublic } + set(value) { underlyingIsPublic = value } + } + var underlyingIsPublic: Bool! + var isSpace: Bool { + get { return underlyingIsSpace } + set(value) { underlyingIsSpace = value } + } + var underlyingIsSpace: Bool! + var isEncrypted: Bool { + get { return underlyingIsEncrypted } + set(value) { underlyingIsEncrypted = value } + } + var underlyingIsEncrypted: Bool! + var isTombstoned: Bool { + get { return underlyingIsTombstoned } + set(value) { underlyingIsTombstoned = value } + } + var underlyingIsTombstoned: Bool! + var canonicalAlias: String? + var alternativeAliases: [String] = [] + var hasUnreadNotifications: Bool { + get { return underlyingHasUnreadNotifications } + set(value) { underlyingHasUnreadNotifications = value } + } + var underlyingHasUnreadNotifications: Bool! + var name: String? + var displayName: String? + var topic: String? + var avatarURL: URL? + + //MARK: - loadAvatarURLForUserId + + var loadAvatarURLForUserIdCallsCount = 0 + var loadAvatarURLForUserIdCalled: Bool { + return loadAvatarURLForUserIdCallsCount > 0 + } + var loadAvatarURLForUserIdReceivedUserId: String? + var loadAvatarURLForUserIdReceivedInvocations: [String] = [] + var loadAvatarURLForUserIdReturnValue: Result! + var loadAvatarURLForUserIdClosure: ((String) async -> Result)? + + func loadAvatarURLForUserId(_ userId: String) async -> Result { + loadAvatarURLForUserIdCallsCount += 1 + loadAvatarURLForUserIdReceivedUserId = userId + loadAvatarURLForUserIdReceivedInvocations.append(userId) + if let loadAvatarURLForUserIdClosure = loadAvatarURLForUserIdClosure { + return await loadAvatarURLForUserIdClosure(userId) + } else { + return loadAvatarURLForUserIdReturnValue + } + } + //MARK: - loadDisplayNameForUserId + + var loadDisplayNameForUserIdCallsCount = 0 + var loadDisplayNameForUserIdCalled: Bool { + return loadDisplayNameForUserIdCallsCount > 0 + } + var loadDisplayNameForUserIdReceivedUserId: String? + var loadDisplayNameForUserIdReceivedInvocations: [String] = [] + var loadDisplayNameForUserIdReturnValue: Result! + var loadDisplayNameForUserIdClosure: ((String) async -> Result)? + + func loadDisplayNameForUserId(_ userId: String) async -> Result { + loadDisplayNameForUserIdCallsCount += 1 + loadDisplayNameForUserIdReceivedUserId = userId + loadDisplayNameForUserIdReceivedInvocations.append(userId) + if let loadDisplayNameForUserIdClosure = loadDisplayNameForUserIdClosure { + return await loadDisplayNameForUserIdClosure(userId) + } else { + return loadDisplayNameForUserIdReturnValue + } + } + //MARK: - addTimelineListener + + var addTimelineListenerListenerCallsCount = 0 + var addTimelineListenerListenerCalled: Bool { + return addTimelineListenerListenerCallsCount > 0 + } + var addTimelineListenerListenerReceivedListener: TimelineListener? + var addTimelineListenerListenerReceivedInvocations: [TimelineListener] = [] + var addTimelineListenerListenerReturnValue: Result<[TimelineItem], RoomProxyError>! + var addTimelineListenerListenerClosure: ((TimelineListener) -> Result<[TimelineItem], RoomProxyError>)? + + func addTimelineListener(listener: TimelineListener) -> Result<[TimelineItem], RoomProxyError> { + addTimelineListenerListenerCallsCount += 1 + addTimelineListenerListenerReceivedListener = listener + addTimelineListenerListenerReceivedInvocations.append(listener) + if let addTimelineListenerListenerClosure = addTimelineListenerListenerClosure { + return addTimelineListenerListenerClosure(listener) + } else { + return addTimelineListenerListenerReturnValue + } + } + //MARK: - removeTimelineListener + + var removeTimelineListenerCallsCount = 0 + var removeTimelineListenerCalled: Bool { + return removeTimelineListenerCallsCount > 0 + } + var removeTimelineListenerClosure: (() -> Void)? + + func removeTimelineListener() { + removeTimelineListenerCallsCount += 1 + removeTimelineListenerClosure?() + } + //MARK: - paginateBackwards + + var paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount = 0 + var paginateBackwardsRequestSizeUntilNumberOfItemsCalled: Bool { + return paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount > 0 + } + var paginateBackwardsRequestSizeUntilNumberOfItemsReceivedArguments: (requestSize: UInt, untilNumberOfItems: UInt)? + var paginateBackwardsRequestSizeUntilNumberOfItemsReceivedInvocations: [(requestSize: UInt, untilNumberOfItems: UInt)] = [] + var paginateBackwardsRequestSizeUntilNumberOfItemsReturnValue: Result! + var paginateBackwardsRequestSizeUntilNumberOfItemsClosure: ((UInt, UInt) async -> Result)? + + func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result { + paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount += 1 + paginateBackwardsRequestSizeUntilNumberOfItemsReceivedArguments = (requestSize: requestSize, untilNumberOfItems: untilNumberOfItems) + paginateBackwardsRequestSizeUntilNumberOfItemsReceivedInvocations.append((requestSize: requestSize, untilNumberOfItems: untilNumberOfItems)) + if let paginateBackwardsRequestSizeUntilNumberOfItemsClosure = paginateBackwardsRequestSizeUntilNumberOfItemsClosure { + return await paginateBackwardsRequestSizeUntilNumberOfItemsClosure(requestSize, untilNumberOfItems) + } else { + return paginateBackwardsRequestSizeUntilNumberOfItemsReturnValue + } + } + //MARK: - sendReadReceipt + + var sendReadReceiptForCallsCount = 0 + var sendReadReceiptForCalled: Bool { + return sendReadReceiptForCallsCount > 0 + } + var sendReadReceiptForReceivedEventID: String? + var sendReadReceiptForReceivedInvocations: [String] = [] + var sendReadReceiptForReturnValue: Result! + var sendReadReceiptForClosure: ((String) async -> Result)? + + func sendReadReceipt(for eventID: String) async -> Result { + sendReadReceiptForCallsCount += 1 + sendReadReceiptForReceivedEventID = eventID + sendReadReceiptForReceivedInvocations.append(eventID) + if let sendReadReceiptForClosure = sendReadReceiptForClosure { + return await sendReadReceiptForClosure(eventID) + } else { + return sendReadReceiptForReturnValue + } + } + //MARK: - sendMessage + + var sendMessageInReplyToCallsCount = 0 + var sendMessageInReplyToCalled: Bool { + return sendMessageInReplyToCallsCount > 0 + } + var sendMessageInReplyToReceivedArguments: (message: String, eventID: String?)? + var sendMessageInReplyToReceivedInvocations: [(message: String, eventID: String?)] = [] + var sendMessageInReplyToReturnValue: Result! + var sendMessageInReplyToClosure: ((String, String?) async -> Result)? + + func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result { + sendMessageInReplyToCallsCount += 1 + sendMessageInReplyToReceivedArguments = (message: message, eventID: eventID) + sendMessageInReplyToReceivedInvocations.append((message: message, eventID: eventID)) + if let sendMessageInReplyToClosure = sendMessageInReplyToClosure { + return await sendMessageInReplyToClosure(message, eventID) + } else { + return sendMessageInReplyToReturnValue + } + } + //MARK: - sendReaction + + var sendReactionToCallsCount = 0 + var sendReactionToCalled: Bool { + return sendReactionToCallsCount > 0 + } + var sendReactionToReceivedArguments: (reaction: String, eventID: String)? + var sendReactionToReceivedInvocations: [(reaction: String, eventID: String)] = [] + var sendReactionToReturnValue: Result! + var sendReactionToClosure: ((String, String) async -> Result)? + + func sendReaction(_ reaction: String, to eventID: String) async -> Result { + sendReactionToCallsCount += 1 + sendReactionToReceivedArguments = (reaction: reaction, eventID: eventID) + sendReactionToReceivedInvocations.append((reaction: reaction, eventID: eventID)) + if let sendReactionToClosure = sendReactionToClosure { + return await sendReactionToClosure(reaction, eventID) + } else { + return sendReactionToReturnValue + } + } + //MARK: - editMessage + + var editMessageOriginalCallsCount = 0 + var editMessageOriginalCalled: Bool { + return editMessageOriginalCallsCount > 0 + } + var editMessageOriginalReceivedArguments: (newMessage: String, eventID: String)? + var editMessageOriginalReceivedInvocations: [(newMessage: String, eventID: String)] = [] + var editMessageOriginalReturnValue: Result! + var editMessageOriginalClosure: ((String, String) async -> Result)? + + func editMessage(_ newMessage: String, original eventID: String) async -> Result { + editMessageOriginalCallsCount += 1 + editMessageOriginalReceivedArguments = (newMessage: newMessage, eventID: eventID) + editMessageOriginalReceivedInvocations.append((newMessage: newMessage, eventID: eventID)) + if let editMessageOriginalClosure = editMessageOriginalClosure { + return await editMessageOriginalClosure(newMessage, eventID) + } else { + return editMessageOriginalReturnValue + } + } + //MARK: - redact + + var redactCallsCount = 0 + var redactCalled: Bool { + return redactCallsCount > 0 + } + var redactReceivedEventID: String? + var redactReceivedInvocations: [String] = [] + var redactReturnValue: Result! + var redactClosure: ((String) async -> Result)? + + func redact(_ eventID: String) async -> Result { + redactCallsCount += 1 + redactReceivedEventID = eventID + redactReceivedInvocations.append(eventID) + if let redactClosure = redactClosure { + return await redactClosure(eventID) + } else { + return redactReturnValue + } + } + //MARK: - reportContent + + var reportContentReasonCallsCount = 0 + var reportContentReasonCalled: Bool { + return reportContentReasonCallsCount > 0 + } + var reportContentReasonReceivedArguments: (eventID: String, reason: String?)? + var reportContentReasonReceivedInvocations: [(eventID: String, reason: String?)] = [] + var reportContentReasonReturnValue: Result! + var reportContentReasonClosure: ((String, String?) async -> Result)? + + func reportContent(_ eventID: String, reason: String?) async -> Result { + reportContentReasonCallsCount += 1 + reportContentReasonReceivedArguments = (eventID: eventID, reason: reason) + reportContentReasonReceivedInvocations.append((eventID: eventID, reason: reason)) + if let reportContentReasonClosure = reportContentReasonClosure { + return await reportContentReasonClosure(eventID, reason) + } else { + return reportContentReasonReturnValue + } + } + //MARK: - members + + var membersCallsCount = 0 + var membersCalled: Bool { + return membersCallsCount > 0 + } + var membersReturnValue: Result<[RoomMemberProxy], RoomProxyError>! + var membersClosure: (() async -> Result<[RoomMemberProxy], RoomProxyError>)? + + func members() async -> Result<[RoomMemberProxy], RoomProxyError> { + membersCallsCount += 1 + if let membersClosure = membersClosure { + return await membersClosure() + } else { + return membersReturnValue + } + } + //MARK: - retryDecryption + + var retryDecryptionForCallsCount = 0 + var retryDecryptionForCalled: Bool { + return retryDecryptionForCallsCount > 0 + } + var retryDecryptionForReceivedSessionID: String? + var retryDecryptionForReceivedInvocations: [String] = [] + var retryDecryptionForClosure: ((String) async -> Void)? + + func retryDecryption(for sessionID: String) async { + retryDecryptionForCallsCount += 1 + retryDecryptionForReceivedSessionID = sessionID + retryDecryptionForReceivedInvocations.append(sessionID) + await retryDecryptionForClosure?(sessionID) + } + //MARK: - leaveRoom + + var leaveRoomCallsCount = 0 + var leaveRoomCalled: Bool { + return leaveRoomCallsCount > 0 + } + var leaveRoomReturnValue: Result! + var leaveRoomClosure: (() async -> Result)? + + func leaveRoom() async -> Result { + leaveRoomCallsCount += 1 + if let leaveRoomClosure = leaveRoomClosure { + return await leaveRoomClosure() + } else { + return leaveRoomReturnValue + } + } +} class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol { var callbacks: PassthroughSubject { get { return underlyingCallbacks } diff --git a/ElementX/Sources/Mocks/RoomProxyMock.swift b/ElementX/Sources/Mocks/RoomProxyMock.swift new file mode 100644 index 000000000..1fb48df56 --- /dev/null +++ b/ElementX/Sources/Mocks/RoomProxyMock.swift @@ -0,0 +1,61 @@ +// +// 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 + +struct RoomProxyMockConfiguration { + var id = UUID().uuidString + let name: String? = nil + let displayName: String? + var topic: String? + var avatarURL: URL? + var isDirect = Bool.random() + var isSpace = Bool.random() + var isPublic = Bool.random() + var isEncrypted = Bool.random() + var isTombstoned = Bool.random() + var canonicalAlias: String? + var alternativeAliases: [String] = [] + var hasUnreadNotifications = Bool.random() + var members: [RoomMemberProxy]? +} + +extension RoomProxyMock { + convenience init(with configuration: RoomProxyMockConfiguration) { + self.init() + + id = configuration.id + name = configuration.name + displayName = configuration.displayName + topic = configuration.topic + avatarURL = configuration.avatarURL + isDirect = configuration.isDirect + isSpace = configuration.isSpace + isPublic = configuration.isPublic + isEncrypted = configuration.isEncrypted + isTombstoned = configuration.isTombstoned + canonicalAlias = configuration.canonicalAlias + alternativeAliases = configuration.alternativeAliases + hasUnreadNotifications = configuration.hasUnreadNotifications + + membersClosure = { + if let members = configuration.members { + return .success(members) + } + return .failure(.failedRetrievingMembers) + } + } +} diff --git a/ElementX/Sources/Other/Extensions/Alert.swift b/ElementX/Sources/Other/Extensions/Alert.swift index cc5a96537..2b19cbe2b 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: (I) -> V, message: (I) -> V) -> some View where I: AlertItem, V: View { + func alert(item: Binding, actions: (Item) -> Actions, 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: (I) -> V) -> some View where I: AlertItem, V: View { + func alert(item: Binding, actions: (Item) -> Actions) -> some View where Item: AlertItem, Actions: View { let binding = Binding(get: { item.wrappedValue != nil }, set: { newValue in diff --git a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift index 710c9a50c..2348aa691 100644 --- a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift +++ b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift @@ -41,6 +41,7 @@ struct FormButtonStyle: PrimitiveButtonStyle { func makeBody(configuration: Configuration) -> some View { Button(action: configuration.trigger) { configuration.label + .labelStyle(FormRowLabelStyle(role: configuration.role)) .frame(maxHeight: .infinity) // Make sure the label fills the cell vertically. } .buttonStyle(Style(accessory: accessory)) @@ -54,7 +55,7 @@ struct FormButtonStyle: PrimitiveButtonStyle { func makeBody(configuration: Configuration) -> some View { HStack { configuration.label - .labelStyle(FormRowLabelStyle()) + .labelStyle(FormRowLabelStyle(role: configuration.role)) .foregroundColor(.element.primaryContent) .frame(maxWidth: .infinity, alignment: .leading) @@ -104,6 +105,11 @@ struct FormButtonStyles_Previews: PreviewProvider { Label("Show something", systemImage: "rectangle.portrait") } .buttonStyle(FormButtonStyle(accessory: .navigationLink)) + + Button(role: .destructive) { } label: { + Label("Show destruction", systemImage: "rectangle.portrait") + } + .buttonStyle(FormButtonStyle(accessory: .navigationLink)) ShareLink(item: "test") .buttonStyle(FormButtonStyle()) @@ -117,6 +123,14 @@ struct FormButtonStyles_Previews: PreviewProvider { .buttonStyle(FormButtonStyle()) } .formSectionStyle() + + Section { + Button(role: .destructive) { } label: { + Label("Destroy", systemImage: "x.circle") + } + .buttonStyle(FormButtonStyle()) + } + .formSectionStyle() } } } diff --git a/ElementX/Sources/Other/SwiftUI/Form Styles/FormRowLabelStyle.swift b/ElementX/Sources/Other/SwiftUI/Form Styles/FormRowLabelStyle.swift index f5d163780..06ced19e2 100644 --- a/ElementX/Sources/Other/SwiftUI/Form Styles/FormRowLabelStyle.swift +++ b/ElementX/Sources/Other/SwiftUI/Form Styles/FormRowLabelStyle.swift @@ -19,19 +19,44 @@ import SwiftUI struct FormRowLabelStyle: LabelStyle { @ScaledMetric private var menuIconSize = 30.0 - var alignment: VerticalAlignment = .firstTextBaseline + var alignment = VerticalAlignment.firstTextBaseline + var role: ButtonRole? + + private var titleColor: Color { + if role == .destructive { + return .element.alert + } else { + return .element.primaryContent + } + } + + private var iconBackgroundColor: Color { + if role == .destructive { + return .element.alert.opacity(0.1) + } else { + return .element.formBackground + } + } + + private var iconForegroundColor: Color { + if role == .destructive { + return .element.alert + } else { + return .element.secondaryContent + } + } func makeBody(configuration: Configuration) -> some View { HStack(alignment: alignment, spacing: 16) { configuration.icon - .foregroundColor(.element.secondaryContent) + .foregroundColor(iconForegroundColor) .padding(4) .frame(width: menuIconSize, height: menuIconSize) - .background(Color.element.formBackground) + .background(iconBackgroundColor) .clipShape(RoundedRectangle(cornerRadius: 8)) configuration.title .font(.element.body) - .foregroundColor(.element.primaryContent) + .foregroundColor(titleColor) } } } @@ -50,6 +75,9 @@ struct FormRowLabelStyle_Previews: PreviewProvider { Label("Help", systemImage: "questionmark") .labelStyle(FormRowLabelStyle()) + + Label("Destroy", systemImage: "x.circle") + .labelStyle(FormRowLabelStyle(role: .destructive)) } .padding() } diff --git a/ElementX/Sources/Other/UserIndicator/UserIndicatorToastView.swift b/ElementX/Sources/Other/UserIndicator/UserIndicatorToastView.swift index 64a8ce8aa..05600e4b6 100644 --- a/ElementX/Sources/Other/UserIndicator/UserIndicatorToastView.swift +++ b/ElementX/Sources/Other/UserIndicator/UserIndicatorToastView.swift @@ -32,7 +32,7 @@ struct UserIndicatorToastView: View { .padding(.horizontal, 12.0) .padding(.vertical, 10.0) .frame(minWidth: 150.0) - .background(Color.element.quaternaryContent) + .background(Color.element.system) .clipShape(RoundedCornerShape(radius: 24.0, corners: .allCorners)) .shadow(color: .black.opacity(0.1), radius: 10.0, y: 4.0) .transition(toastTransition) diff --git a/ElementX/Sources/Screens/ReportContent/View/ReportContentScreen.swift b/ElementX/Sources/Screens/ReportContent/View/ReportContentScreen.swift index f7629c12a..12a873ba2 100644 --- a/ElementX/Sources/Screens/ReportContent/View/ReportContentScreen.swift +++ b/ElementX/Sources/Screens/ReportContent/View/ReportContentScreen.swift @@ -76,7 +76,7 @@ struct ReportContentScreen: View { // MARK: - Previews struct ReportContent_Previews: PreviewProvider { - static let viewModel = ReportContentViewModel(itemID: "", roomProxy: MockRoomProxy(displayName: nil)) + static let viewModel = ReportContentViewModel(itemID: "", roomProxy: RoomProxyMock(with: .init(displayName: nil))) static var previews: some View { ReportContentScreen(context: viewModel.context) diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift index 6561980c5..69e10e78f 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsCoordinator.swift @@ -24,6 +24,7 @@ struct RoomDetailsCoordinatorParameters { enum RoomDetailsCoordinatorAction { case cancel + case leftRoom } final class RoomDetailsCoordinator: CoordinatorProtocol { @@ -51,6 +52,8 @@ final class RoomDetailsCoordinator: CoordinatorProtocol { self.presentRoomMemberDetails(members) case .cancel: self.callback?(.cancel) + case .leftRoom: + self.callback?(.leftRoom) } } } diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift index 13565c4a0..79b7ddb96 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsModels.swift @@ -23,6 +23,7 @@ import UIKit enum RoomDetailsViewModelAction { case requestMemberDetailsPresentation([RoomMemberProxy]) + case leftRoom case cancel } @@ -49,15 +50,34 @@ struct RoomDetailsViewState: BindableState { struct RoomDetailsViewStateBindings { /// Information describing the currently displayed alert. var alertInfo: AlertInfo? + var leaveRoomAlertItem: LeaveRoomAlertItem? } -enum RoomDetailsErrorType: Hashable { - /// A specific error message shown in an alert. - case alert(String) +struct LeaveRoomAlertItem: AlertItem { + enum RoomState { + case empty + case `public` + case `private` + } + + let state: RoomState + let title = ElementL10n.roomProfileSectionMoreLeave + let confirmationTitle = ElementL10n.actionLeave + let cancelTitle = ElementL10n.actionCancel + + var subtitle: String { + switch state { + case .empty: return ElementL10n.roomDetailsLeaveEmptyRoomAlertSubtitle + case .private: return ElementL10n.roomDetailsLeavePrivateRoomAlertSubtitle + case .public: return ElementL10n.roomDetailsLeaveRoomAlertSubtitle + } + } } enum RoomDetailsViewAction { case processTapPeople + case processTapLeave + case confirmLeave case copyRoomLink } @@ -72,3 +92,10 @@ struct RoomDetailsMember: Identifiable, Equatable { avatarURL = proxy.avatarURL } } + +enum RoomDetailsErrorType: Hashable { + /// A specific error message shown in an alert. + case alert(String) + /// Leaving room has failed.. + case unknown +} diff --git a/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift b/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift index 0f23a7e2d..836c040ea 100644 --- a/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift +++ b/ElementX/Sources/Screens/RoomDetails/RoomDetailsViewModel.swift @@ -19,6 +19,7 @@ import SwiftUI typealias RoomDetailsViewModelType = StateStoreViewModel class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtocol { + private let roomProxy: RoomProxyProtocol private var members: [RoomMemberProxy] = [] { didSet { state.members = members.map { RoomDetailsMember(withProxy: $0) } @@ -29,6 +30,7 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol) { + self.roomProxy = roomProxy super.init(initialViewState: .init(roomId: roomProxy.id, canonicalAlias: roomProxy.canonicalAlias, isEncrypted: roomProxy.isEncrypted, @@ -60,10 +62,20 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc callback?(.requestMemberDetailsPresentation(members)) case .copyRoomLink: copyRoomLink() + case .processTapLeave: + guard members.count > 1 else { + state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: .empty) + return + } + state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: roomProxy.isPublic ? .public : .private) + case .confirmLeave: + await leaveRoom() } } // MARK: - Private + + private static let leaveRoomLoadingID = "LeaveRoomLoading" private func copyRoomLink() { if let roomLink = state.permalink { @@ -73,4 +85,16 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(title: ElementL10n.unknownError)) } } + + private func leaveRoom() async { + ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: ElementL10n.loading, persistent: true)) + let result = await roomProxy.leaveRoom() + ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID) + switch result { + case .failure: + state.bindings.alertInfo = AlertInfo(id: .unknown) + case .success: + callback?(.leftRoom) + } + } } diff --git a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift index d75249bc8..61a8c3bf4 100644 --- a/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift +++ b/ElementX/Sources/Screens/RoomDetails/View/RoomDetailsScreen.swift @@ -30,10 +30,15 @@ struct RoomDetailsScreen: View { if context.viewState.isEncrypted { securitySection } + + leaveRoomSection } .scrollContentBackground(.hidden) .background(Color.element.formBackground.ignoresSafeArea()) .alert(item: $context.alertInfo) { $0.alert } + .alert(item: $context.leaveRoomAlertItem, + actions: leaveRoomAlertActions, + message: leaveRoomAlertMessage) } // MARK: - Private @@ -147,6 +152,30 @@ struct RoomDetailsScreen: View { } .formSectionStyle() } + + private var leaveRoomSection: some View { + Section { + Button(role: .destructive) { + context.send(viewAction: .processTapLeave) + } label: { + Label(ElementL10n.roomProfileSectionMoreLeave, systemImage: "door.right.hand.open") + } + .buttonStyle(FormButtonStyle(accessory: nil)) + } + .formSectionStyle() + } + + @ViewBuilder + private func leaveRoomAlertActions(_ item: LeaveRoomAlertItem) -> some View { + Button(item.cancelTitle, role: .cancel) { } + Button(item.confirmationTitle, role: .destructive) { + context.send(viewAction: .confirmLeave) + } + } + + private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View { + Text(item.subtitle) + } } // MARK: - Previews @@ -158,12 +187,12 @@ struct RoomDetails_Previews: PreviewProvider { .mockBob, .mockCharlie ] - let roomProxy = MockRoomProxy(displayName: "Room A", - topic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", - isDirect: false, - isEncrypted: true, - canonicalAlias: "#alias:domain.com", - members: members) + let roomProxy = RoomProxyMock(with: .init(displayName: "Room A", + topic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + isDirect: false, + isEncrypted: true, + canonicalAlias: "#alias:domain.com", + members: members)) return RoomDetailsViewModel(roomProxy: roomProxy, mediaProvider: MockMediaProvider()) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 4f89f65b1..9ad0f8a10 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -24,6 +24,10 @@ struct RoomScreenCoordinatorParameters { let emojiProvider: EmojiProviderProtocol } +enum RoomScreenCoordinatorAction { + case leftRoom +} + final class RoomScreenCoordinator: CoordinatorProtocol { private var parameters: RoomScreenCoordinatorParameters @@ -31,6 +35,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { private var navigationStackCoordinator: NavigationStackCoordinator { parameters.navigationStackCoordinator } + + var callback: ((RoomScreenCoordinatorAction) -> Void)? init(parameters: RoomScreenCoordinatorParameters) { self.parameters = parameters @@ -111,8 +117,13 @@ final class RoomScreenCoordinator: CoordinatorProtocol { roomProxy: parameters.roomProxy, mediaProvider: parameters.mediaProvider) let coordinator = RoomDetailsCoordinator(parameters: params) - coordinator.callback = { [weak self] _ in - self?.navigationStackCoordinator.pop() + coordinator.callback = { [weak self] action in + switch action { + case .cancel: + self?.navigationStackCoordinator.pop() + case .leftRoom: + self?.callback?(.leftRoom) + } } navigationStackCoordinator.push(coordinator) diff --git a/ElementX/Sources/Services/Client/MockClientProxy.swift b/ElementX/Sources/Services/Client/MockClientProxy.swift index 5e7fd00c3..fbd5ec870 100644 --- a/ElementX/Sources/Services/Client/MockClientProxy.swift +++ b/ElementX/Sources/Services/Client/MockClientProxy.swift @@ -50,9 +50,9 @@ class MockClientProxy: ClientProxyProtocol { switch room { case .empty: - return MockRoomProxy(displayName: "Empty room") + return await RoomProxyMock(with: .init(displayName: "Empty room")) case .filled(let details), .invalidated(let details): - return MockRoomProxy(displayName: details.name) + return await RoomProxyMock(with: .init(displayName: details.name)) } } diff --git a/ElementX/Sources/Services/Room/MockRoomProxy.swift b/ElementX/Sources/Services/Room/MockRoomProxy.swift deleted file mode 100644 index 530de98ae..000000000 --- a/ElementX/Sources/Services/Room/MockRoomProxy.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// 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 -import MatrixRustSDK - -struct MockRoomProxy: RoomProxyProtocol { - var id = UUID().uuidString - let name: String? = nil - let displayName: String? - var topic: String? - var avatarURL: URL? - var isDirect = Bool.random() - var isSpace = Bool.random() - var isPublic = Bool.random() - var isEncrypted = Bool.random() - var isTombstoned = Bool.random() - var canonicalAlias: String? - var alternativeAliases: [String] = [] - var hasUnreadNotifications = Bool.random() - var members: [RoomMemberProxy]? - - let timelineProvider: RoomTimelineProviderProtocol = MockRoomTimelineProvider() - - func loadDisplayNameForUserId(_ userId: String) async -> Result { - .failure(.failedRetrievingMemberDisplayName) - } - - func loadAvatarURLForUserId(_ userId: String) async -> Result { - .failure(.failedRetrievingMemberAvatarURL) - } - - func addTimelineListener(listener: TimelineListener) -> Result<[TimelineItem], RoomProxyError> { - .failure(.failedAddingTimelineListener) - } - - func removeTimelineListener() { } - - func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result { - .failure(.failedPaginatingBackwards) - } - - func sendReadReceipt(for eventID: String) async -> Result { - .failure(.failedSendingReadReceipt) - } - - func sendMessage(_ message: String, inReplyTo eventID: String? = nil) async -> Result { - .failure(.failedSendingMessage) - } - - func sendReaction(_ reaction: String, to eventID: String) async -> Result { - .failure(.failedSendingMessage) - } - - func editMessage(_ newMessage: String, original eventID: String) async -> Result { - .failure(.failedSendingMessage) - } - - func redact(_ eventID: String) async -> Result { - .failure(.failedRedactingEvent) - } - - func reportContent(_ eventID: String, reason: String?) async -> Result { - .failure(.failedReportingContent) - } - - func members() async -> Result<[RoomMemberProxy], RoomProxyError> { - if let members { - return .success(members) - } - return .failure(.failedRetrievingMembers) - } - - func retryDecryption(for sessionID: String) async { } -} diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 3ab7bd395..d3bd275a7 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -287,6 +287,23 @@ class RoomProxy: RoomProxyProtocol { self?.room.retryDecryption(sessionIds: [sessionID]) } } + + func leaveRoom() async -> Result { + sendMessageBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundTaskName, isReusable: true) + defer { + sendMessageBackgroundTask?.stop() + } + + return await Task.dispatch(on: .global()) { + do { + try self.room.leave() + return .success(()) + } catch { + MXLog.error("Failed to leave the room: \(error)") + return .failure(.failedLeavingRoom) + } + } + } // MARK: - Private diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 112c15c47..29e311bbb 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -31,9 +31,11 @@ enum RoomProxyError: Error { case failedReportingContent case failedAddingTimelineListener case failedRetrievingMembers + case failedLeavingRoom } @MainActor +// sourcery: AutoMockable protocol RoomProxyProtocol { var id: String { get } var isDirect: Bool { get } @@ -51,9 +53,7 @@ protocol RoomProxyProtocol { var topic: String? { get } var avatarURL: URL? { get } - - var permalink: URL? { get } - + func loadAvatarURLForUserId(_ userId: String) async -> Result func loadDisplayNameForUserId(_ userId: String) async -> Result @@ -79,6 +79,8 @@ protocol RoomProxyProtocol { func members() async -> Result<[RoomMemberProxy], RoomProxyError> func retryDecryption(for sessionID: String) async + + func leaveRoom() async -> Result } extension RoomProxyProtocol { diff --git a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift index 5c8803ff7..3a3d57ea3 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionFlowCoordinator.swift @@ -173,6 +173,12 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { mediaProvider: userSession.mediaProvider, emojiProvider: emojiProvider) let coordinator = RoomScreenCoordinator(parameters: parameters) + coordinator.callback = { [weak self] action in + switch action { + case .leftRoom: + self?.dismissRoom() + } + } detailNavigationStackCoordinator.setRootCoordinator(coordinator) { [weak self, roomIdentifier] in guard let self else { return } @@ -191,6 +197,11 @@ class UserSessionFlowCoordinator: CoordinatorProtocol { } } } + + private func dismissRoom() { + detailNavigationStackCoordinator.popToRoot(animated: true) + navigationSplitCoordinator.setDetailCoordinator(nil) + } // MARK: Settings diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index b0c9e67b2..33445bc03 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -143,7 +143,7 @@ class MockScreen: Identifiable { case .roomPlainNoAvatar: let navigationStackCoordinator = NavigationStackCoordinator() let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Some room name", avatarURL: nil), + roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: nil)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -153,7 +153,7 @@ class MockScreen: Identifiable { case .roomEncryptedWithAvatar: let navigationStackCoordinator = NavigationStackCoordinator() let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Some room name", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "Some room name", avatarURL: URL.picturesDirectory)), timelineController: MockRoomTimelineController(), mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -165,7 +165,7 @@ class MockScreen: Identifiable { let timelineController = MockRoomTimelineController() timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "New room", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "New room", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -180,7 +180,7 @@ class MockScreen: Identifiable { timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk] timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Small timeline", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -195,7 +195,7 @@ class MockScreen: Identifiable { timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "Small timeline, paginating", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -210,7 +210,7 @@ class MockScreen: Identifiable { timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Large timeline", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -226,7 +226,7 @@ class MockScreen: Identifiable { timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk] timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Large timeline", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -241,7 +241,7 @@ class MockScreen: Identifiable { timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage] let parameters = RoomScreenCoordinatorParameters(navigationStackCoordinator: navigationStackCoordinator, - roomProxy: MockRoomProxy(displayName: "Large timeline", avatarURL: URL.picturesDirectory), + roomProxy: RoomProxyMock(with: .init(displayName: "Large timeline", avatarURL: URL.picturesDirectory)), timelineController: timelineController, mediaProvider: MockMediaProvider(), emojiProvider: EmojiProvider()) @@ -270,10 +270,10 @@ class MockScreen: Identifiable { return navigationSplitCoordinator case .roomDetailsScreen: let navigationStackCoordinator = NavigationStackCoordinator() - let roomProxy = MockRoomProxy(id: "MockRoomIdentifier", - displayName: "Room", - isEncrypted: true, - members: [.mockAlice, .mockBob, .mockCharlie]) + let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier", + displayName: "Room", + isEncrypted: true, + members: [.mockAlice, .mockBob, .mockCharlie])) let coordinator = RoomDetailsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, roomProxy: roomProxy, mediaProvider: MockMediaProvider())) @@ -281,13 +281,13 @@ class MockScreen: Identifiable { return navigationStackCoordinator case .roomDetailsScreenWithRoomAvatar: let navigationStackCoordinator = NavigationStackCoordinator() - let roomProxy = MockRoomProxy(id: "MockRoomIdentifier", - displayName: "Room", - topic: "Bacon ipsum dolor amet commodo incididunt ribeye dolore cupidatat short ribs.", - avatarURL: URL.picturesDirectory, - isEncrypted: true, - canonicalAlias: "#mock:room.org", - members: [.mockAlice, .mockBob, .mockCharlie]) + let roomProxy = RoomProxyMock(with: .init(id: "MockRoomIdentifier", + displayName: "Room", + topic: "Bacon ipsum dolor amet commodo incididunt ribeye dolore cupidatat short ribs.", + avatarURL: URL.picturesDirectory, + isEncrypted: true, + canonicalAlias: "#mock:room.org", + members: [.mockAlice, .mockBob, .mockCharlie])) let coordinator = RoomDetailsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator, roomProxy: roomProxy, mediaProvider: MockMediaProvider())) @@ -301,7 +301,7 @@ class MockScreen: Identifiable { return navigationStackCoordinator case .reportContent: let navigationStackCoordinator = NavigationStackCoordinator() - let coordinator = ReportContentCoordinator(parameters: .init(itemID: "test", roomProxy: MockRoomProxy(displayName: "test"))) + let coordinator = ReportContentCoordinator(parameters: .init(itemID: "test", roomProxy: RoomProxyMock(with: .init(displayName: "test")))) navigationStackCoordinator.setRootCoordinator(coordinator) return navigationStackCoordinator case .startChat: diff --git a/Tools/Sourcery/sourcery_automockable_config.yml b/Tools/Sourcery/sourcery_automockable_config.yml index 5f4cb6f6e..5bfc137c1 100644 --- a/Tools/Sourcery/sourcery_automockable_config.yml +++ b/Tools/Sourcery/sourcery_automockable_config.yml @@ -6,4 +6,4 @@ output: ../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift args: automMockableTestableImports: [] - autoMockableImports: [Combine, MatrixRustSDK] \ No newline at end of file + autoMockableImports: [Combine, Foundation, MatrixRustSDK] \ No newline at end of file diff --git a/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreen.png b/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreen.png index ef6bc329b..80345b598 100644 --- a/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreen.png +++ b/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf58b16186bdaf165064a675a75a33a66a3ddd626c715e29bfd534844e209c1d -size 90332 +oid sha256:480187021f8337ffbc59d03b149d9aa27141b695d116daa311de27bca9b517c9 +size 95009 diff --git a/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png b/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png index 8b2cf5805..5f132715d 100644 --- a/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png +++ b/UITests/Sources/__Snapshots__/Application/de-DE-iPad-9th-generation.roomDetailsScreenWithRoomAvatar.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:023baadc85550005385b8fd770a55bc2f082d52514837385855184758dbcdcf5 -size 116212 +oid sha256:e035ab94494f59f9ecf1416f861b8d9bbee6477daad8a5cfdb107032cd14d322 +size 120849 diff --git a/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreen.png b/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreen.png index 78c29274e..a6f2a5aa6 100644 --- a/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreen.png +++ b/UITests/Sources/__Snapshots__/Application/de-DE-iPhone-14.roomDetailsScreen.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbcfc6944b4c32583168276b6d64f68618dde1de251c772156918aa7b6644594 -size 108453 +oid sha256:8eafd53e5f50f849a4a7da0bf436d0a532e725fe9adff3fc3fca788f5dac5edb +size 115884 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 a90a07f6c..3201b2e48 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:e2d8a909c8ea5d9877ac951bd56334481f924b9a8ce1a495f7352164f1815d2a -size 145566 +oid sha256:1d6d23f2c8fb88c9baeab1181bbc1a59dc07fc36da4d9f3192f4e3d943e60a70 +size 150567 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 a944c9d44..f09238ebe 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:5781e2acf57989a2091b6bcc1287a76966ab6b5ecea800b9e096338b31b60704 -size 87190 +oid sha256:7533db27ed76004d975897520ea841421fb54aa77c9966330e41771ef7c18dba +size 91562 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 046b7a048..9ee2ab142 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:8da611272e84a28cc3a02b3ffdda36703a6ad8c9927ad9bd5bc412b95018bb16 -size 112731 +oid sha256:7d267a4f41ee25c78e7beba699d89f67fa03da101a7b2cf30a973731cc38267f +size 116394 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 fb5c0afbd..6ea6703a2 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:7bedda0ec007954c232d31713a9e6eb6adae2b561d2a856dede57e68c62dae68 -size 104960 +oid sha256:d52f746ba47e31fb6aaa72dc7def6cd3ce9bde9a32eb2bbe7ad0da16d2249745 +size 109874 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 5da55698c..9a649eedd 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:59a7327e8b127222fcf1cef19094af0cc10446d5e5d6de09cf4c0765ffa3c83e -size 142239 +oid sha256:f7d2db5f50221fcea17f3f0453b5098cae0dbdf16a1d531d34ad5656917481c4 +size 146955 diff --git a/UnitTests/Sources/NavigationSplitCoordinatorTests.swift b/UnitTests/Sources/NavigationSplitCoordinatorTests.swift index 7fc49d720..cc5b7181e 100644 --- a/UnitTests/Sources/NavigationSplitCoordinatorTests.swift +++ b/UnitTests/Sources/NavigationSplitCoordinatorTests.swift @@ -255,6 +255,32 @@ class NavigationSplitCoordinatorTests: XCTestCase { } waitForExpectations(timeout: 1.0) } + + func testSetRootDetailToNilAfterPoppingToRoot() { + navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: SomeTestCoordinator()) + let sidebarCoordinator = NavigationStackCoordinator() + sidebarCoordinator.setRootCoordinator(SomeTestCoordinator()) + + let detailCoordinator = NavigationStackCoordinator() + detailCoordinator.setRootCoordinator(SomeTestCoordinator()) + detailCoordinator.push(SomeTestCoordinator()) + + navigationSplitCoordinator.setSidebarCoordinator(sidebarCoordinator) + navigationSplitCoordinator.setDetailCoordinator(detailCoordinator) + + let expectation = expectation(description: "Details coordinator should be nil, and the compact layout revert to the sidebar root") + DispatchQueue.main.async { + detailCoordinator.popToRoot(animated: true) + self.navigationSplitCoordinator.setDetailCoordinator(nil) + DispatchQueue.main.async { + XCTAssertNil(self.navigationSplitCoordinator.detailCoordinator) + self.assertCoordinatorsEqual(self.navigationSplitCoordinator.compactLayoutRootCoordinator, sidebarCoordinator.rootCoordinator) + XCTAssertTrue(self.navigationSplitCoordinator.compactLayoutStackModules.isEmpty) + expectation.fulfill() + } + } + waitForExpectations(timeout: 1.0) + } // MARK: - Private diff --git a/UnitTests/Sources/ReportContentViewModelTests.swift b/UnitTests/Sources/ReportContentViewModelTests.swift index 6f079eb1e..1c57e8031 100644 --- a/UnitTests/Sources/ReportContentViewModelTests.swift +++ b/UnitTests/Sources/ReportContentViewModelTests.swift @@ -21,7 +21,7 @@ import XCTest @MainActor class ReportContentScreenViewModelTests: XCTestCase { func testInitialState() { - let viewModel = ReportContentViewModel(itemID: "test-id", roomProxy: MockRoomProxy(displayName: "test")) + let viewModel = ReportContentViewModel(itemID: "test-id", roomProxy: RoomProxyMock(with: .init(displayName: "test"))) let context = viewModel.context XCTAssertEqual(context.reasonText, "") diff --git a/UnitTests/Sources/RoomDetailsViewModelTests.swift b/UnitTests/Sources/RoomDetailsViewModelTests.swift index 46e0b7c8a..634d33c06 100644 --- a/UnitTests/Sources/RoomDetailsViewModelTests.swift +++ b/UnitTests/Sources/RoomDetailsViewModelTests.swift @@ -19,4 +19,65 @@ import XCTest @testable import ElementX @MainActor -class RoomDetailsScreenViewModelTests: XCTestCase { } +class RoomDetailsScreenViewModelTests: XCTestCase { + var viewModel: RoomDetailsViewModelProtocol! + var roomProxyMock: RoomProxyMock! + var context: RoomDetailsViewModelType.Context { viewModel.context } + + override func setUp() { + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test")) + viewModel = RoomDetailsViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + } + + func testLeaveRoomTappedWhenPublic() async { + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isPublic: true, members: [.mockBob, .mockAlice])) + viewModel = RoomDetailsViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + context.send(viewAction: .processTapLeave) + await Task.yield() + XCTAssertEqual(context.leaveRoomAlertItem?.state, .public) + XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, ElementL10n.roomDetailsLeaveRoomAlertSubtitle) + } + + func testLeavRoomTappedWhenRoomNotPublic() async { + roomProxyMock = RoomProxyMock(with: .init(displayName: "Test", isPublic: false, members: [.mockBob, .mockAlice])) + viewModel = RoomDetailsViewModel(roomProxy: roomProxyMock, mediaProvider: MockMediaProvider()) + context.send(viewAction: .processTapLeave) + await Task.yield() + XCTAssertEqual(context.leaveRoomAlertItem?.state, .private) + XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, ElementL10n.roomDetailsLeavePrivateRoomAlertSubtitle) + } + + func testLeaveRoomTappedWithLessThanTwoMembers() async { + context.send(viewAction: .processTapLeave) + await Task.yield() + XCTAssertEqual(context.leaveRoomAlertItem?.state, .empty) + XCTAssertEqual(context.leaveRoomAlertItem?.subtitle, ElementL10n.roomDetailsLeaveEmptyRoomAlertSubtitle) + } + + func testLeaveRoomSuccess() async { + roomProxyMock.leaveRoomClosure = { + .success(()) + } + viewModel.callback = { action in + switch action { + case .leftRoom: + break + default: + XCTFail("leftRoom expected") + } + } + context.send(viewAction: .confirmLeave) + await Task.yield() + XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1) + } + + func testLeaveRoomError() async { + roomProxyMock.leaveRoomClosure = { + .failure(.failedLeavingRoom) + } + context.send(viewAction: .confirmLeave) + await Task.yield() + XCTAssertEqual(roomProxyMock.leaveRoomCallsCount, 1) + XCTAssertNotNil(context.alertInfo) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5f8e2c4f3..89007ec23 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -97,7 +97,8 @@ end lane :unit_tests do run_tests( - scheme: "UnitTests" + scheme: "UnitTests", + result_bundle: true, ) slather(