mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
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:
@@ -113,7 +113,7 @@ struct HomeScreenContent: View {
|
||||
HomeScreenRecoveryKeyConfirmationBanner(context: context)
|
||||
}
|
||||
|
||||
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused {
|
||||
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused, !ServiceLocator.shared.settings.roomListInvitesEnabled {
|
||||
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
|
||||
context.send(viewAction: .selectInvites)
|
||||
}
|
||||
|
||||
@@ -40,12 +40,18 @@ struct HomeScreenInviteCell: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 16)
|
||||
.padding(.trailing, 16)
|
||||
.multilineTextAlignment(.leading)
|
||||
.overlay(alignment: .bottom) {
|
||||
separator
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
.padding(.leading, 16)
|
||||
.onTapGesture {
|
||||
if let roomId = room.roomId {
|
||||
context.send(viewAction: .selectRoom(roomIdentifier: roomId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -24,7 +24,7 @@ struct RoomDirectorySearchScreenCoordinatorParameters {
|
||||
}
|
||||
|
||||
enum RoomDirectorySearchScreenCoordinatorAction {
|
||||
case joined(roomID: String)
|
||||
case select(roomID: String)
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ final class RoomDirectorySearchScreenCoordinator: CoordinatorProtocol {
|
||||
viewModel.actionsPublisher.sink { [weak self] action in
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case .joined(let roomID):
|
||||
actionsSubject.send(.joined(roomID: roomID))
|
||||
case .select(let roomID):
|
||||
actionsSubject.send(.select(roomID: roomID))
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@
|
||||
import Foundation
|
||||
|
||||
enum RoomDirectorySearchScreenViewModelAction {
|
||||
case joined(roomID: String)
|
||||
case select(roomID: String)
|
||||
case dismiss
|
||||
}
|
||||
|
||||
@@ -35,6 +35,6 @@ struct RoomDirectorySearchScreenViewStateBindings {
|
||||
|
||||
enum RoomDirectorySearchScreenViewAction {
|
||||
case dismiss
|
||||
case join(roomID: String)
|
||||
case select(roomID: String)
|
||||
case reachedBottom
|
||||
}
|
||||
|
||||
+2
-33
@@ -65,8 +65,8 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
|
||||
switch viewAction {
|
||||
case .dismiss:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .join(roomID: let roomID):
|
||||
joinRoom(roomID: roomID)
|
||||
case .select(roomID: let roomID):
|
||||
actionsSubject.send(.select(roomID: roomID))
|
||||
case .reachedBottom:
|
||||
loadNextPage()
|
||||
}
|
||||
@@ -74,24 +74,6 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func joinRoom(roomID: String) {
|
||||
showLoadingIndicator()
|
||||
|
||||
Task {
|
||||
defer {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
switch await clientProxy.joinRoom(roomID) {
|
||||
case .success:
|
||||
actionsSubject.send(.joined(roomID: roomID))
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed joining room with error: \(error)")
|
||||
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let errorID = "roomDirectorySearchViewModelLoadingError"
|
||||
|
||||
private func search(query: String?) {
|
||||
@@ -122,17 +104,4 @@ class RoomDirectorySearchScreenViewModel: RoomDirectorySearchScreenViewModelType
|
||||
state.isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private static let loadingIndicatorIdentifier = "\(RoomDirectorySearchScreenViewModel.self)-Loading"
|
||||
|
||||
private func showLoadingIndicator() {
|
||||
userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier,
|
||||
type: .modal,
|
||||
title: L10n.commonLoading,
|
||||
persistent: true))
|
||||
}
|
||||
|
||||
private func hideLoadingIndicator() {
|
||||
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ struct RoomDirectorySearchScreen: View {
|
||||
Section {
|
||||
ForEach(context.viewState.rooms) { room in
|
||||
RoomDirectorySearchCell(result: room, imageProvider: context.imageProvider) {
|
||||
context.send(viewAction: .join(roomID: room.id))
|
||||
context.send(viewAction: .select(roomID: room.id))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
|
||||
Reference in New Issue
Block a user