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
This commit is contained in:
Mauro 2023-03-17 14:57:08 +01:00 committed by GitHub
parent 7544619a55
commit 61d42a24ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 719 additions and 155 deletions

View File

@ -42,6 +42,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
with:

View File

@ -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";

View File

@ -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

View File

@ -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<URL?, RoomProxyError>!
var loadAvatarURLForUserIdClosure: ((String) async -> Result<URL?, RoomProxyError>)?
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError> {
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<String?, RoomProxyError>!
var loadDisplayNameForUserIdClosure: ((String) async -> Result<String?, RoomProxyError>)?
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError> {
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<Void, RoomProxyError>!
var paginateBackwardsRequestSizeUntilNumberOfItemsClosure: ((UInt, UInt) async -> Result<Void, RoomProxyError>)?
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var sendReadReceiptForClosure: ((String) async -> Result<Void, RoomProxyError>)?
func sendReadReceipt(for eventID: String) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var sendMessageInReplyToClosure: ((String, String?) async -> Result<Void, RoomProxyError>)?
func sendMessage(_ message: String, inReplyTo eventID: String?) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var sendReactionToClosure: ((String, String) async -> Result<Void, RoomProxyError>)?
func sendReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var editMessageOriginalClosure: ((String, String) async -> Result<Void, RoomProxyError>)?
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var redactClosure: ((String) async -> Result<Void, RoomProxyError>)?
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var reportContentReasonClosure: ((String, String?) async -> Result<Void, RoomProxyError>)?
func reportContent(_ eventID: String, reason: String?) async -> Result<Void, RoomProxyError> {
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<Void, RoomProxyError>!
var leaveRoomClosure: (() async -> Result<Void, RoomProxyError>)?
func leaveRoom() async -> Result<Void, RoomProxyError> {
leaveRoomCallsCount += 1
if let leaveRoomClosure = leaveRoomClosure {
return await leaveRoomClosure()
} else {
return leaveRoomReturnValue
}
}
}
class SessionVerificationControllerProxyMock: SessionVerificationControllerProxyProtocol {
var callbacks: PassthroughSubject<SessionVerificationControllerProxyCallback, Never> {
get { return underlyingCallbacks }

View File

@ -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)
}
}
}

View File

@ -21,7 +21,7 @@ protocol AlertItem {
}
extension View {
func alert<I, V>(item: Binding<I?>, actions: (I) -> V, message: (I) -> V) -> some View where I: AlertItem, V: View {
func alert<Item, Actions, Message>(item: Binding<Item?>, actions: (Item) -> Actions, message: (Item) -> Message) -> some View where Item: AlertItem, Actions: View, Message: View {
let binding = Binding<Bool>(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<I, V>(item: Binding<I?>, actions: (I) -> V) -> some View where I: AlertItem, V: View {
func alert<Item, Actions>(item: Binding<Item?>, actions: (Item) -> Actions) -> some View where Item: AlertItem, Actions: View {
let binding = Binding<Bool>(get: {
item.wrappedValue != nil
}, set: { newValue in

View File

@ -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)
@ -105,6 +106,11 @@ struct FormButtonStyles_Previews: PreviewProvider {
}
.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()
}
}
}

View File

@ -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()
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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<RoomDetailsErrorType>?
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
}

View File

@ -19,6 +19,7 @@ import SwiftUI
typealias RoomDetailsViewModelType = StateStoreViewModel<RoomDetailsViewState, RoomDetailsViewAction>
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,11 +62,21 @@ 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 {
UIPasteboard.general.url = roomLink
@ -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)
}
}
}

View File

@ -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())

View File

@ -24,6 +24,10 @@ struct RoomScreenCoordinatorParameters {
let emojiProvider: EmojiProviderProtocol
}
enum RoomScreenCoordinatorAction {
case leftRoom
}
final class RoomScreenCoordinator: CoordinatorProtocol {
private var parameters: RoomScreenCoordinatorParameters
@ -32,6 +36,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
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)

View File

@ -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))
}
}

View File

@ -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<String?, RoomProxyError> {
.failure(.failedRetrievingMemberDisplayName)
}
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError> {
.failure(.failedRetrievingMemberAvatarURL)
}
func addTimelineListener(listener: TimelineListener) -> Result<[TimelineItem], RoomProxyError> {
.failure(.failedAddingTimelineListener)
}
func removeTimelineListener() { }
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomProxyError> {
.failure(.failedPaginatingBackwards)
}
func sendReadReceipt(for eventID: String) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingReadReceipt)
}
func sendMessage(_ message: String, inReplyTo eventID: String? = nil) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMessage)
}
func sendReaction(_ reaction: String, to eventID: String) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMessage)
}
func editMessage(_ newMessage: String, original eventID: String) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMessage)
}
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
.failure(.failedRedactingEvent)
}
func reportContent(_ eventID: String, reason: String?) async -> Result<Void, RoomProxyError> {
.failure(.failedReportingContent)
}
func members() async -> Result<[RoomMemberProxy], RoomProxyError> {
if let members {
return .success(members)
}
return .failure(.failedRetrievingMembers)
}
func retryDecryption(for sessionID: String) async { }
}

View File

@ -288,6 +288,23 @@ class RoomProxy: RoomProxyProtocol {
}
}
func leaveRoom() async -> Result<Void, RoomProxyError> {
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
/// Force the timeline to load member details so it can populate sender profiles whenever we add a timeline listener

View File

@ -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 }
@ -52,8 +54,6 @@ protocol RoomProxyProtocol {
var avatarURL: URL? { get }
var permalink: URL? { get }
func loadAvatarURLForUserId(_ userId: String) async -> Result<URL?, RoomProxyError>
func loadDisplayNameForUserId(_ userId: String) async -> Result<String?, RoomProxyError>
@ -79,6 +79,8 @@ protocol RoomProxyProtocol {
func members() async -> Result<[RoomMemberProxy], RoomProxyError>
func retryDecryption(for sessionID: String) async
func leaveRoom() async -> Result<Void, RoomProxyError>
}
extension RoomProxyProtocol {

View File

@ -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 }
@ -192,6 +198,11 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
}
}
private func dismissRoom() {
detailNavigationStackCoordinator.popToRoot(animated: true)
navigationSplitCoordinator.setDetailCoordinator(nil)
}
// MARK: Settings
private func presentSettingsScreen() {

View File

@ -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:

View File

@ -6,4 +6,4 @@ output:
../../ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
args:
automMockableTestableImports: []
autoMockableImports: [Combine, MatrixRustSDK]
autoMockableImports: [Combine, Foundation, MatrixRustSDK]

View File

@ -256,6 +256,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
private func assertCoordinatorsEqual(_ lhs: CoordinatorProtocol?, _ rhs: CoordinatorProtocol?) {

View File

@ -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, "")

View File

@ -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)
}
}

View File

@ -97,7 +97,8 @@ end
lane :unit_tests do
run_tests(
scheme: "UnitTests"
scheme: "UnitTests",
result_bundle: true,
)
slather(