Contextual menu for rooms (#842)
* Add room contextual menu * Add room context menu view actions * Add navigation to room’s detail * Show leave alert in HomeScreen * Implement leave logic * Refine leave navigation for iPad * Optimize leave from homescreen * Refactor roomSettings -> roomDetails * Refactor room for id mock * Add UTs * Cleanup * Fix alert presentation in RoomDetailsScreenViewModel * Fix animation flag
This commit is contained in:
@@ -32,6 +32,7 @@ struct RoomProxyMockConfiguration {
|
||||
var alternativeAliases: [String] = []
|
||||
var hasUnreadNotifications = Bool.random()
|
||||
var members: [RoomMemberProxyProtocol]?
|
||||
var inviter: RoomMemberProxyMock?
|
||||
}
|
||||
|
||||
extension RoomProxyMock {
|
||||
@@ -57,7 +58,12 @@ extension RoomProxyMock {
|
||||
} else {
|
||||
membersPublisher = Just([]).eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
if let inviter = configuration.inviter {
|
||||
inviterClosure = { inviter }
|
||||
}
|
||||
|
||||
updateMembersClosure = { }
|
||||
acceptInvitationClosure = { .success(()) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ struct HomeScreenCoordinatorParameters {
|
||||
|
||||
enum HomeScreenCoordinatorAction {
|
||||
case presentRoom(roomIdentifier: String)
|
||||
case presentRoomDetails(roomIdentifier: String)
|
||||
case roomLeft(roomIdentifier: String)
|
||||
case presentSettingsScreen
|
||||
case presentFeedbackScreen
|
||||
case presentSessionVerificationScreen
|
||||
@@ -54,6 +56,10 @@ final class HomeScreenCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .presentRoom(let roomIdentifier):
|
||||
self.callback?(.presentRoom(roomIdentifier: roomIdentifier))
|
||||
case .presentRoomDetails(roomIdentifier: let roomIdentifier):
|
||||
self.callback?(.presentRoomDetails(roomIdentifier: roomIdentifier))
|
||||
case .roomLeft(roomIdentifier: let roomIdentifier):
|
||||
self.callback?(.roomLeft(roomIdentifier: roomIdentifier))
|
||||
case .presentFeedbackScreen:
|
||||
self.callback?(.presentFeedbackScreen)
|
||||
case .presentSettingsScreen:
|
||||
|
||||
@@ -20,6 +20,8 @@ import UIKit
|
||||
|
||||
enum HomeScreenViewModelAction {
|
||||
case presentRoom(roomIdentifier: String)
|
||||
case presentRoomDetails(roomIdentifier: String)
|
||||
case roomLeft(roomIdentifier: String)
|
||||
case presentSessionVerificationScreen
|
||||
case presentSettingsScreen
|
||||
case presentFeedbackScreen
|
||||
@@ -36,6 +38,9 @@ enum HomeScreenViewUserMenuAction {
|
||||
|
||||
enum HomeScreenViewAction {
|
||||
case selectRoom(roomIdentifier: String)
|
||||
case showRoomDetails(roomIdentifier: String)
|
||||
case leaveRoom(roomIdentifier: String)
|
||||
case confirmLeaveRoom(roomIdentifier: String)
|
||||
case userMenu(action: HomeScreenViewUserMenuAction)
|
||||
case startChat
|
||||
case verifySession
|
||||
@@ -101,6 +106,7 @@ struct HomeScreenViewStateBindings {
|
||||
var searchQuery = ""
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
var leaveRoomAlertItem: LeaveRoomAlertItem?
|
||||
}
|
||||
|
||||
struct HomeScreenRoom: Identifiable, Equatable {
|
||||
|
||||
@@ -153,10 +153,17 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
override func process(viewAction: HomeScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .selectRoom(let roomIdentifier):
|
||||
callback?(.presentRoom(roomIdentifier: roomIdentifier))
|
||||
case .showRoomDetails(roomIdentifier: let roomIdentifier):
|
||||
callback?(.presentRoomDetails(roomIdentifier: roomIdentifier))
|
||||
case .leaveRoom(roomIdentifier: let roomIdentifier):
|
||||
startLeaveRoomProcess(roomId: roomIdentifier)
|
||||
case .confirmLeaveRoom(roomIdentifier: let roomIdentifier):
|
||||
leaveRoom(roomId: roomIdentifier)
|
||||
case .userMenu(let action):
|
||||
switch action {
|
||||
case .feedback:
|
||||
@@ -301,4 +308,56 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
|
||||
visibleRoomsSummaryProvider.updateVisibleRange(range, timelineLimit: timelineLimit)
|
||||
}
|
||||
|
||||
private static let leaveRoomLoadingID = "LeaveRoomLoading"
|
||||
|
||||
private func startLeaveRoomProcess(roomId: String) {
|
||||
Task {
|
||||
defer {
|
||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
|
||||
}
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
|
||||
let room = await userSession.clientProxy.roomForIdentifier(roomId)
|
||||
|
||||
guard let room else {
|
||||
state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
guard !room.isPublic else {
|
||||
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomId, state: .public)
|
||||
return
|
||||
}
|
||||
|
||||
await room.updateMembers()
|
||||
let joinedMembers = await room.membersPublisher.values.first()?.filter { $0.membership == .join }
|
||||
|
||||
guard let joinedMembers else {
|
||||
state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown)
|
||||
return
|
||||
}
|
||||
|
||||
state.bindings.leaveRoomAlertItem = joinedMembers.count > 1 ? LeaveRoomAlertItem(roomId: roomId, state: .private) : LeaveRoomAlertItem(roomId: roomId, state: .empty)
|
||||
}
|
||||
}
|
||||
|
||||
private func leaveRoom(roomId: String) {
|
||||
Task {
|
||||
defer {
|
||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
|
||||
}
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
|
||||
let room = await userSession.clientProxy.roomForIdentifier(roomId)
|
||||
let result = await room?.leaveRoom()
|
||||
|
||||
switch result {
|
||||
case .none, .some(.failure):
|
||||
state.bindings.alertInfo = AlertInfo(id: UUID(), title: L10n.errorUnknown)
|
||||
case .some(.success):
|
||||
callback?(.roomLeft(roomIdentifier: roomId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,19 @@ struct HomeScreen: View {
|
||||
.redacted(reason: .placeholder)
|
||||
} else {
|
||||
HomeScreenRoomCell(room: room, context: context)
|
||||
.contextMenu {
|
||||
Button {
|
||||
context.send(viewAction: .showRoomDetails(roomIdentifier: room.id))
|
||||
} label: {
|
||||
Label(L10n.commonSettings, systemImage: "gearshape")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
context.send(viewAction: .leaveRoom(roomIdentifier: room.id))
|
||||
} label: {
|
||||
Label(L10n.actionLeaveRoom, systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +110,9 @@ struct HomeScreen: View {
|
||||
.animation(.elementDefault, value: context.viewState.showSessionVerificationBanner)
|
||||
.animation(.elementDefault, value: context.viewState.roomListMode)
|
||||
.alert(item: $context.alertInfo) { $0.alert }
|
||||
.alert(item: $context.leaveRoomAlertItem,
|
||||
actions: leaveRoomAlertActions,
|
||||
message: leaveRoomAlertMessage)
|
||||
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -238,6 +254,18 @@ struct HomeScreen: View {
|
||||
// This will be deduped and throttled on the view model layer
|
||||
context.send(viewAction: .updateVisibleItemRange(range: lowerBound..<upperBound, isScrolling: scrollViewAdapter.isScrolling.value))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func leaveRoomAlertActions(_ item: LeaveRoomAlertItem) -> some View {
|
||||
Button(item.cancelTitle, role: .cancel) { }
|
||||
Button(item.confirmationTitle, role: .destructive) {
|
||||
context.send(viewAction: .confirmLeaveRoom(roomIdentifier: item.roomId))
|
||||
}
|
||||
}
|
||||
|
||||
private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View {
|
||||
Text(item.subtitle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -106,6 +106,7 @@ struct LeaveRoomAlertItem: AlertItem {
|
||||
case `private`
|
||||
}
|
||||
|
||||
let roomId: String
|
||||
let state: RoomState
|
||||
let title = L10n.actionLeaveRoom
|
||||
let confirmationTitle = L10n.actionLeave
|
||||
|
||||
@@ -75,11 +75,12 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
case .processTapPeople:
|
||||
callback?(.requestMemberDetailsPresentation(members))
|
||||
case .processTapLeave:
|
||||
guard members.count > 1 else {
|
||||
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: .empty)
|
||||
let joinedMembers = members.filter { $0.membership == .join }
|
||||
guard joinedMembers.count > 1 else {
|
||||
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomProxy.id, state: .empty)
|
||||
return
|
||||
}
|
||||
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(state: roomProxy.isPublic ? .public : .private)
|
||||
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomProxy.id, state: roomProxy.isPublic ? .public : .private)
|
||||
case .confirmLeave:
|
||||
Task { await leaveRoom() }
|
||||
case .processTapIgnore:
|
||||
|
||||
@@ -53,9 +53,13 @@ class MockClientProxy: ClientProxyProtocol {
|
||||
.failure(.failedCreatingRoom)
|
||||
}
|
||||
|
||||
var roomInviter: RoomMemberProxyMock?
|
||||
var roomForIdentifierMocks: [String: RoomProxyMock] = .init()
|
||||
@MainActor
|
||||
func roomForIdentifier(_ identifier: String) async -> RoomProxyProtocol? {
|
||||
guard roomForIdentifierMocks[identifier] == nil else {
|
||||
return roomForIdentifierMocks[identifier]
|
||||
}
|
||||
|
||||
guard let room = visibleRoomsSummaryProvider?.roomListPublisher.value.first(where: { $0.id == identifier }) else {
|
||||
return nil
|
||||
}
|
||||
@@ -64,10 +68,7 @@ class MockClientProxy: ClientProxyProtocol {
|
||||
case .empty:
|
||||
return RoomProxyMock(with: .init(displayName: "Empty room"))
|
||||
case .filled(let details), .invalidated(let details):
|
||||
let room = RoomProxyMock(with: .init(displayName: details.name))
|
||||
room.acceptInvitationReturnValue = .success(())
|
||||
room.inviterReturnValue = roomInviter
|
||||
return room
|
||||
return RoomProxyMock(with: .init(displayName: details.name))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,9 +123,14 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
|
||||
case (.invitesScreen, .closedInvitesScreen, .roomList):
|
||||
break
|
||||
case (.invitesScreen, .selectRoom(let roomId), .invitesScreen(let selectedRoomId)) where roomId == selectedRoomId:
|
||||
self.presentRoomWithIdentifier(roomId)
|
||||
self.presentRoomWithIdentifier(roomId, animated: animated)
|
||||
case (.invitesScreen, .deselectRoom, .invitesScreen):
|
||||
break
|
||||
|
||||
case (.roomList(let currentRoomId), .selectRoomDetails(let roomId), .roomList) where currentRoomId == roomId:
|
||||
break
|
||||
case (.roomList, .selectRoomDetails(let roomId), .roomList(let selectedRoomId)) where roomId == selectedRoomId:
|
||||
self.presentRoomDetails(roomIdentifier: roomId, animated: animated)
|
||||
|
||||
default:
|
||||
fatalError("Unknown transition: \(context)")
|
||||
@@ -152,6 +157,10 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
|
||||
switch action {
|
||||
case .presentRoom(let roomIdentifier):
|
||||
self.stateMachine.processEvent(.selectRoom(roomId: roomIdentifier))
|
||||
case .presentRoomDetails(let roomIdentifier):
|
||||
self.stateMachine.processEvent(.selectRoomDetails(roomId: roomIdentifier))
|
||||
case .roomLeft(let roomIdentifier):
|
||||
self.deselectRoomIfNeeded(roomIdentifier: roomIdentifier)
|
||||
case .presentSettingsScreen:
|
||||
self.stateMachine.processEvent(.showSettingsScreen)
|
||||
case .presentFeedbackScreen:
|
||||
@@ -226,11 +235,54 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deselectRoomIfNeeded(roomIdentifier: String) {
|
||||
guard
|
||||
case .roomList(selectedRoomId: let selectedRoomId) = stateMachine.state,
|
||||
selectedRoomId == roomIdentifier
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
stateMachine.processEvent(.deselectRoom)
|
||||
navigationSplitCoordinator.setDetailCoordinator(nil)
|
||||
}
|
||||
|
||||
private func dismissRoom() {
|
||||
detailNavigationStackCoordinator.popToRoot(animated: true)
|
||||
navigationSplitCoordinator.setDetailCoordinator(nil)
|
||||
}
|
||||
|
||||
private func presentRoomDetails(roomIdentifier: String, animated: Bool = true) {
|
||||
Task {
|
||||
guard let roomProxy = await userSession.clientProxy.roomForIdentifier(roomIdentifier) else {
|
||||
MXLog.error("Invalid room identifier: \(roomIdentifier)")
|
||||
return
|
||||
}
|
||||
|
||||
let params = RoomDetailsScreenCoordinatorParameters(navigationStackCoordinator: detailNavigationStackCoordinator,
|
||||
roomProxy: roomProxy,
|
||||
mediaProvider: userSession.mediaProvider)
|
||||
|
||||
let coordinator = RoomDetailsScreenCoordinator(parameters: params)
|
||||
|
||||
coordinator.callback = { [weak self] action in
|
||||
switch action {
|
||||
case .cancel, .leftRoom:
|
||||
self?.stateMachine.processEvent(.deselectRoom)
|
||||
self?.detailNavigationStackCoordinator.setRootCoordinator(nil)
|
||||
}
|
||||
}
|
||||
|
||||
detailNavigationStackCoordinator.setRootCoordinator(coordinator, animated: animated) { [weak self, roomIdentifier] in
|
||||
self?.deselectRoomIfNeeded(roomIdentifier: roomIdentifier)
|
||||
}
|
||||
|
||||
if navigationSplitCoordinator.detailCoordinator == nil {
|
||||
navigationSplitCoordinator.setDetailCoordinator(detailNavigationStackCoordinator, animated: animated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Settings
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ class UserSessionFlowCoordinatorStateMachine {
|
||||
case showInvitesScreen
|
||||
/// The invites screen has been dismissed
|
||||
case closedInvitesScreen
|
||||
|
||||
/// Request presentation of the settings of a specific room
|
||||
case selectRoomDetails(roomId: String)
|
||||
}
|
||||
|
||||
private let stateMachine: StateMachine<State, Event>
|
||||
@@ -129,6 +132,9 @@ class UserSessionFlowCoordinatorStateMachine {
|
||||
return .invitesScreen(selectedRoomId: roomId)
|
||||
case (.deselectRoom, .invitesScreen):
|
||||
return .invitesScreen(selectedRoomId: nil)
|
||||
|
||||
case (.selectRoomDetails(let roomId), .roomList):
|
||||
return .roomList(selectedRoomId: roomId)
|
||||
|
||||
default:
|
||||
return nil
|
||||
|
||||
@@ -368,9 +368,9 @@ class MockScreen: Identifiable {
|
||||
ServiceLocator.shared.settings.seenInvites = Set([RoomSummary].mockInvites.dropFirst(1).compactMap(\.id))
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com")
|
||||
clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie
|
||||
clientProxy.roomForIdentifierMocks["someAwesomeRoomId1"] = .init(with: .init(displayName: "First room", inviter: .mockCharlie))
|
||||
clientProxy.roomForIdentifierMocks["someAwesomeRoomId2"] = .init(with: .init(displayName: "Second room", inviter: .mockCharlie))
|
||||
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
|
||||
clientProxy.visibleRoomsSummaryProvider = summaryProvider
|
||||
clientProxy.invitesSummaryProvider = summaryProvider
|
||||
|
||||
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
|
||||
@@ -380,9 +380,9 @@ class MockScreen: Identifiable {
|
||||
ServiceLocator.shared.settings.seenInvites = Set([RoomSummary].mockInvites.compactMap(\.id))
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com")
|
||||
clientProxy.roomInviter = RoomMemberProxyMock.mockCharlie
|
||||
clientProxy.roomForIdentifierMocks["someAwesomeRoomId1"] = .init(with: .init(displayName: "First room", inviter: .mockCharlie))
|
||||
clientProxy.roomForIdentifierMocks["someAwesomeRoomId2"] = .init(with: .init(displayName: "Second room", inviter: .mockCharlie))
|
||||
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
|
||||
clientProxy.visibleRoomsSummaryProvider = summaryProvider
|
||||
clientProxy.invitesSummaryProvider = summaryProvider
|
||||
|
||||
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
|
||||
|
||||
@@ -21,13 +21,17 @@ import XCTest
|
||||
@MainActor
|
||||
class HomeScreenViewModelTests: XCTestCase {
|
||||
var viewModel: HomeScreenViewModelProtocol!
|
||||
var context: HomeScreenViewModelType.Context!
|
||||
var clientProxy: MockClientProxy!
|
||||
|
||||
var context: HomeScreenViewModelType.Context! {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"),
|
||||
clientProxy = MockClientProxy(userID: "@mock:client.com")
|
||||
viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: clientProxy,
|
||||
mediaProvider: MockMediaProvider()),
|
||||
attributedStringBuilder: AttributedStringBuilder())
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testSelectRoom() async throws {
|
||||
@@ -65,4 +69,59 @@ class HomeScreenViewModelTests: XCTestCase {
|
||||
await Task.yield()
|
||||
XCTAssert(correctResult)
|
||||
}
|
||||
|
||||
func testLeaveRoomAlert() async throws {
|
||||
let mockRoomId = "1"
|
||||
clientProxy.roomForIdentifierMocks[mockRoomId] = .init(with: .init(id: mockRoomId, displayName: "Some room"))
|
||||
context.send(viewAction: .leaveRoom(roomIdentifier: mockRoomId))
|
||||
await context.nextViewState()
|
||||
XCTAssertEqual(context.leaveRoomAlertItem?.roomId, mockRoomId)
|
||||
}
|
||||
|
||||
func testLeaveRoomError() async throws {
|
||||
let mockRoomId = "1"
|
||||
let room: RoomProxyMock = .init(with: .init(id: mockRoomId, displayName: "Some room"))
|
||||
room.leaveRoomClosure = { .failure(.failedLeavingRoom) }
|
||||
clientProxy.roomForIdentifierMocks[mockRoomId] = room
|
||||
context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId))
|
||||
let newState = await context.nextViewState()
|
||||
XCTAssertNotNil(context.alertInfo)
|
||||
}
|
||||
|
||||
func testLeaveRoomSuccess() async throws {
|
||||
let mockRoomId = "1"
|
||||
var correctResult = false
|
||||
viewModel.callback = { result in
|
||||
switch result {
|
||||
case .roomLeft(let roomIdentifier):
|
||||
correctResult = roomIdentifier == mockRoomId
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
let room: RoomProxyMock = .init(with: .init(id: mockRoomId, displayName: "Some room"))
|
||||
room.leaveRoomClosure = { .success(()) }
|
||||
clientProxy.roomForIdentifierMocks[mockRoomId] = room
|
||||
context.send(viewAction: .confirmLeaveRoom(roomIdentifier: mockRoomId))
|
||||
await Task.yield()
|
||||
XCTAssertNil(context.alertInfo)
|
||||
XCTAssertTrue(correctResult)
|
||||
}
|
||||
|
||||
func testShowRoomDetails() async throws {
|
||||
let mockRoomId = "1"
|
||||
var correctResult = false
|
||||
viewModel.callback = { result in
|
||||
switch result {
|
||||
case .presentRoomDetails(let roomIdentifier):
|
||||
correctResult = roomIdentifier == mockRoomId
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
context.send(viewAction: .showRoomDetails(roomIdentifier: mockRoomId))
|
||||
await Task.yield()
|
||||
XCTAssertNil(context.alertInfo)
|
||||
XCTAssertTrue(correctResult)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
class InvitesViewModelTests: XCTestCase {
|
||||
class InvitesScreenViewModelTests: XCTestCase {
|
||||
var viewModel: InvitesScreenViewModelProtocol!
|
||||
var clientProxy: MockClientProxy!
|
||||
var userSession: MockUserSession!
|
||||
Reference in New Issue
Block a user