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:
Alfonso Grillo
2023-05-04 12:02:18 +02:00
committed by GitHub
parent 8c19e66185
commit 858e155a07
14 changed files with 242 additions and 17 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,6 +106,7 @@ struct LeaveRoomAlertItem: AlertItem {
case `private`
}
let roomId: String
let state: RoomState
let title = L10n.actionLeaveRoom
let confirmationTitle = L10n.actionLeave

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
import XCTest
@MainActor
class InvitesViewModelTests: XCTestCase {
class InvitesScreenViewModelTests: XCTestCase {
var viewModel: InvitesScreenViewModelProtocol!
var clientProxy: MockClientProxy!
var userSession: MockUserSession!