mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Knock and knocked state for the join room screen (#3424)
* JoinRoomScreen ui for knocking * code improvement * updated previews * added knocked state with tests * send knock request * Apply suggestions from code review Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com> * pr comments --------- Co-authored-by: Doug <6060466+pixlwave@users.noreply.github.com>
This commit is contained in:
@@ -662,7 +662,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
via: via,
|
||||
clientProxy: userSession.clientProxy,
|
||||
mediaProvider: userSession.mediaProvider,
|
||||
userIndicatorController: userIndicatorController))
|
||||
userIndicatorController: userIndicatorController,
|
||||
appSettings: appSettings))
|
||||
|
||||
joinRoomScreenCoordinator = coordinator
|
||||
|
||||
|
||||
@@ -502,6 +502,8 @@ internal enum L10n {
|
||||
internal static var commonVerificationCancelled: String { return L10n.tr("Localizable", "common_verification_cancelled") }
|
||||
/// Verification complete
|
||||
internal static var commonVerificationComplete: String { return L10n.tr("Localizable", "common_verification_complete") }
|
||||
/// Verified
|
||||
internal static var commonVerified: String { return L10n.tr("Localizable", "common_verified") }
|
||||
/// Verify device
|
||||
internal static var commonVerifyDevice: String { return L10n.tr("Localizable", "common_verify_device") }
|
||||
/// Video
|
||||
@@ -1187,10 +1189,18 @@ internal enum L10n {
|
||||
internal static func screenInvitesInvitedYou(_ p1: Any, _ p2: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_invites_invited_you", String(describing: p1), String(describing: p2))
|
||||
}
|
||||
/// Cancel request
|
||||
internal static var screenJoinRoomCancelKnockAction: String { return L10n.tr("Localizable", "screen_join_room_cancel_knock_action") }
|
||||
/// Join room
|
||||
internal static var screenJoinRoomJoinAction: String { return L10n.tr("Localizable", "screen_join_room_join_action") }
|
||||
/// Knock to join
|
||||
/// Send request to join
|
||||
internal static var screenJoinRoomKnockAction: String { return L10n.tr("Localizable", "screen_join_room_knock_action") }
|
||||
/// Message (optional)
|
||||
internal static var screenJoinRoomKnockMessageDescription: String { return L10n.tr("Localizable", "screen_join_room_knock_message_description") }
|
||||
/// You will receive an invite to join the room if your request is accepted.
|
||||
internal static var screenJoinRoomKnockSentDescription: String { return L10n.tr("Localizable", "screen_join_room_knock_sent_description") }
|
||||
/// Request to join sent
|
||||
internal static var screenJoinRoomKnockSentTitle: String { return L10n.tr("Localizable", "screen_join_room_knock_sent_title") }
|
||||
/// %1$@ does not support spaces yet. You can access spaces on web.
|
||||
internal static func screenJoinRoomSpaceNotSupportedDescription(_ p1: Any) -> String {
|
||||
return L10n.tr("Localizable", "screen_join_room_space_not_supported_description", String(describing: p1))
|
||||
|
||||
@@ -2835,6 +2835,146 @@ class ClientProxyMock: ClientProxyProtocol {
|
||||
return joinRoomAliasReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - knockRoom
|
||||
|
||||
var knockRoomMessageUnderlyingCallsCount = 0
|
||||
var knockRoomMessageCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return knockRoomMessageUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = knockRoomMessageUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
knockRoomMessageUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
knockRoomMessageUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var knockRoomMessageCalled: Bool {
|
||||
return knockRoomMessageCallsCount > 0
|
||||
}
|
||||
var knockRoomMessageReceivedArguments: (roomID: String, message: String?)?
|
||||
var knockRoomMessageReceivedInvocations: [(roomID: String, message: String?)] = []
|
||||
|
||||
var knockRoomMessageUnderlyingReturnValue: Result<Void, ClientProxyError>!
|
||||
var knockRoomMessageReturnValue: Result<Void, ClientProxyError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return knockRoomMessageUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Void, ClientProxyError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = knockRoomMessageUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
knockRoomMessageUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
knockRoomMessageUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var knockRoomMessageClosure: ((String, String?) async -> Result<Void, ClientProxyError>)?
|
||||
|
||||
func knockRoom(_ roomID: String, message: String?) async -> Result<Void, ClientProxyError> {
|
||||
knockRoomMessageCallsCount += 1
|
||||
knockRoomMessageReceivedArguments = (roomID: roomID, message: message)
|
||||
DispatchQueue.main.async {
|
||||
self.knockRoomMessageReceivedInvocations.append((roomID: roomID, message: message))
|
||||
}
|
||||
if let knockRoomMessageClosure = knockRoomMessageClosure {
|
||||
return await knockRoomMessageClosure(roomID, message)
|
||||
} else {
|
||||
return knockRoomMessageReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - knockRoomAlias
|
||||
|
||||
var knockRoomAliasMessageUnderlyingCallsCount = 0
|
||||
var knockRoomAliasMessageCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return knockRoomAliasMessageUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = knockRoomAliasMessageUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
knockRoomAliasMessageUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
knockRoomAliasMessageUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var knockRoomAliasMessageCalled: Bool {
|
||||
return knockRoomAliasMessageCallsCount > 0
|
||||
}
|
||||
var knockRoomAliasMessageReceivedArguments: (roomAlias: String, message: String?)?
|
||||
var knockRoomAliasMessageReceivedInvocations: [(roomAlias: String, message: String?)] = []
|
||||
|
||||
var knockRoomAliasMessageUnderlyingReturnValue: Result<Void, ClientProxyError>!
|
||||
var knockRoomAliasMessageReturnValue: Result<Void, ClientProxyError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return knockRoomAliasMessageUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Void, ClientProxyError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = knockRoomAliasMessageUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
knockRoomAliasMessageUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
knockRoomAliasMessageUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var knockRoomAliasMessageClosure: ((String, String?) async -> Result<Void, ClientProxyError>)?
|
||||
|
||||
func knockRoomAlias(_ roomAlias: String, message: String?) async -> Result<Void, ClientProxyError> {
|
||||
knockRoomAliasMessageCallsCount += 1
|
||||
knockRoomAliasMessageReceivedArguments = (roomAlias: roomAlias, message: message)
|
||||
DispatchQueue.main.async {
|
||||
self.knockRoomAliasMessageReceivedInvocations.append((roomAlias: roomAlias, message: message))
|
||||
}
|
||||
if let knockRoomAliasMessageClosure = knockRoomAliasMessageClosure {
|
||||
return await knockRoomAliasMessageClosure(roomAlias, message)
|
||||
} else {
|
||||
return knockRoomAliasMessageReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - uploadMedia
|
||||
|
||||
var uploadMediaUnderlyingCallsCount = 0
|
||||
@@ -9541,6 +9681,117 @@ class KeychainControllerMock: KeychainControllerProtocol {
|
||||
removePINCodeBiometricStateClosure?()
|
||||
}
|
||||
}
|
||||
class KnockedRoomProxyMock: KnockedRoomProxyProtocol {
|
||||
var id: String {
|
||||
get { return underlyingId }
|
||||
set(value) { underlyingId = value }
|
||||
}
|
||||
var underlyingId: String!
|
||||
var canonicalAlias: String?
|
||||
var ownUserID: String {
|
||||
get { return underlyingOwnUserID }
|
||||
set(value) { underlyingOwnUserID = value }
|
||||
}
|
||||
var underlyingOwnUserID: String!
|
||||
var name: String?
|
||||
var topic: String?
|
||||
var avatar: RoomAvatar {
|
||||
get { return underlyingAvatar }
|
||||
set(value) { underlyingAvatar = value }
|
||||
}
|
||||
var underlyingAvatar: RoomAvatar!
|
||||
var avatarURL: URL?
|
||||
var isPublic: Bool {
|
||||
get { return underlyingIsPublic }
|
||||
set(value) { underlyingIsPublic = value }
|
||||
}
|
||||
var underlyingIsPublic: Bool!
|
||||
var isDirect: Bool {
|
||||
get { return underlyingIsDirect }
|
||||
set(value) { underlyingIsDirect = value }
|
||||
}
|
||||
var underlyingIsDirect: Bool!
|
||||
var isSpace: Bool {
|
||||
get { return underlyingIsSpace }
|
||||
set(value) { underlyingIsSpace = value }
|
||||
}
|
||||
var underlyingIsSpace: Bool!
|
||||
var joinedMembersCount: Int {
|
||||
get { return underlyingJoinedMembersCount }
|
||||
set(value) { underlyingJoinedMembersCount = value }
|
||||
}
|
||||
var underlyingJoinedMembersCount: Int!
|
||||
var activeMembersCount: Int {
|
||||
get { return underlyingActiveMembersCount }
|
||||
set(value) { underlyingActiveMembersCount = value }
|
||||
}
|
||||
var underlyingActiveMembersCount: Int!
|
||||
|
||||
//MARK: - cancelKnock
|
||||
|
||||
var cancelKnockUnderlyingCallsCount = 0
|
||||
var cancelKnockCallsCount: Int {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return cancelKnockUnderlyingCallsCount
|
||||
} else {
|
||||
var returnValue: Int? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = cancelKnockUnderlyingCallsCount
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
cancelKnockUnderlyingCallsCount = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
cancelKnockUnderlyingCallsCount = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var cancelKnockCalled: Bool {
|
||||
return cancelKnockCallsCount > 0
|
||||
}
|
||||
|
||||
var cancelKnockUnderlyingReturnValue: Result<Void, RoomProxyError>!
|
||||
var cancelKnockReturnValue: Result<Void, RoomProxyError>! {
|
||||
get {
|
||||
if Thread.isMainThread {
|
||||
return cancelKnockUnderlyingReturnValue
|
||||
} else {
|
||||
var returnValue: Result<Void, RoomProxyError>? = nil
|
||||
DispatchQueue.main.sync {
|
||||
returnValue = cancelKnockUnderlyingReturnValue
|
||||
}
|
||||
|
||||
return returnValue!
|
||||
}
|
||||
}
|
||||
set {
|
||||
if Thread.isMainThread {
|
||||
cancelKnockUnderlyingReturnValue = newValue
|
||||
} else {
|
||||
DispatchQueue.main.sync {
|
||||
cancelKnockUnderlyingReturnValue = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var cancelKnockClosure: (() async -> Result<Void, RoomProxyError>)?
|
||||
|
||||
func cancelKnock() async -> Result<Void, RoomProxyError> {
|
||||
cancelKnockCallsCount += 1
|
||||
if let cancelKnockClosure = cancelKnockClosure {
|
||||
return await cancelKnockClosure()
|
||||
} else {
|
||||
return cancelKnockReturnValue
|
||||
}
|
||||
}
|
||||
}
|
||||
class MediaLoaderMock: MediaLoaderProtocol {
|
||||
|
||||
//MARK: - loadMediaContentForSource
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
struct KnockedRoomProxyMockConfiguration {
|
||||
var id = UUID().uuidString
|
||||
var name: String?
|
||||
var avatarURL: URL?
|
||||
var members: [RoomMemberProxyMock] = .allMembers
|
||||
}
|
||||
|
||||
extension KnockedRoomProxyMock {
|
||||
@MainActor
|
||||
convenience init(_ configuration: KnockedRoomProxyMockConfiguration) {
|
||||
self.init()
|
||||
id = configuration.id
|
||||
name = configuration.name
|
||||
avatarURL = configuration.avatarURL
|
||||
avatar = .room(id: configuration.id, name: configuration.name, avatarURL: configuration.avatarURL) // Note: This doesn't replicate the real proxy logic.
|
||||
activeMembersCount = configuration.members.filter { $0.membership == .join || $0.membership == .invite }.count
|
||||
}
|
||||
}
|
||||
@@ -90,3 +90,10 @@ extension String {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
/// detects if the string is empty or contains only whitespaces and newlines
|
||||
var isBlank: Bool {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ struct JoinRoomScreenCoordinatorParameters {
|
||||
let clientProxy: ClientProxyProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let userIndicatorController: UserIndicatorControllerProtocol
|
||||
let appSettings: AppSettings
|
||||
}
|
||||
|
||||
enum JoinRoomScreenCoordinatorAction {
|
||||
@@ -34,6 +35,7 @@ final class JoinRoomScreenCoordinator: CoordinatorProtocol {
|
||||
init(parameters: JoinRoomScreenCoordinatorParameters) {
|
||||
viewModel = JoinRoomScreenViewModel(roomID: parameters.roomID,
|
||||
via: parameters.via,
|
||||
appSettings: parameters.appSettings,
|
||||
clientProxy: parameters.clientProxy,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
userIndicatorController: parameters.userIndicatorController)
|
||||
|
||||
@@ -18,6 +18,7 @@ enum JoinRoomScreenInteractionMode {
|
||||
case invited
|
||||
case join
|
||||
case knock
|
||||
case knocked
|
||||
}
|
||||
|
||||
struct JoinRoomScreenRoomDetails {
|
||||
@@ -48,6 +49,7 @@ struct JoinRoomScreenViewState: BindableState {
|
||||
case .loading: nil
|
||||
case .unknown: L10n.screenJoinRoomSubtitleNoPreview
|
||||
case .invited, .join, .knock: roomDetails?.canonicalAlias
|
||||
case .knocked: nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ struct JoinRoomScreenViewState: BindableState {
|
||||
|
||||
struct JoinRoomScreenViewStateBindings {
|
||||
var alertInfo: AlertInfo<JoinRoomScreenAlertType>?
|
||||
var knockMessage = ""
|
||||
}
|
||||
|
||||
enum JoinRoomScreenAlertType {
|
||||
@@ -65,6 +68,7 @@ enum JoinRoomScreenAlertType {
|
||||
}
|
||||
|
||||
enum JoinRoomScreenViewAction {
|
||||
case cancelKnock
|
||||
case knock
|
||||
case join
|
||||
case acceptInvite
|
||||
|
||||
@@ -13,7 +13,7 @@ typealias JoinRoomScreenViewModelType = StateStoreViewModel<JoinRoomScreenViewSt
|
||||
class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewModelProtocol {
|
||||
private let roomID: String
|
||||
private let via: [String]
|
||||
private let allowKnocking: Bool // For preview tests only, actions aren't sent.
|
||||
private let appSettings: AppSettings
|
||||
private let clientProxy: ClientProxyProtocol
|
||||
private let userIndicatorController: UserIndicatorControllerProtocol
|
||||
|
||||
@@ -27,13 +27,13 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
|
||||
init(roomID: String,
|
||||
via: [String],
|
||||
allowKnocking: Bool = false,
|
||||
appSettings: AppSettings,
|
||||
clientProxy: ClientProxyProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
userIndicatorController: UserIndicatorControllerProtocol) {
|
||||
self.roomID = roomID
|
||||
self.via = via
|
||||
self.allowKnocking = allowKnocking
|
||||
self.appSettings = appSettings
|
||||
self.clientProxy = clientProxy
|
||||
self.userIndicatorController = userIndicatorController
|
||||
|
||||
@@ -51,13 +51,16 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
|
||||
switch viewAction {
|
||||
case .knock:
|
||||
break
|
||||
Task { await knockRoom() }
|
||||
case .join:
|
||||
Task { await joinRoom() }
|
||||
case .acceptInvite:
|
||||
Task { await joinRoom() }
|
||||
case .declineInvite:
|
||||
showDeclineInviteConfirmationAlert()
|
||||
case .cancelKnock:
|
||||
// TODO: implement once available
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +109,8 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
case .invited(let invitedRoomProxy):
|
||||
inviter = await invitedRoomProxy.inviter.flatMap(RoomInviterDetails.init)
|
||||
roomProxy = invitedRoomProxy
|
||||
case .knocked(let knockedRoomProxy):
|
||||
roomProxy = knockedRoomProxy
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -122,6 +127,11 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
}
|
||||
|
||||
private func updateMode() {
|
||||
if case .knocked = room {
|
||||
state.mode = .knocked
|
||||
return
|
||||
}
|
||||
|
||||
// Check invites first to show Accept/Decline buttons on public rooms.
|
||||
if case .invited = room {
|
||||
state.mode = .invited
|
||||
@@ -133,13 +143,9 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
return
|
||||
}
|
||||
|
||||
if roomPreviewDetails?.isPublic ?? false {
|
||||
state.mode = .join
|
||||
} else if roomPreviewDetails?.canKnock ?? false, allowKnocking { // Knocking is not supported yet, the flag is purely for preview tests.
|
||||
if roomPreviewDetails?.canKnock ?? false, appSettings.knockingEnabled {
|
||||
state.mode = .knock
|
||||
} else {
|
||||
// If everything else fails fallback to showing the join button and
|
||||
// letting the server figure it out.
|
||||
state.mode = .join
|
||||
}
|
||||
}
|
||||
@@ -171,6 +177,34 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
}
|
||||
}
|
||||
|
||||
private func knockRoom() async {
|
||||
showLoadingIndicator()
|
||||
|
||||
defer {
|
||||
hideLoadingIndicator()
|
||||
}
|
||||
|
||||
if let alias = state.roomDetails?.canonicalAlias {
|
||||
switch await clientProxy.knockRoomAlias(alias,
|
||||
message: state.bindings.knockMessage.isBlank ? nil : state.bindings.knockMessage) {
|
||||
case .success:
|
||||
state.mode = .knocked
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed knocking room alias: \(alias) with error: \(error)")
|
||||
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
|
||||
}
|
||||
} else {
|
||||
switch await clientProxy.knockRoom(roomID,
|
||||
message: state.bindings.knockMessage.isBlank ? nil : state.bindings.knockMessage) {
|
||||
case .success:
|
||||
state.mode = .knocked
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed knocking room id: \(roomID) with error: \(error)")
|
||||
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showDeclineInviteConfirmationAlert() {
|
||||
guard let roomDetails = state.roomDetails else {
|
||||
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
|
||||
|
||||
@@ -29,7 +29,17 @@ struct JoinRoomScreen: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var mainContent: some View {
|
||||
if context.viewState.mode == .knocked {
|
||||
knockedView
|
||||
} else {
|
||||
defaultView
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var defaultView: some View {
|
||||
VStack(spacing: 16) {
|
||||
RoomAvatarImage(avatar: context.viewState.avatar,
|
||||
avatarSize: .room(on: .joinRoom),
|
||||
@@ -66,10 +76,59 @@ struct JoinRoomScreen: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
if context.viewState.mode == .knock {
|
||||
knockMessage
|
||||
.padding(.top, 19)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var knockedView: some View {
|
||||
VStack(spacing: 16) {
|
||||
HeroImage(icon: \.checkCircleSolid, style: .success)
|
||||
VStack(spacing: 8) {
|
||||
Text(L10n.screenJoinRoomKnockSentTitle)
|
||||
.font(.compound.headingMDBold)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(L10n.screenJoinRoomKnockSentDescription)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var knockMessage: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 0) {
|
||||
TextField("", text: $context.knockMessage, axis: .vertical)
|
||||
.onChange(of: context.knockMessage) { newValue in
|
||||
context.knockMessage = String(newValue.prefix(1000))
|
||||
}
|
||||
.lineLimit(4, reservesSpace: true)
|
||||
.font(.compound.bodyMD)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.background(.compound.bgCanvasDefault)
|
||||
.cornerRadius(8)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.inset(by: 0.5)
|
||||
.stroke(.compound.borderInteractivePrimary)
|
||||
}
|
||||
|
||||
Text(L10n.screenJoinRoomKnockMessageDescription)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var buttons: some View {
|
||||
switch context.viewState.mode {
|
||||
@@ -77,7 +136,10 @@ struct JoinRoomScreen: View {
|
||||
EmptyView()
|
||||
case .knock:
|
||||
Button(L10n.screenJoinRoomKnockAction) { context.send(viewAction: .knock) }
|
||||
.buttonStyle(.compound(.primary))
|
||||
.buttonStyle(.compound(.super))
|
||||
case .knocked:
|
||||
Button(L10n.screenJoinRoomCancelKnockAction) { context.send(viewAction: .cancelKnock) }
|
||||
.buttonStyle(.compound(.secondary))
|
||||
case .join:
|
||||
Button(L10n.screenJoinRoomJoinAction) { context.send(viewAction: .join) }
|
||||
.buttonStyle(.compound(.super))
|
||||
@@ -105,6 +167,7 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let knockViewModel = makeViewModel(mode: .knock)
|
||||
static let joinViewModel = makeViewModel(mode: .join)
|
||||
static let inviteViewModel = makeViewModel(mode: .invited)
|
||||
static let knockedViewModel = makeViewModel(mode: .knocked)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
@@ -130,6 +193,12 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
}
|
||||
.previewDisplayName("Invite")
|
||||
.snapshotPreferences(delay: 0.25)
|
||||
|
||||
NavigationStack {
|
||||
JoinRoomScreen(context: knockedViewModel.context)
|
||||
}
|
||||
.previewDisplayName("Knocked")
|
||||
.snapshotPreferences(delay: 0.25)
|
||||
}
|
||||
|
||||
static func makeViewModel(mode: JoinRoomScreenInteractionMode) -> JoinRoomScreenViewModel {
|
||||
@@ -145,11 +214,18 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
(false, false, true, false)
|
||||
case .knock:
|
||||
(false, false, false, true)
|
||||
case .knocked:
|
||||
(false, false, false, false)
|
||||
}
|
||||
|
||||
if mode == .unknown {
|
||||
clientProxy.roomPreviewForIdentifierViaReturnValue = .failure(.sdkError(ClientProxyMockError.generic))
|
||||
} else {
|
||||
if mode == .knocked {
|
||||
clientProxy.roomForIdentifierClosure = { _ in
|
||||
.knocked(KnockedRoomProxyMock(.init()))
|
||||
}
|
||||
}
|
||||
clientProxy.roomPreviewForIdentifierViaReturnValue = .success(.init(roomID: "1",
|
||||
name: "The Three-Body Problem - 三体",
|
||||
canonicalAlias: "#3🌞problem:matrix.org",
|
||||
@@ -164,9 +240,11 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
canKnock: membership.canKnock))
|
||||
}
|
||||
|
||||
ServiceLocator.shared.settings.knockingEnabled = true
|
||||
|
||||
return JoinRoomScreenViewModel(roomID: "1",
|
||||
via: [],
|
||||
allowKnocking: true,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
clientProxy: clientProxy,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
@@ -415,6 +415,30 @@ class ClientProxy: ClientProxyProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func knockRoom(_ roomID: String, message: String?) async -> Result<Void, ClientProxyError> {
|
||||
do {
|
||||
// TODO: It should also include a message but the API for is not available yet
|
||||
let _ = try await client.knock(roomIdOrAlias: roomID)
|
||||
await waitForRoomToSync(roomID: roomID, timeout: .seconds(30))
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Failed knocking roomID: \(roomID) with error: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func knockRoomAlias(_ roomAlias: String, message: String?) async -> Result<Void, ClientProxyError> {
|
||||
do {
|
||||
// TODO: It should also include a message but the API for is not available yet
|
||||
let room = try await client.knock(roomIdOrAlias: roomAlias)
|
||||
await waitForRoomToSync(roomID: room.id(), timeout: .seconds(30))
|
||||
return .success(())
|
||||
} catch {
|
||||
MXLog.error("Failed knocking roomAlias: \(roomAlias) with error: \(error)")
|
||||
return .failure(.sdkError(error))
|
||||
}
|
||||
}
|
||||
|
||||
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError> {
|
||||
guard let mimeType = media.mimeType else {
|
||||
MXLog.error("Failed uploading media, invalid mime type: \(media)")
|
||||
@@ -846,9 +870,17 @@ class ClientProxy: ClientProxyProtocol {
|
||||
let roomListItem = try roomListService.room(roomId: roomID)
|
||||
|
||||
switch roomListItem.membership() {
|
||||
case .invited, .knocked:
|
||||
case .invited:
|
||||
return try .invited(InvitedRoomProxy(roomListItem: roomListItem,
|
||||
room: roomListItem.invitedRoom()))
|
||||
case .knocked:
|
||||
if appSettings.knockingEnabled {
|
||||
return try .knocked(KnockedRoomProxy(roomListItem: roomListItem,
|
||||
room: roomListItem.invitedRoom()))
|
||||
} else {
|
||||
return try .invited(InvitedRoomProxy(roomListItem: roomListItem,
|
||||
room: roomListItem.invitedRoom()))
|
||||
}
|
||||
case .joined:
|
||||
if roomListItem.isTimelineInitialized() == false {
|
||||
try await roomListItem.initTimeline(eventTypeFilter: eventFilters, internalIdPrefix: nil)
|
||||
|
||||
@@ -145,6 +145,10 @@ protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
|
||||
|
||||
func joinRoomAlias(_ roomAlias: String) async -> Result<Void, ClientProxyError>
|
||||
|
||||
func knockRoom(_ roomID: String, message: String?) async -> Result<Void, ClientProxyError>
|
||||
|
||||
func knockRoomAlias(_ roomAlias: String, message: String?) async -> Result<Void, ClientProxyError>
|
||||
|
||||
func uploadMedia(_ media: MediaInfo) async -> Result<String, ClientProxyError>
|
||||
|
||||
func roomForIdentifier(_ identifier: String) async -> RoomProxyType?
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MatrixRustSDK
|
||||
import UIKit
|
||||
|
||||
class KnockedRoomProxy: KnockedRoomProxyProtocol {
|
||||
private let roomListItem: RoomListItemProtocol
|
||||
private let room: RoomProtocol
|
||||
|
||||
// A room identifier is constant and lazy stops it from being fetched
|
||||
// multiple times over FFI
|
||||
lazy var id: String = room.id()
|
||||
|
||||
var canonicalAlias: String? {
|
||||
room.canonicalAlias()
|
||||
}
|
||||
|
||||
var ownUserID: String {
|
||||
room.ownUserId()
|
||||
}
|
||||
|
||||
var name: String? {
|
||||
roomListItem.displayName()
|
||||
}
|
||||
|
||||
var topic: String? {
|
||||
room.topic()
|
||||
}
|
||||
|
||||
var avatarURL: URL? {
|
||||
roomListItem.avatarUrl().flatMap(URL.init(string:))
|
||||
}
|
||||
|
||||
var avatar: RoomAvatar {
|
||||
if isDirect, avatarURL == nil {
|
||||
let heroes = room.heroes()
|
||||
|
||||
if heroes.count == 1 {
|
||||
return .heroes(heroes.map(UserProfileProxy.init))
|
||||
}
|
||||
}
|
||||
|
||||
return .room(id: id, name: name, avatarURL: avatarURL)
|
||||
}
|
||||
|
||||
var isDirect: Bool {
|
||||
room.isDirect()
|
||||
}
|
||||
|
||||
var isPublic: Bool {
|
||||
room.isPublic()
|
||||
}
|
||||
|
||||
var isSpace: Bool {
|
||||
room.isSpace()
|
||||
}
|
||||
|
||||
var joinedMembersCount: Int {
|
||||
Int(room.joinedMembersCount())
|
||||
}
|
||||
|
||||
var activeMembersCount: Int {
|
||||
Int(room.activeMembersCount())
|
||||
}
|
||||
|
||||
init(roomListItem: RoomListItemProtocol,
|
||||
room: RoomProtocol) {
|
||||
self.roomListItem = roomListItem
|
||||
self.room = room
|
||||
}
|
||||
|
||||
func cancelKnock() async -> Result<Void, RoomProxyError> {
|
||||
// TODO: Implement this once the API is available
|
||||
.failure(.invalidURL)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ enum RoomProxyError: Error {
|
||||
enum RoomProxyType {
|
||||
case joined(JoinedRoomProxyProtocol)
|
||||
case invited(InvitedRoomProxyProtocol)
|
||||
case knocked(KnockedRoomProxyProtocol)
|
||||
case left
|
||||
}
|
||||
|
||||
@@ -56,6 +57,11 @@ protocol InvitedRoomProxyProtocol: RoomProxyProtocol {
|
||||
func acceptInvitation() async -> Result<Void, RoomProxyError>
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol KnockedRoomProxyProtocol: RoomProxyProtocol {
|
||||
func cancelKnock() async -> Result<Void, RoomProxyError>
|
||||
}
|
||||
|
||||
enum JoinedRoomProxyAction: Equatable {
|
||||
case roomInfoUpdate
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user