Membership aware room routes (#2709)

* Remove the invites button when room list invites are enabled

* element-hq/element-x-ios/issues/2632 - Handle room permalinks based on their membership state and show the according room joining screens:
- modify the room flow coordinator to only take a roomID and be able to decide its own room flows (especially for unjoined rooms)
- add support for the new room preview API
- have RoomDirectorySearch go through the same paths
This commit is contained in:
Stefan Ceriu
2024-04-19 17:04:18 +03:00
committed by GitHub
parent 9a172df7fd
commit effe3b6a9b
35 changed files with 698 additions and 247 deletions
@@ -19,10 +19,9 @@ import SwiftUI
struct JoinRoomScreenCoordinatorParameters {
let roomID: String
let roomName: String
let avatarURL: URL?
let interaction: JoinRoomScreenInteraction
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum JoinRoomScreenCoordinatorAction {
@@ -45,10 +44,9 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = JoinRoomScreenViewModel(roomID: parameters.roomID,
roomName: parameters.roomName,
avatarURL: parameters.avatarURL,
interaction: parameters.interaction,
clientProxy: parameters.clientProxy)
clientProxy: parameters.clientProxy,
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
@@ -65,6 +63,10 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol {
}
.store(in: &cancellables)
}
func stop() {
viewModel.stop()
}
func toPresentable() -> AnyView {
AnyView(JoinRoomScreen(context: viewModel.context))
@@ -21,34 +21,44 @@ enum JoinRoomScreenViewModelAction {
case cancelled
}
enum JoinRoomScreenInteractionMode {
case unknown
case invited
case join
case knock
}
struct JoinRoomScreenViewState: BindableState {
// Maybe use room summary details or similar here??
let roomID: String
let roomName: String
let avatarURL: URL?
let interaction: JoinRoomScreenInteraction
var roomDetails: RoomPreviewDetails?
var isJoining = false
var mode: JoinRoomScreenInteractionMode {
guard let roomDetails else {
return .unknown
}
if roomDetails.isInvited {
return .invited
}
if roomDetails.isPublic {
return .join
}
// Knocking is not supported yet, treat it as .unknown
// if roomDetails.canKnock {
// return .knock
// }
return .unknown
}
var bindings = JoinRoomScreenViewStateBindings()
var title: String {
switch interaction {
case .knock:
L10n.screenJoinRoomTitleKnock
case .join, .invited:
L10n.screenJoinRoomTitleNoPreview
}
}
var subtitle: String {
switch interaction {
case .knock:
L10n.screenJoinRoomSubtitleKnock
case .join, .invited:
L10n.screenJoinRoomSubtitleNoPreview
}
roomDetails?.name ?? L10n.screenJoinRoomTitleNoPreview
}
}
@@ -57,13 +67,7 @@ struct JoinRoomScreenViewStateBindings {
}
enum JoinRoomScreenAlertType {
case joinFailed
}
enum JoinRoomScreenInteraction {
case knock
case join
case invited
case declineInvite
}
enum JoinRoomScreenViewAction {
@@ -20,20 +20,28 @@ import SwiftUI
typealias JoinRoomScreenViewModelType = StateStoreViewModel<JoinRoomScreenViewState, JoinRoomScreenViewAction>
class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewModelProtocol {
private let roomID: String
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<JoinRoomScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<JoinRoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomID: String, roomName: String, avatarURL: URL?, interaction: JoinRoomScreenInteraction, clientProxy: ClientProxyProtocol) {
init(roomID: String,
clientProxy: ClientProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomID = roomID
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID,
roomName: roomName,
avatarURL: avatarURL,
interaction: interaction))
super.init(initialViewState: JoinRoomScreenViewState(roomID: roomID), imageProvider: mediaProvider)
Task {
await loadRoomDetails()
}
}
// MARK: - Public
@@ -47,21 +55,93 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
case .join:
Task { await joinRoom() }
case .acceptInvite:
break
Task { await joinRoom() }
case .declineInvite:
break
showDeclineInviteConfirmationAlert()
}
}
func stop() {
hideLoadingIndicator()
}
// MARK: - Private
private func joinRoom() async {
state.isJoining = true
switch await clientProxy.joinRoom(state.roomID) {
case .success:
actionsSubject.send(.joined)
private func loadRoomDetails() async {
showLoadingIndicator()
defer {
hideLoadingIndicator()
}
switch await clientProxy.roomPreviewForIdentifier(roomID) {
case .success(let roomDetails):
state.roomDetails = roomDetails
case .failure:
state.bindings.alertInfo = .init(id: .joinFailed)
userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
}
}
private func joinRoom() async {
showLoadingIndicator()
defer {
hideLoadingIndicator()
}
switch await clientProxy.joinRoom(roomID) {
case .success:
actionsSubject.send(.joined)
case .failure(let error):
MXLog.error("Failed joining room with error: \(error)")
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private func showDeclineInviteConfirmationAlert() {
guard let roomDetails = state.roomDetails else {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
return
}
let roomName = roomDetails.name ?? roomID
state.bindings.alertInfo = .init(id: .declineInvite,
title: L10n.screenInvitesDeclineChatTitle,
message: L10n.screenInvitesDeclineChatMessage(roomName),
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite() } }))
}
private func declineInvite() async {
defer {
userIndicatorController.retractIndicatorWithId(roomID)
}
userIndicatorController.submitIndicator(UserIndicator(id: roomID, type: .modal, title: L10n.commonLoading, persistent: true))
guard let roomProxy = await clientProxy.roomForIdentifier(roomID) else {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
return
}
let result = await roomProxy.rejectInvitation()
if case .failure = result {
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
private static let loadingIndicatorIdentifier = "\(JoinRoomScreenViewModel.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true),
title: L10n.commonLoading,
persistent: true),
delay: .seconds(0.25))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -20,4 +20,6 @@ import Combine
protocol JoinRoomScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<JoinRoomScreenViewModelAction, Never> { get }
var context: JoinRoomScreenViewModelType.Context { get }
func stop()
}
@@ -28,16 +28,16 @@ struct JoinRoomScreen: View {
} bottomContent: {
buttons
}
.alert(item: $context.alertInfo)
.background()
.backgroundStyle(.compound.bgCanvasDefault)
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
}
var mainContent: some View {
VStack(spacing: 16) {
LoadableAvatarImage(url: context.viewState.avatarURL,
name: context.viewState.roomName,
LoadableAvatarImage(url: context.viewState.roomDetails?.avatarURL,
name: context.viewState.title,
contentID: context.viewState.roomID,
avatarSize: .room(on: .joinRoom),
imageProvider: context.imageProvider)
@@ -49,10 +49,28 @@ struct JoinRoomScreen: View {
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(context.viewState.subtitle)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
if let alias = context.viewState.roomDetails?.canonicalAlias {
Text(alias)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
if let memberCount = context.viewState.roomDetails?.memberCount {
Label("\(memberCount)", icon: \.userProfile, iconSize: .small, relativeTo: .compound.bodySMSemibold)
.font(.compound.bodySMSemibold)
.foregroundStyle(.compound.textSecondary)
.padding(4)
.background(Capsule().fill(.compound.bgSubtleSecondary))
.labelStyle(.custom(spacing: 4))
}
if let topic = context.viewState.roomDetails?.topic {
Text(topic)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
.lineLimit(3)
}
}
Image(asset: Asset.Images.joinRoomGraphic)
@@ -62,7 +80,9 @@ struct JoinRoomScreen: View {
@ViewBuilder
var buttons: some View {
switch context.viewState.interaction {
switch context.viewState.mode {
case .unknown:
EmptyView()
case .knock:
Button(L10n.screenJoinRoomKnockAction) { context.send(viewAction: .knock) }
.buttonStyle(.compound(.primary))
@@ -79,52 +99,82 @@ struct JoinRoomScreen: View {
@ViewBuilder
var inviteButtons: some View {
Button(L10n.actionDecline) { context.send(viewAction: .acceptInvite) }
Button(L10n.actionDecline) { context.send(viewAction: .declineInvite) }
.buttonStyle(.compound(.secondary))
Button(L10n.actionAccept) { context.send(viewAction: .declineInvite) }
Button(L10n.actionAccept) { context.send(viewAction: .acceptInvite) }
.buttonStyle(.compound(.primary))
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .principal) {
RoomHeaderView(roomID: context.viewState.roomID,
roomName: context.viewState.roomName,
avatarURL: context.viewState.avatarURL,
imageProvider: context.imageProvider)
}
}
}
// MARK: - Previews
struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
static let knockViewModel = makeViewModel(interaction: .knock)
static let joinViewModel = makeViewModel(interaction: .join)
static let inviteViewModel = makeViewModel(interaction: .invited)
static let unknownViewModel = makeViewModel(mode: .unknown)
static let knockViewModel = makeViewModel(mode: .knock)
static let joinViewModel = makeViewModel(mode: .join)
static let inviteViewModel = makeViewModel(mode: .invited)
static var previews: some View {
NavigationStack {
JoinRoomScreen(context: knockViewModel.context)
JoinRoomScreen(context: unknownViewModel.context)
}
.previewDisplayName("Knock")
.previewDisplayName("Unknown")
.snapshot(delay: 0.25)
// NavigationStack {
// JoinRoomScreen(context: knockViewModel.context)
// }
// .previewDisplayName("Knock")
// .snapshot(delay: 0.25)
NavigationStack {
JoinRoomScreen(context: joinViewModel.context)
}
.previewDisplayName("Join")
.snapshot(delay: 0.25)
NavigationStack {
JoinRoomScreen(context: inviteViewModel.context)
}
.previewDisplayName("Invite")
.snapshot(delay: 0.25)
}
static func makeViewModel(interaction: JoinRoomScreenInteraction) -> JoinRoomScreenViewModel {
JoinRoomScreenViewModel(roomID: "6",
roomName: "Room Name",
avatarURL: nil,
interaction: interaction,
clientProxy: ClientProxyMock(.init()))
static func makeViewModel(mode: JoinRoomScreenInteractionMode) -> JoinRoomScreenViewModel {
let clientProxy = ClientProxyMock(.init())
// swiftlint:disable:next large_tuple
let membership: (isJoined: Bool, isInvited: Bool, isPublic: Bool, canKnock: Bool) = switch mode {
case .unknown:
(false, false, false, false)
case .invited:
(false, true, false, false)
case .join:
(false, false, true, false)
case .knock:
(false, false, false, true)
}
if mode == .unknown {
clientProxy.roomPreviewForIdentifierReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
} else {
clientProxy.roomPreviewForIdentifierReturnValue = .success(.init(roomID: "1",
name: "The Three-Body Problem - 三体",
canonicalAlias: "#3🌞problem:matrix.org",
// swiftlint:disable:next line_length
topic: "“Science and technology were the only keys to opening the door to the future, and people approached science with the faith and sincerity of elementary school students.”",
avatarURL: URL.homeDirectory,
memberCount: UInt(100),
isHistoryWorldReadable: false,
isJoined: membership.isJoined,
isInvited: membership.isInvited,
isPublic: membership.isPublic,
canKnock: membership.canKnock))
}
return JoinRoomScreenViewModel(roomID: "1",
clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}