Implement Knock Logic (#3573)

* WIP RequestToJoin struct

* implemented the logic to display the cells

* knock request banner accept flow

* mark all knocks as seen implemented

* details logic

* implemented accept, decline and ban in the list

* added a loader and modified the stacked view

of the banner

* pr suggestions

* updated naming and loading strings

* added the initial loading state

improved code and the tests

* updated a string that has changed

* code improvement

* tests for the room screen view model

* room details tests

and improved the knock requests tests for the room screen

* knock requests list tests

* added error state alerts with retry

* struct has been renamed on the sdk

so I renamed it also on the app side

* update SDK
This commit is contained in:
Mauro
2024-12-16 15:32:45 +01:00
committed by GitHub
parent 016cdc687a
commit 45a630dd85
54 changed files with 1700 additions and 150 deletions
+17 -1
View File
@@ -70,6 +70,7 @@
0A0625A271EE5B06D2AAA069 /* HomeScreenSlidingSyncMigrationBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4691B8DE1D51DE152680098A /* HomeScreenSlidingSyncMigrationBanner.swift */; };
0A194F5E70B5A628C1BF4476 /* AdvancedSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4999B5FD50AED7CB0F590FF8 /* AdvancedSettingsScreenModels.swift */; };
0ACAA31FD0399CEEBA3ECC21 /* UserDetailsEditScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85149F56BA333619900E2410 /* UserDetailsEditScreenViewModelProtocol.swift */; };
0AD8EF040A60D62F488C18B5 /* KnockRequestProxyMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */; };
0AE0AB1952F186EB86719B4F /* HomeScreenRoomCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED044D00F2176681CC02CD54 /* HomeScreenRoomCell.swift */; };
0BAF83521871E69D222EE8E4 /* ClientBuilderHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AC0CD1CAFD3F8B057F9AEA5 /* ClientBuilderHook.swift */; };
0BDA19079FD6E17C5AC62E22 /* RoomDetailsEditScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */; };
@@ -139,6 +140,7 @@
18E3786918486D4C9726BC84 /* FormButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89FBFC09F9DAFF1E4BA97849 /* FormButtonStyles.swift */; };
18FDE4ED6D83B0771452B43D /* RoomSelectionScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F104596B0620CEFE5DFD31B1 /* RoomSelectionScreenCoordinator.swift */; };
192A3CDCD0174AD1E4A128E4 /* AudioRecorderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2441E2424E78A40FC95DBA76 /* AudioRecorderTests.swift */; };
194585F6CD77242B36D4ADF1 /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = DADECBBB672497BCD4822468 /* Result.swift */; };
1950A80CD198BED283DFC2CE /* ClientProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2958E6D247AE2516BEEE8 /* ClientProxy.swift */; };
197441F1EF23A5DABACCA79F /* StickerRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5338450E6783A576B5C16DD /* StickerRoomTimelineView.swift */; };
19DED23340D0855B59693ED2 /* VoiceMessageRecorderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45C9EAA86423D7D3126DE4F /* VoiceMessageRecorderProtocol.swift */; };
@@ -646,6 +648,7 @@
8358D145F9BF94F412BEDCA8 /* RoomRolesAndPermissionsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE7969EBCAF078813E18EA1 /* RoomRolesAndPermissionsScreenModels.swift */; };
83A4DAB181C56987C3E804FF /* MapTilerStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0B9F5BC4C80543DE7228B9D /* MapTilerStyle.swift */; };
83B17A44D3E7E6DF22D9A2A4 /* RoomModerationRole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B32BBA8887BD7A5C4ECF16F /* RoomModerationRole.swift */; };
83D519C509F0F76EDBB60455 /* KnockRequestProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F062DD2CCD95DC33528A16F /* KnockRequestProxy.swift */; };
84226AD2E1F1FBC965F3B09E /* UnitTestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8E19C4645D3F5F9FB02355 /* UnitTestsAppCoordinator.swift */; };
8446C2A7ECEFDA79F622725F /* TimelineReactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54AD70D6E03D2031AE1B5A52 /* TimelineReactionsView.swift */; };
8478992479B296C45150208F /* AppLockScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC0275CEE9CA078B34028BDF /* AppLockScreenViewModelTests.swift */; };
@@ -1025,6 +1028,7 @@
D10BA4F041DC58580A440A32 /* RoomRolesAndPermissionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B1DC3B3FB40A7F4AE9B7BF /* RoomRolesAndPermissionsScreen.swift */; };
D12F440F7973F1489F61389D /* NotificationSettingsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F64447FF544298A6A3BEF85 /* NotificationSettingsScreenModels.swift */; };
D181AC8FF236B7F91C0A8C28 /* MapTiler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AA3F4B285570805CB0CCDD /* MapTiler.swift */; };
D18B70975644C24F60656C0D /* KnockRequestProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C07851F4EA81AA3339806A7B /* KnockRequestProxyProtocol.swift */; };
D19A748E95E2FAB2940570F0 /* CallScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4103AB4340F2974D690A12A /* CallScreen.swift */; };
D2048FD56760BDABA3DB5FC2 /* AppLockServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EAAB54C6CE91D64B69A9F8 /* AppLockServiceProtocol.swift */; };
D22345698F6548C1EE960940 /* IdentityConfirmedScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DBE70FFB7936F35811772C1 /* IdentityConfirmedScreenModels.swift */; };
@@ -1906,6 +1910,7 @@
7EB58E4E8D6D634C246AD5C2 /* RoomInviterLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomInviterLabel.swift; sourceTree = "<group>"; };
7EECE8B331CD169790EF284F /* BugReportScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModelTests.swift; sourceTree = "<group>"; };
7F615A00DB223FF3280204D2 /* UserDiscoveryServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDiscoveryServiceProtocol.swift; sourceTree = "<group>"; };
7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestProxyMock.swift; sourceTree = "<group>"; };
7FB2253D36E81E045E1CB432 /* Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Duration.swift; sourceTree = "<group>"; };
7FDF541AE914059942B575B4 /* IdentityConfirmationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityConfirmationScreenModels.swift; sourceTree = "<group>"; };
8063E65441E771200108C558 /* ReadReceiptsSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptsSummaryView.swift; sourceTree = "<group>"; };
@@ -1983,6 +1988,7 @@
8E088F2A1B9EC529D3221931 /* UITests.xctestplan */ = {isa = PBXFileReference; path = UITests.xctestplan; sourceTree = "<group>"; };
8E1584F8BCF407BB94F48F04 /* EncryptionResetPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreen.swift; sourceTree = "<group>"; };
8EAF4A49F3ACD8BB8B0D2371 /* ClientSDKMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientSDKMock.swift; sourceTree = "<group>"; };
8F062DD2CCD95DC33528A16F /* KnockRequestProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestProxy.swift; sourceTree = "<group>"; };
8F21ED7205048668BEB44A38 /* AppActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppActivityView.swift; sourceTree = "<group>"; };
8F6210134203BE1F2DD5C679 /* RoomDirectoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectoryCell.swift; sourceTree = "<group>"; };
8F841F219ACDFC1D3F42FEFB /* RoomChangeRolesScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomChangeRolesScreenViewModelTests.swift; sourceTree = "<group>"; };
@@ -2219,6 +2225,7 @@
BFEE91FB8ABB5F5884B6D940 /* WaveformInteractionModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformInteractionModifier.swift; sourceTree = "<group>"; };
C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = "<group>"; };
C070FD43DC6BF4E50217965A /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = "<group>"; };
C07851F4EA81AA3339806A7B /* KnockRequestProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestProxyProtocol.swift; sourceTree = "<group>"; };
C08E9043618AE5B0BF7B07E1 /* TemplateScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModelTests.swift; sourceTree = "<group>"; };
C0900BBF0A5D5D775E917C70 /* EventBasedMessageTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBasedMessageTimelineItemProtocol.swift; sourceTree = "<group>"; };
C0FEA560929DD73FFEF8C3DF /* HomeScreenEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenEmptyStateView.swift; sourceTree = "<group>"; };
@@ -2341,6 +2348,7 @@
DA2AEC1AB349A341FE13DEC1 /* StartChatScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenUITests.swift; sourceTree = "<group>"; };
DA3D82522494E78746B2214E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/SAS.strings; sourceTree = "<group>"; };
DAB8D7926A5684E18196B538 /* VoiceMessageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageCache.swift; sourceTree = "<group>"; };
DADECBBB672497BCD4822468 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = "<group>"; };
DB06F22CFA34885B40976061 /* RoomDetailsEditScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreen.swift; sourceTree = "<group>"; };
DBEDCEC9D908C19C63D24395 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
DC0AEA686E425F86F6BA0404 /* UNNotification+Creator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UNNotification+Creator.swift"; sourceTree = "<group>"; };
@@ -3152,6 +3160,7 @@
3A21027F05874B1BCC3E452B /* InvitedRoomProxyMock.swift */,
867DC9530C42F7B5176BE465 /* JoinedRoomProxyMock.swift */,
9E8F4D7D61B80EBD5CB92F8A /* KnockedRoomProxyMock.swift */,
7F957320D0EB7D7B4E30C79D /* KnockRequestProxyMock.swift */,
6F65E4BB9E82EB8373207CF8 /* MediaProviderMock.swift */,
8DA1E8F287680C8ED25EDBAC /* NetworkMonitorMock.swift */,
382B50F7E379B3DBBD174364 /* NotificationSettingsProxyMock.swift */,
@@ -3448,6 +3457,8 @@
0E95B3BDB80531C85CD50AE6 /* InvitedRoomProxy.swift */,
07C6B0B087FE6601C3F77816 /* JoinedRoomProxy.swift */,
858DA81F2ACF484B7CAD6AE4 /* KnockedRoomProxy.swift */,
8F062DD2CCD95DC33528A16F /* KnockRequestProxy.swift */,
C07851F4EA81AA3339806A7B /* KnockRequestProxyProtocol.swift */,
B6404166CBF5CC88673FF9E2 /* RoomDetails.swift */,
40A66E8BC8D9AE4A08EFB2DF /* RoomInfoProxy.swift */,
974AEAF3FE0C577A6C04AD6E /* RoomPermissions.swift */,
@@ -3527,6 +3538,7 @@
077B01C13BBA2996272C5FB5 /* ProcessInfo.swift */,
1DFE0E493FB55E5A62E7852A /* ProposedViewSize.swift */,
7310D8DFE01AF45F0689C3AA /* Publisher.swift */,
DADECBBB672497BCD4822468 /* Result.swift */,
584A61D9C459FAFEF038A7C0 /* Section.swift */,
DF17EA323AD0205A6AB621AA /* Snapshotting.swift */,
40B21E611DADDEF00307E7AC /* String.swift */,
@@ -7011,6 +7023,9 @@
FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */,
CB99B0FA38A4AC596F38CC13 /* KeychainControllerProtocol.swift in Sources */,
2748E5574A1031DD05E54FDA /* KnockRequestCell.swift in Sources */,
83D519C509F0F76EDBB60455 /* KnockRequestProxy.swift in Sources */,
0AD8EF040A60D62F488C18B5 /* KnockRequestProxyMock.swift in Sources */,
D18B70975644C24F60656C0D /* KnockRequestProxyProtocol.swift in Sources */,
D5E8EE8A288EFCCF646860EA /* KnockRequestsBannerView.swift in Sources */,
E8B290CBB7E5FF5E3C1B6124 /* KnockRequestsListEmptyStateView.swift in Sources */,
AAA551AD8768309024D4907B /* KnockRequestsListScreen.swift in Sources */,
@@ -7212,6 +7227,7 @@
9A0326D2375075871D2AB537 /* ResolveVerifiedUserSendFailureScreenViewModel.swift in Sources */,
ED3E91E6166E4923791ACA84 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift in Sources */,
A494741843F087881299ACF0 /* RestorationToken.swift in Sources */,
194585F6CD77242B36D4ADF1 /* Result.swift in Sources */,
6E391F7F628D984AF44385D9 /* RoomAttachmentPicker.swift in Sources */,
8587A53DE8EF94FD796DC375 /* RoomAvatarImage.swift in Sources */,
F8C87130FD999F7F1076208C /* RoomChangePermissionsScreen.swift in Sources */,
@@ -8391,7 +8407,7 @@
repositoryURL = "https://github.com/element-hq/matrix-rust-components-swift";
requirement = {
kind = exactVersion;
version = 1.0.80;
version = 1.0.81;
};
};
701C7BEF8F70F7A83E852DCC /* XCRemoteSwiftPackageReference "GZIP" */ = {
@@ -149,8 +149,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/matrix-rust-components-swift",
"state" : {
"revision" : "342dc2f1b6553dba7ed5d6f0a330d77d7fae13c4",
"version" : "1.0.80"
"revision" : "7c3d3abd370bd416c435790dc0c76999e018529b",
"version" : "1.0.81"
}
},
{
@@ -908,7 +908,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
private func presentKnockRequestsList() {
let parameters = KnockRequestsListScreenCoordinatorParameters(roomProxy: roomProxy, mediaProvider: userSession.mediaProvider)
let parameters = KnockRequestsListScreenCoordinatorParameters(roomProxy: roomProxy,
mediaProvider: userSession.mediaProvider,
userIndicatorController: userIndicatorController)
let coordinator = KnockRequestsListScreenCoordinator(parameters: parameters)
navigationStackCoordinator.push(coordinator) { [weak self] in
@@ -1723,14 +1725,3 @@ private extension RoomFlowCoordinator {
case dismissSecurityAndPrivacyScreen
}
}
private extension Result {
var isFailure: Bool {
switch self {
case .success:
return false
case .failure:
return true
}
}
}
@@ -388,7 +388,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case .unknownDevice, .unsignedDevice: .ExpectedSentByInsecureDevice
case .verificationViolation: .ExpectedVerificationViolation
case .sentBeforeWeJoined: .ExpectedDueToMembership
case .historicalMessage: .HistoricalMessage
case .historicalMessageAndBackupIsDisabled, .historicalMessageAndDeviceIsUnverified: .HistoricalMessage
case .withheldForUnverifiedOrInsecureDevice: .RoomKeysWithheldForUnverifiedDevice
case .withheldBySender: .OlmKeysNotSentError
}
@@ -5974,6 +5974,11 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol {
set(value) { underlyingIdentityStatusChangesPublisher = value }
}
var underlyingIdentityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never>!
var knockRequestsStatePublisher: CurrentValuePublisher<KnockRequestsState, Never> {
get { return underlyingKnockRequestsStatePublisher }
set(value) { underlyingKnockRequestsStatePublisher = value }
}
var underlyingKnockRequestsStatePublisher: CurrentValuePublisher<KnockRequestsState, Never>!
var timeline: TimelineProxyProtocol {
get { return underlyingTimeline }
set(value) { underlyingTimeline = value }
@@ -9610,6 +9615,284 @@ class KeychainControllerMock: KeychainControllerProtocol {
removePINCodeBiometricStateClosure?()
}
}
class KnockRequestProxyMock: KnockRequestProxyProtocol {
var eventID: String {
get { return underlyingEventID }
set(value) { underlyingEventID = value }
}
var underlyingEventID: String!
var userID: String {
get { return underlyingUserID }
set(value) { underlyingUserID = value }
}
var underlyingUserID: String!
var displayName: String?
var avatarURL: URL?
var reason: String?
var formattedTimestamp: String?
var isSeen: Bool {
get { return underlyingIsSeen }
set(value) { underlyingIsSeen = value }
}
var underlyingIsSeen: Bool!
//MARK: - accept
var acceptUnderlyingCallsCount = 0
var acceptCallsCount: Int {
get {
if Thread.isMainThread {
return acceptUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = acceptUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
acceptUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
acceptUnderlyingCallsCount = newValue
}
}
}
}
var acceptCalled: Bool {
return acceptCallsCount > 0
}
var acceptUnderlyingReturnValue: Result<Void, KnockRequestProxyError>!
var acceptReturnValue: Result<Void, KnockRequestProxyError>! {
get {
if Thread.isMainThread {
return acceptUnderlyingReturnValue
} else {
var returnValue: Result<Void, KnockRequestProxyError>? = nil
DispatchQueue.main.sync {
returnValue = acceptUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
acceptUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
acceptUnderlyingReturnValue = newValue
}
}
}
}
var acceptClosure: (() async -> Result<Void, KnockRequestProxyError>)?
func accept() async -> Result<Void, KnockRequestProxyError> {
acceptCallsCount += 1
if let acceptClosure = acceptClosure {
return await acceptClosure()
} else {
return acceptReturnValue
}
}
//MARK: - decline
var declineUnderlyingCallsCount = 0
var declineCallsCount: Int {
get {
if Thread.isMainThread {
return declineUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = declineUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
declineUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
declineUnderlyingCallsCount = newValue
}
}
}
}
var declineCalled: Bool {
return declineCallsCount > 0
}
var declineUnderlyingReturnValue: Result<Void, KnockRequestProxyError>!
var declineReturnValue: Result<Void, KnockRequestProxyError>! {
get {
if Thread.isMainThread {
return declineUnderlyingReturnValue
} else {
var returnValue: Result<Void, KnockRequestProxyError>? = nil
DispatchQueue.main.sync {
returnValue = declineUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
declineUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
declineUnderlyingReturnValue = newValue
}
}
}
}
var declineClosure: (() async -> Result<Void, KnockRequestProxyError>)?
func decline() async -> Result<Void, KnockRequestProxyError> {
declineCallsCount += 1
if let declineClosure = declineClosure {
return await declineClosure()
} else {
return declineReturnValue
}
}
//MARK: - ban
var banUnderlyingCallsCount = 0
var banCallsCount: Int {
get {
if Thread.isMainThread {
return banUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = banUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
banUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
banUnderlyingCallsCount = newValue
}
}
}
}
var banCalled: Bool {
return banCallsCount > 0
}
var banUnderlyingReturnValue: Result<Void, KnockRequestProxyError>!
var banReturnValue: Result<Void, KnockRequestProxyError>! {
get {
if Thread.isMainThread {
return banUnderlyingReturnValue
} else {
var returnValue: Result<Void, KnockRequestProxyError>? = nil
DispatchQueue.main.sync {
returnValue = banUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
banUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
banUnderlyingReturnValue = newValue
}
}
}
}
var banClosure: (() async -> Result<Void, KnockRequestProxyError>)?
func ban() async -> Result<Void, KnockRequestProxyError> {
banCallsCount += 1
if let banClosure = banClosure {
return await banClosure()
} else {
return banReturnValue
}
}
//MARK: - markAsSeen
var markAsSeenUnderlyingCallsCount = 0
var markAsSeenCallsCount: Int {
get {
if Thread.isMainThread {
return markAsSeenUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = markAsSeenUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
markAsSeenUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
markAsSeenUnderlyingCallsCount = newValue
}
}
}
}
var markAsSeenCalled: Bool {
return markAsSeenCallsCount > 0
}
var markAsSeenUnderlyingReturnValue: Result<Void, KnockRequestProxyError>!
var markAsSeenReturnValue: Result<Void, KnockRequestProxyError>! {
get {
if Thread.isMainThread {
return markAsSeenUnderlyingReturnValue
} else {
var returnValue: Result<Void, KnockRequestProxyError>? = nil
DispatchQueue.main.sync {
returnValue = markAsSeenUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
markAsSeenUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
markAsSeenUnderlyingReturnValue = newValue
}
}
}
}
var markAsSeenClosure: (() async -> Result<Void, KnockRequestProxyError>)?
func markAsSeen() async -> Result<Void, KnockRequestProxyError> {
markAsSeenCallsCount += 1
if let markAsSeenClosure = markAsSeenClosure {
return await markAsSeenClosure()
} else {
return markAsSeenReturnValue
}
}
}
class KnockedRoomProxyMock: KnockedRoomProxyProtocol {
var info: BaseRoomInfoProxyProtocol {
get { return underlyingInfo }
@@ -7938,6 +7938,189 @@ open class InReplyToDetailsSDKMock: MatrixRustSDK.InReplyToDetails {
}
}
}
open class KnockRequestActionsSDKMock: MatrixRustSDK.KnockRequestActions {
init() {
super.init(noPointer: .init())
}
public required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) {
fatalError("init(unsafeFromRawPointer:) has not been implemented")
}
fileprivate var pointer: UnsafeMutableRawPointer!
//MARK: - accept
open var acceptThrowableError: Error?
var acceptUnderlyingCallsCount = 0
open var acceptCallsCount: Int {
get {
if Thread.isMainThread {
return acceptUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = acceptUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
acceptUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
acceptUnderlyingCallsCount = newValue
}
}
}
}
open var acceptCalled: Bool {
return acceptCallsCount > 0
}
open var acceptClosure: (() async throws -> Void)?
open override func accept() async throws {
if let error = acceptThrowableError {
throw error
}
acceptCallsCount += 1
try await acceptClosure?()
}
//MARK: - decline
open var declineReasonThrowableError: Error?
var declineReasonUnderlyingCallsCount = 0
open var declineReasonCallsCount: Int {
get {
if Thread.isMainThread {
return declineReasonUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = declineReasonUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
declineReasonUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
declineReasonUnderlyingCallsCount = newValue
}
}
}
}
open var declineReasonCalled: Bool {
return declineReasonCallsCount > 0
}
open var declineReasonReceivedReason: String?
open var declineReasonReceivedInvocations: [String?] = []
open var declineReasonClosure: ((String?) async throws -> Void)?
open override func decline(reason: String?) async throws {
if let error = declineReasonThrowableError {
throw error
}
declineReasonCallsCount += 1
declineReasonReceivedReason = reason
DispatchQueue.main.async {
self.declineReasonReceivedInvocations.append(reason)
}
try await declineReasonClosure?(reason)
}
//MARK: - declineAndBan
open var declineAndBanReasonThrowableError: Error?
var declineAndBanReasonUnderlyingCallsCount = 0
open var declineAndBanReasonCallsCount: Int {
get {
if Thread.isMainThread {
return declineAndBanReasonUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = declineAndBanReasonUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
declineAndBanReasonUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
declineAndBanReasonUnderlyingCallsCount = newValue
}
}
}
}
open var declineAndBanReasonCalled: Bool {
return declineAndBanReasonCallsCount > 0
}
open var declineAndBanReasonReceivedReason: String?
open var declineAndBanReasonReceivedInvocations: [String?] = []
open var declineAndBanReasonClosure: ((String?) async throws -> Void)?
open override func declineAndBan(reason: String?) async throws {
if let error = declineAndBanReasonThrowableError {
throw error
}
declineAndBanReasonCallsCount += 1
declineAndBanReasonReceivedReason = reason
DispatchQueue.main.async {
self.declineAndBanReasonReceivedInvocations.append(reason)
}
try await declineAndBanReasonClosure?(reason)
}
//MARK: - markAsSeen
open var markAsSeenThrowableError: Error?
var markAsSeenUnderlyingCallsCount = 0
open var markAsSeenCallsCount: Int {
get {
if Thread.isMainThread {
return markAsSeenUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = markAsSeenUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
markAsSeenUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
markAsSeenUnderlyingCallsCount = newValue
}
}
}
}
open var markAsSeenCalled: Bool {
return markAsSeenCallsCount > 0
}
open var markAsSeenClosure: (() async throws -> Void)?
open override func markAsSeen() async throws {
if let error = markAsSeenThrowableError {
throw error
}
markAsSeenCallsCount += 1
try await markAsSeenClosure?()
}
}
open class LazyTimelineItemProviderSDKMock: MatrixRustSDK.LazyTimelineItemProvider {
init() {
super.init(noPointer: .init())
@@ -14018,6 +14201,81 @@ open class RoomSDKMock: MatrixRustSDK.Room {
}
}
//MARK: - subscribeToKnockRequests
open var subscribeToKnockRequestsListenerThrowableError: Error?
var subscribeToKnockRequestsListenerUnderlyingCallsCount = 0
open var subscribeToKnockRequestsListenerCallsCount: Int {
get {
if Thread.isMainThread {
return subscribeToKnockRequestsListenerUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = subscribeToKnockRequestsListenerUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
subscribeToKnockRequestsListenerUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
subscribeToKnockRequestsListenerUnderlyingCallsCount = newValue
}
}
}
}
open var subscribeToKnockRequestsListenerCalled: Bool {
return subscribeToKnockRequestsListenerCallsCount > 0
}
open var subscribeToKnockRequestsListenerReceivedListener: KnockRequestsListener?
open var subscribeToKnockRequestsListenerReceivedInvocations: [KnockRequestsListener] = []
var subscribeToKnockRequestsListenerUnderlyingReturnValue: TaskHandle!
open var subscribeToKnockRequestsListenerReturnValue: TaskHandle! {
get {
if Thread.isMainThread {
return subscribeToKnockRequestsListenerUnderlyingReturnValue
} else {
var returnValue: TaskHandle? = nil
DispatchQueue.main.sync {
returnValue = subscribeToKnockRequestsListenerUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
subscribeToKnockRequestsListenerUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
subscribeToKnockRequestsListenerUnderlyingReturnValue = newValue
}
}
}
}
open var subscribeToKnockRequestsListenerClosure: ((KnockRequestsListener) async throws -> TaskHandle)?
open override func subscribeToKnockRequests(listener: KnockRequestsListener) async throws -> TaskHandle {
if let error = subscribeToKnockRequestsListenerThrowableError {
throw error
}
subscribeToKnockRequestsListenerCallsCount += 1
subscribeToKnockRequestsListenerReceivedListener = listener
DispatchQueue.main.async {
self.subscribeToKnockRequestsListenerReceivedInvocations.append(listener)
}
if let subscribeToKnockRequestsListenerClosure = subscribeToKnockRequestsListenerClosure {
return try await subscribeToKnockRequestsListenerClosure(listener)
} else {
return subscribeToKnockRequestsListenerReturnValue
}
}
//MARK: - subscribeToRoomInfoUpdates
var subscribeToRoomInfoUpdatesListenerUnderlyingCallsCount = 0
@@ -30,6 +30,7 @@ struct JoinedRoomProxyMockConfiguration {
var timelineStartReached = false
var members: [RoomMemberProxyMock] = .allMembers
var knockRequestsState: KnockRequestsState = .loaded([])
var ownUserID = RoomMemberProxyMock.mockMe.userID
var inviter: RoomMemberProxyProtocol?
@@ -57,6 +58,7 @@ extension JoinedRoomProxyMock {
infoPublisher = CurrentValueSubject(.init(roomInfo: .init(configuration))).asCurrentValuePublisher()
membersPublisher = CurrentValueSubject(configuration.members).asCurrentValuePublisher()
knockRequestsStatePublisher = CurrentValueSubject(configuration.knockRequestsState).asCurrentValuePublisher()
typingMembersPublisher = CurrentValueSubject([]).asCurrentValuePublisher()
identityStatusChangesPublisher = CurrentValueSubject([]).asCurrentValuePublisher()
@@ -0,0 +1,35 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
struct KnockRequestProxyMockConfiguration {
let eventID: String
let userID: String
var displayName: String?
var avatarURL: URL?
var timestamp: String?
var reason: String?
var isSeen = false
}
extension KnockRequestProxyMock {
convenience init(_ configuration: KnockRequestProxyMockConfiguration) {
self.init()
eventID = configuration.eventID
userID = configuration.userID
displayName = configuration.displayName
avatarURL = configuration.avatarURL
reason = configuration.reason
formattedTimestamp = configuration.timestamp
isSeen = configuration.isSeen
acceptReturnValue = .success(())
declineReturnValue = .success(())
banReturnValue = .success(())
markAsSeenReturnValue = .success(())
}
}
@@ -71,7 +71,7 @@ extension Array where Element == RoomSummary {
static let mockRooms: [Element] = [
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "1",
joinRequestType: nil,
knockRequestType: nil,
name: "Foundation 🔭🪐🌌",
isDirect: false,
avatarURL: nil,
@@ -88,7 +88,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "2",
joinRequestType: nil,
knockRequestType: nil,
name: "Foundation and Empire",
isDirect: false,
avatarURL: .mockMXCAvatar,
@@ -105,7 +105,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "3",
joinRequestType: nil,
knockRequestType: nil,
name: "Second Foundation",
isDirect: false,
avatarURL: nil,
@@ -122,7 +122,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "4",
joinRequestType: nil,
knockRequestType: nil,
name: "Foundation's Edge",
isDirect: false,
avatarURL: nil,
@@ -139,7 +139,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "5",
joinRequestType: nil,
knockRequestType: nil,
name: "Foundation and Earth",
isDirect: true,
avatarURL: nil,
@@ -156,7 +156,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "6",
joinRequestType: nil,
knockRequestType: nil,
name: "Prelude to Foundation",
isDirect: true,
avatarURL: nil,
@@ -173,7 +173,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "0",
joinRequestType: nil,
knockRequestType: nil,
name: "Unknown",
isDirect: false,
avatarURL: nil,
@@ -223,7 +223,7 @@ extension Array where Element == RoomSummary {
static let mockInvites: [Element] = [
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "someAwesomeRoomId1",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
knockRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
name: "First room",
isDirect: false,
avatarURL: .mockMXCAvatar,
@@ -240,7 +240,7 @@ extension Array where Element == RoomSummary {
isFavourite: false),
RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "someAwesomeRoomId2",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
knockRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
name: "Second room",
isDirect: true,
avatarURL: nil,
@@ -0,0 +1,17 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
extension Result {
var isFailure: Bool {
switch self {
case .success:
return false
case .failure:
return true
}
}
}
@@ -195,7 +195,6 @@ struct CreateRoomScreen: View {
Text("#")
.font(.compound.bodyLG)
.foregroundStyle(.compound.textSecondary)
TextField("", text: aliasBinding)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -219,13 +219,13 @@ extension HomeScreenRoom {
let hasUnreadMessages = hideUnreadMessagesBadge ? false : summary.hasUnreadMessages
let isDotShown = hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || summary.joinRequestType?.isKnock == true
let isDotShown = hasUnreadMessages || summary.hasUnreadMentions || summary.hasUnreadNotifications || summary.isMarkedUnread || summary.knockRequestType?.isKnock == true
let isMentionShown = summary.hasUnreadMentions && !summary.isMuted
let isMuteShown = summary.isMuted
let isCallShown = summary.hasOngoingCall
let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || summary.joinRequestType?.isKnock == true
let isHighlighted = summary.isMarkedUnread || (!summary.isMuted && (summary.hasUnreadNotifications || summary.hasUnreadMentions)) || summary.knockRequestType?.isKnock == true
let type: HomeScreenRoom.RoomType = switch summary.joinRequestType {
let type: HomeScreenRoom.RoomType = switch summary.knockRequestType {
case .invite(let inviter): .invite(inviterDetails: inviter.map(RoomInviterDetails.init))
case .knock: .knock
case .none: .room
@@ -178,7 +178,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter),
knockRequestType: .invite(inviter: inviter),
name: "Some Guy",
isDirect: true,
avatarURL: nil,
@@ -205,7 +205,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter),
knockRequestType: .invite(inviter: inviter),
name: "Awesome Room",
isDirect: false,
avatarURL: avatarURL,
@@ -152,7 +152,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter),
knockRequestType: .invite(inviter: inviter),
name: "Some Guy",
isDirect: true,
avatarURL: nil,
@@ -179,7 +179,7 @@ private extension HomeScreenRoom {
let summary = RoomSummary(roomListItem: RoomListItemSDKMock(),
id: "@someone:somewhere.com",
joinRequestType: .invite(inviter: inviter),
knockRequestType: .invite(inviter: inviter),
name: "Awesome Room",
isDirect: false,
avatarURL: avatarURL,
@@ -13,6 +13,7 @@ import SwiftUI
struct KnockRequestsListScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
let mediaProvider: MediaProviderProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum KnockRequestsListScreenCoordinatorAction { }
@@ -29,7 +30,8 @@ final class KnockRequestsListScreenCoordinator: CoordinatorProtocol {
init(parameters: KnockRequestsListScreenCoordinatorParameters) {
viewModel = KnockRequestsListScreenViewModel(roomProxy: parameters.roomProxy,
mediaProvider: parameters.mediaProvider)
mediaProvider: parameters.mediaProvider,
userIndicatorController: parameters.userIndicatorController)
}
func start() { }
@@ -10,18 +10,43 @@ import Foundation
enum KnockRequestsListScreenViewModelAction { }
struct KnockRequestsListScreenViewState: BindableState {
// TODO: Not sure yet how we will fetch this, this is just for testing purposes
var requests: [KnockRequestCellInfo] = [.init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello")]
var requestsState: KnockRequestsListState = .loading
var displayedRequests: [KnockRequestCellInfo] {
guard case let .loaded(requests) = requestsState else {
return []
}
return requests.filter { !handledEventIDs.contains($0.id) }
}
var isLoading: Bool {
switch requestsState {
case .loading:
true
default:
false
}
}
// If you are in this view one of these must have been true so by default we assume all of them to be true
var canAccept = true
var canDecline = true
var canBan = true
var isKnockableRoom = true
var handledEventIDs: Set<String> = []
// If all the permissions are denied or the join rule changes while we are in the view
// we want to stop displaying any request
var shouldDisplayRequests: Bool {
!requests.isEmpty && isKnockableRoom && (canAccept || canDecline || canBan)
!displayedRequests.isEmpty && isKnockableRoom && (canAccept || canDecline || canBan)
}
var shouldDisplayAcceptAllButton: Bool {
!isLoading && shouldDisplayRequests && displayedRequests.count > 1
}
var shouldDisplayEmptyView: Bool {
!isLoading && !shouldDisplayRequests
}
var bindings = KnockRequestsListStateBindings()
@@ -35,11 +60,39 @@ enum KnockRequestsListAlertType {
case acceptAllRequests
case declineRequest
case declineAndBan
case acceptAllFailed
case acceptFailed
case declineFailed
}
enum KnockRequestsListScreenViewAction {
case acceptAllRequests
case acceptRequest(userID: String)
case declineRequest(userID: String)
case ban(userID: String)
case acceptRequest(eventID: String)
case declineRequest(eventID: String)
case ban(eventID: String)
}
enum KnockRequestsListState: Equatable {
case loading
case loaded([KnockRequestCellInfo])
init(from state: KnockRequestsState) {
switch state {
case .loading:
self = .loading
case .loaded(let requests):
self = .loaded(requests.map(KnockRequestCellInfo.init))
}
}
}
private extension KnockRequestCellInfo {
init(from proxy: KnockRequestProxyProtocol) {
self.init(eventID: proxy.eventID,
userID: proxy.userID,
displayName: proxy.displayName,
avatarURL: proxy.avatarURL,
timestamp: proxy.formattedTimestamp,
reason: proxy.reason)
}
}
@@ -12,14 +12,18 @@ typealias KnockRequestsListScreenViewModelType = StateStoreViewModel<KnockReques
class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, KnockRequestsListScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<KnockRequestsListScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<KnockRequestsListScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: JoinedRoomProxyProtocol, mediaProvider: MediaProviderProtocol) {
init(roomProxy: JoinedRoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: KnockRequestsListScreenViewState(), mediaProvider: mediaProvider)
updateRoomInfo(roomInfo: roomProxy.infoPublisher.value)
@@ -39,35 +43,147 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
title: L10n.screenKnockRequestsListAcceptAllAlertTitle,
message: L10n.screenKnockRequestsListAcceptAllAlertDescription,
primaryButton: .init(title: L10n.screenKnockRequestsListAcceptAllAlertConfirmButtonTitle,
// TODO: Implement action
action: nil),
action: acceptAll),
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .acceptRequest(let userID):
// TODO: Implement
break
case .declineRequest(let userID):
case .acceptRequest(let eventID):
guard let request = getRequest(eventID: eventID) else {
return
}
accept(request: request)
case .declineRequest(let eventID):
guard let request = getRequest(eventID: eventID) else {
return
}
state.bindings.alertInfo = .init(id: .declineRequest,
title: L10n.screenKnockRequestsListDeclineAlertTitle,
message: L10n.screenKnockRequestsListDeclineAlertDescription(userID),
message: L10n.screenKnockRequestsListDeclineAlertDescription(request.userID),
primaryButton: .init(title: L10n.screenKnockRequestsListDeclineAlertConfirmButtonTitle,
role: .destructive,
// TODO: Implement action
action: nil),
role: .destructive) { [weak self] in self?.decline(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
case .ban(let userID):
case .ban(let eventID):
guard let request = getRequest(eventID: eventID) else {
return
}
state.bindings.alertInfo = .init(id: .declineAndBan,
title: L10n.screenKnockRequestsListBanAlertTitle,
message: L10n.screenKnockRequestsListBanAlertDescription(userID),
// TODO: Implement action
primaryButton: .init(title: L10n.screenKnockRequestsListBanAlertConfirmButtonTitle,
role: .destructive,
action: nil),
role: .destructive) { [weak self] in self?.declineAndBan(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
// MARK: - Private
private func getRequest(eventID: String) -> KnockRequestProxyProtocol? {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value,
let request = requests.first(where: { $0.eventID == eventID }) else {
return nil
}
return request
}
private func accept(request: KnockRequestProxyProtocol) {
showLoadingIndicator(title: L10n.screenKnockRequestsListAcceptLoadingTitle)
let eventID = request.eventID
state.handledEventIDs.insert(eventID)
Task {
switch await request.accept() {
case .success:
hideLoadingIndicator()
case .failure:
hideLoadingIndicator()
state.handledEventIDs.remove(eventID)
state.bindings.alertInfo = .init(id: .acceptFailed,
title: L10n.screenKnockRequestsListAcceptFailedAlertTitle,
message: L10n.screenKnockRequestsListAcceptFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.accept(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func decline(request: KnockRequestProxyProtocol) {
showLoadingIndicator(title: L10n.screenKnockRequestsListDeclineLoadingTitle)
let eventID = request.eventID
state.handledEventIDs.insert(eventID)
Task {
switch await request.decline() {
case .success:
hideLoadingIndicator()
case .failure:
hideLoadingIndicator()
state.handledEventIDs.remove(eventID)
state.bindings.alertInfo = .init(id: .declineFailed,
title: L10n.screenKnockRequestsListDeclineFailedAlertTitle,
message: L10n.screenKnockRequestsListDeclineFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.decline(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func declineAndBan(request: KnockRequestProxyProtocol) {
showLoadingIndicator(title: L10n.screenKnockRequestsListBanLoadingTitle)
let eventID = request.eventID
state.handledEventIDs.insert(eventID)
Task {
switch await request.ban() {
case .success:
hideLoadingIndicator()
case .failure:
hideLoadingIndicator()
state.handledEventIDs.remove(eventID)
state.bindings.alertInfo = .init(id: .declineFailed,
title: L10n.screenKnockRequestsListDeclineFailedAlertTitle,
message: L10n.screenKnockRequestsListDeclineFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.declineAndBan(request: request) },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func acceptAll() {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value else {
return
}
showLoadingIndicator(title: L10n.screenKnockRequestsListAcceptAllLoadingTitle)
state.handledEventIDs.formUnion(Set(requests.map(\.eventID)))
Task {
let failedIDs = await withTaskGroup(of: (String, Result<Void, KnockRequestProxyError>).self) { group in
for request in requests {
group.addTask {
await (request.eventID, request.accept())
}
}
var failedIDs = [String]()
for await result in group where result.1.isFailure {
failedIDs.append(result.0)
}
return failedIDs
}
hideLoadingIndicator()
if !failedIDs.isEmpty {
state.handledEventIDs.subtract(failedIDs)
state.bindings.alertInfo = .init(id: .acceptAllFailed,
title: L10n.screenKnockRequestsListAcceptAllFailedAlertTitle,
message: L10n.screenKnockRequestsListAcceptAllFailedAlertDescription,
primaryButton: .init(title: L10n.actionYesTryAgain) { [weak self] in self?.acceptAll() },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
}
}
private func setupSubscriptions() {
roomProxy.infoPublisher
.receive(on: DispatchQueue.main)
@@ -76,6 +192,26 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
Task { await self?.updatePermissions() }
}
.store(in: &cancellables)
roomProxy.knockRequestsStatePublisher
.map(KnockRequestsListState.init)
.removeDuplicates()
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.requestsState, on: self)
.store(in: &cancellables)
context.$viewState
.map(\.isLoading)
.removeDuplicates()
.sink { [weak self] isLoading in
guard let self else { return }
if isLoading {
showInitialLoadingIndicator()
} else {
hideLoadingIndicator()
}
}
.store(in: &cancellables)
}
private func updateRoomInfo(roomInfo: RoomInfoProxy) {
@@ -93,15 +229,29 @@ class KnockRequestsListScreenViewModel: KnockRequestsListScreenViewModelType, Kn
state.canBan = await (try? roomProxy.canUserBan(userID: roomProxy.ownUserID).get()) == true
}
// For testing purposes
private init(initialViewState: KnockRequestsListScreenViewState) {
roomProxy = JoinedRoomProxyMock(.init())
super.init(initialViewState: initialViewState)
}
}
extension KnockRequestsListScreenViewModel {
static func mockWithInitialState(_ initialViewState: KnockRequestsListScreenViewState) -> KnockRequestsListScreenViewModel {
.init(initialViewState: initialViewState)
private static let loadingIndicatorIdentifier = "\(KnockRequestsListScreenViewModel.self)-Loading"
private func showInitialLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate,
interactiveDismissDisabled: false,
allowsInteraction: true),
title: L10n.screenKnockRequestsListInitialLoadingTitle,
persistent: true),
delay: .milliseconds(100))
}
private func showLoadingIndicator(title: String) {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal(progress: .indeterminate,
interactiveDismissDisabled: false,
allowsInteraction: false),
title: title,
persistent: true),
delay: .milliseconds(200))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
@@ -15,9 +15,9 @@
import Compound
import SwiftUI
struct KnockRequestCellInfo: Identifiable {
/// user identifier of the usee that sent the request
let id: String
struct KnockRequestCellInfo: Equatable {
let eventID: String
let userID: String
let displayName: String?
let avatarURL: URL?
let timestamp: String?
@@ -35,7 +35,7 @@ struct KnockRequestCell: View {
HStack(alignment: .top, spacing: 16) {
LoadableAvatarImage(url: cellInfo.avatarURL,
name: cellInfo.displayName,
contentID: cellInfo.id,
contentID: cellInfo.userID,
avatarSize: .user(on: .knockingUserList),
mediaProvider: mediaProvider)
VStack(alignment: .leading, spacing: 12) {
@@ -60,7 +60,7 @@ struct KnockRequestCell: View {
private var header: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Text(cellInfo.displayName ?? cellInfo.id)
Text(cellInfo.displayName ?? cellInfo.userID)
.font(.compound.bodyLGSemibold)
.foregroundStyle(.compound.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -71,7 +71,7 @@ struct KnockRequestCell: View {
}
}
if cellInfo.displayName != nil {
Text(cellInfo.id)
Text(cellInfo.userID)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
}
@@ -85,14 +85,14 @@ struct KnockRequestCell: View {
HStack(spacing: 16) {
if let onDecline {
Button(L10n.actionDecline) {
onDecline(cellInfo.id)
onDecline(cellInfo.eventID)
}
.buttonStyle(.compound(.secondary, size: .medium))
}
if let onAccept {
Button(L10n.actionAccept) {
onAccept(cellInfo.id)
onAccept(cellInfo.eventID)
}
.buttonStyle(.compound(.primary, size: .medium))
}
@@ -101,7 +101,7 @@ struct KnockRequestCell: View {
if let onDeclineAndBan {
Button(role: .destructive) {
onDeclineAndBan(cellInfo.id)
onDeclineAndBan(cellInfo.eventID)
} label: {
Text(L10n.screenKnockRequestsListDeclineAndBanActionTitle)
.padding(.top, 8)
@@ -166,15 +166,19 @@ private struct DisclosableText: View {
}
}
extension KnockRequestCellInfo: Identifiable {
var id: String { eventID }
}
struct KnockRequestCell_Previews: PreviewProvider, TestablePreview {
// swiftlint:disable:next line_length
static let aliceWithLongReason = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello would like to join this room, also this is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason")
static let aliceWithLongReason = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello would like to join this room, also this is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason")
static let aliceWithShortReason = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello, I am Alice and would like to join this room, please")
static let aliceWithShortReason = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: "Hello, I am Alice and would like to join this room, please")
static let aliceWithNoReason = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static let aliceWithNoReason = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static let aliceWithNoName = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: nil, avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static let aliceWithNoName = KnockRequestCellInfo(eventID: "1", userID: "@alice:matrix.org", displayName: nil, avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
static var previews: some View {
KnockRequestCell(cellInfo: aliceWithLongReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in }
@@ -17,12 +17,12 @@ struct KnockRequestsListScreen: View {
.navigationTitle(L10n.screenKnockRequestsListTitle)
.background(.compound.bgCanvasDefault)
.overlay {
if !context.viewState.shouldDisplayRequests {
if context.viewState.shouldDisplayEmptyView {
KnockRequestsListEmptyStateView()
}
}
.safeAreaInset(edge: .bottom) {
if context.viewState.shouldDisplayRequests {
if context.viewState.shouldDisplayAcceptAllButton {
acceptAllButton
}
}
@@ -31,10 +31,18 @@ struct KnockRequestsListScreen: View {
@ViewBuilder
private var mainContent: some View {
if context.viewState.isLoading {
EmptyView()
} else {
list
}
}
private var list: some View {
ScrollView {
LazyVStack(spacing: 0) {
if context.viewState.shouldDisplayRequests {
ForEach(context.viewState.requests) { requestInfo in
ForEach(context.viewState.displayedRequests) { requestInfo in
ListRow(kind: .custom {
KnockRequestCell(cellInfo: requestInfo,
mediaProvider: context.mediaProvider,
@@ -60,37 +68,66 @@ struct KnockRequestsListScreen: View {
.background(.compound.bgCanvasDefault)
}
private func onAccept(userID: String) {
context.send(viewAction: .acceptRequest(userID: userID))
private func onAccept(eventID: String) {
context.send(viewAction: .acceptRequest(eventID: eventID))
}
private func onDecline(userID: String) {
context.send(viewAction: .declineRequest(userID: userID))
private func onDecline(eventID: String) {
context.send(viewAction: .declineRequest(eventID: eventID))
}
private func onDeclineAndBan(userID: String) {
context.send(viewAction: .ban(userID: userID))
private func onDeclineAndBan(eventID: String) {
context.send(viewAction: .ban(eventID: eventID))
}
}
// MARK: - Previews
struct KnockRequestsListScreen_Previews: PreviewProvider, TestablePreview {
static let emptyViewModel = KnockRequestsListScreenViewModel.mockWithInitialState(.init(requests: []))
static let loadingViewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loading)
static let emptyViewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loaded([]))
static let singleRequestViewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello"))]))
static let viewModel = KnockRequestsListScreenViewModel.mockWithRequestsState(.loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello")),
// swiftlint:disable:next line_length
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org", displayName: "Bob", avatarURL: nil, timestamp: "Now", reason: "Hello this one is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason")),
KnockRequestProxyMock(.init(eventID: "3", userID: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil, timestamp: "Now", reason: nil)),
KnockRequestProxyMock(.init(eventID: "4", userID: "@dan:matrix.org", displayName: "Dan", avatarURL: nil, timestamp: "Now", reason: "Hello! It's a me! Dan!"))]))
static let viewModel = KnockRequestsListScreenViewModel.mockWithInitialState(.init(requests: [.init(id: "@alice:matrix.org", displayName: "Alice", avatarURL: nil, timestamp: "Now", reason: "Hello"),
// swiftlint:disable:next line_length
.init(id: "@bob:matrix.org", displayName: "Bob", avatarURL: nil, timestamp: "Now", reason: "Hello this one is a very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long reason"),
.init(id: "@charlie:matrix.org", displayName: "Charlie", avatarURL: nil, timestamp: "Now", reason: nil),
.init(id: "@dan:matrix.org", displayName: "Dan", avatarURL: nil, timestamp: "Now", reason: "Hello! It's a me! Dan!")]))
static var previews: some View {
NavigationStack {
KnockRequestsListScreen(context: viewModel.context)
}
.snapshotPreferences(delay: 0.2)
NavigationStack {
KnockRequestsListScreen(context: singleRequestViewModel.context)
}
.previewDisplayName("Single Request")
.snapshotPreferences(delay: 0.2)
NavigationStack {
KnockRequestsListScreen(context: emptyViewModel.context)
}
.previewDisplayName("Empty state")
.snapshotPreferences(delay: 0.2)
NavigationStack {
KnockRequestsListScreen(context: loadingViewModel.context)
}
.previewDisplayName("Loading state")
}
}
extension KnockRequestsListScreenViewModel {
static func mockWithRequestsState(_ requestsState: KnockRequestsState) -> KnockRequestsListScreenViewModel {
.init(roomProxy: JoinedRoomProxyMock(.init(members: [.mockAdmin],
knockRequestsState: requestsState,
ownUserID: RoomMemberProxyMock.mockAdmin.userID,
joinRule: .knock)),
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
}
}
@@ -53,6 +53,8 @@ struct RoomDetailsScreenViewState: BindableState {
var knockingEnabled = false
var isKnockableRoom = false
var knockRequestsCount = 0
var canSeeKnockingRequests: Bool {
knockingEnabled && dmRecipient == nil && isKnockableRoom && (canInviteUsers || canKickUsers || canBanUsers)
}
@@ -187,6 +187,18 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
Task { await self?.updatePowerLevelPermissions() }
}
.store(in: &cancellables)
roomProxy.knockRequestsStatePublisher
.map { requestsState in
guard case let .loaded(requests) = requestsState else {
return 0
}
return requests.count
}
.removeDuplicates()
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.knockRequestsCount, on: self)
.store(in: &cancellables)
}
private func updateRoomInfo(_ roomInfo: RoomInfoProxy) {
@@ -166,8 +166,7 @@ struct RoomDetailsScreen: View {
if context.viewState.canSeeKnockingRequests {
ListRow(label: .default(title: L10n.screenRoomDetailsRequestsToJoinTitle,
icon: \.askToJoin),
// TODO: Display count if requests > 0 when an API for them is available
details: .counter(1),
details: context.viewState.knockRequestsCount > 0 ? .counter(context.viewState.knockRequestsCount) : nil,
kind: .navigationLink {
context.send(viewAction: .processTapRequestsToJoin)
})
@@ -324,6 +323,7 @@ struct RoomDetailsScreen: View {
struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
static let genericRoomViewModel = {
ServiceLocator.shared.settings.knockingEnabled = true
let knockRequests: [KnockRequestProxyMock] = [.init()]
let members: [RoomMemberProxyMock] = [
.mockMeAdmin,
.mockAlice,
@@ -344,6 +344,7 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
isEncrypted: true,
canonicalAlias: "#alias:domain.com",
members: members,
knockRequestsState: .loaded(knockRequests),
joinRule: .knock))
var notificationSettingsProxyMockConfiguration = NotificationSettingsProxyMockConfiguration()
@@ -388,6 +389,7 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
}()
static let simpleRoomViewModel = {
let knockRequests: [KnockRequestProxyMock] = [.init()]
ServiceLocator.shared.settings.knockingEnabled = true
let members: [RoomMemberProxyMock] = [
.mockMeAdmin,
@@ -400,6 +402,7 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
isDirect: false,
isEncrypted: false,
members: members,
knockRequestsState: .loaded(knockRequests),
joinRule: .knock))
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
@@ -8,7 +8,7 @@
import Foundation
import OrderedCollections
enum RoomScreenViewModelAction {
enum RoomScreenViewModelAction: Equatable {
case focusEvent(eventID: String)
case displayPinnedEventsTimeline
case displayRoomDetails
@@ -23,7 +23,7 @@ enum RoomScreenViewAction {
case displayRoomDetails
case displayCall
case footerViewAction(RoomScreenFooterViewAction)
case acceptKnock(userID: String)
case acceptKnock(eventID: String)
case dismissKnockRequests
case viewKnockRequests
}
@@ -48,11 +48,18 @@ struct RoomScreenViewState: BindableState {
var canAcceptKnocks = false
var canDeclineKnocks = false
var canBan = false
// TODO: We still don't know how to get these, but these will be the non already seen knock requests of the room, for now we are using this as a mock for testing purposes
var unseenKnockRequests: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Helloooo")]
var unseenKnockRequests: [KnockRequestInfo] = []
var handledEventIDs: Set<String> = []
var displayedKnockRequests: [KnockRequestInfo] {
unseenKnockRequests.filter { !handledEventIDs.contains($0.eventID) }
}
var shouldSeeKnockRequests: Bool {
isKnockingEnabled && isKnockableRoom && !unseenKnockRequests.isEmpty && (canAcceptKnocks || canDeclineKnocks || canBan)
isKnockingEnabled &&
isKnockableRoom &&
!displayedKnockRequests.isEmpty &&
(canAcceptKnocks || canDeclineKnocks || canBan)
}
var footerDetails: RoomScreenFooterViewDetails?
@@ -103,12 +103,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .resolvePinViolation(let userID):
Task { await resolveIdentityPinningViolation(userID) }
}
case .acceptKnock(userID: let userID):
// TODO: API to accept a knock required
break
case .acceptKnock(let eventID):
Task { await acceptKnock(eventID: eventID) }
case .dismissKnockRequests:
// TODO: API to mark knocks as seen required
break
Task { await markAllKnocksAsSeen() }
case .viewKnockRequests:
actionsSubject.send(.displayKnockRequests)
}
@@ -181,6 +179,23 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.shouldShowCallButton = ongoingCallRoomID != roomProxy.id
}
.store(in: &cancellables)
roomProxy.knockRequestsStatePublisher
// We only care about unseen requests
.map { knockRequestsState in
guard case let .loaded(requests) = knockRequestsState else {
return []
}
return requests
.filter { !$0.isSeen }
.map(KnockRequestInfo.init)
}
// If the requests have the same event ids we can discard the output
.removeDuplicates { Set($0.map(\.eventID)) == Set($1.map(\.eventID)) }
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.weakAssign(to: \.state.unseenKnockRequests, on: self)
.store(in: &cancellables)
}
private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async {
@@ -277,10 +292,49 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
}
private func acceptKnock(eventID: String) async {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value,
let request = requests.first(where: { $0.eventID == eventID }) else {
return
}
state.handledEventIDs.insert(eventID)
switch await request.accept() {
case .success:
break
case .failure:
userIndicatorController.submitIndicator(.init(id: Self.errorIndicatorIdentifier, type: .toast, title: L10n.errorUnknown))
state.handledEventIDs.remove(eventID)
}
}
private func markAllKnocksAsSeen() async {
guard case let .loaded(requests) = roomProxy.knockRequestsStatePublisher.value else {
return
}
state.handledEventIDs.formUnion(Set(requests.map(\.eventID)))
let failedIDs = await withTaskGroup(of: (String, Result<Void, KnockRequestProxyError>).self) { group in
for request in requests {
group.addTask {
await (request.eventID, request.markAsSeen())
}
}
var failedIDs = [String]()
for await result in group where result.1.isFailure {
failedIDs.append(result.0)
}
return failedIDs
}
state.handledEventIDs.subtract(failedIDs)
}
// MARK: Loading indicators
private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading"
private static let errorIndicatorIdentifier = "\(RoomScreenViewModel.self)-Error"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier, type: .toast, title: L10n.commonLoading))
@@ -304,3 +358,13 @@ extension RoomScreenViewModel {
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
private extension KnockRequestInfo {
init(from proxy: KnockRequestProxyProtocol) {
self.init(displayName: proxy.displayName,
avatarURL: proxy.avatarURL,
userID: proxy.userID,
reason: proxy.reason,
eventID: proxy.eventID)
}
}
@@ -8,11 +8,12 @@
import Compound
import SwiftUI
struct KnockRequestInfo {
struct KnockRequestInfo: Equatable {
let displayName: String?
let avatarURL: URL?
let userID: String
let reason: String?
let eventID: String
}
struct KnockRequestsBannerView: View {
@@ -102,10 +103,8 @@ private struct SingleKnockRequestBannerContent: View {
Button(L10n.screenRoomSingleKnockRequestViewButtonTitle, action: onViewAll)
.buttonStyle(.compound(.secondary, size: .medium))
if let onAccept {
Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle) {
onAccept(request.userID)
}
.buttonStyle(.compound(.primary, size: .medium))
Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle) { onAccept(request.eventID) }
.buttonStyle(.compound(.primary, size: .medium))
}
}
.padding(.top, request.reason == nil ? 0 : 2)
@@ -123,7 +122,6 @@ private struct MultipleKnockRequestsBannerContent: View {
requests
.prefix(3)
.map { .init(url: $0.avatarURL, name: $0.displayName, contentID: $0.userID) }
.reversed()
}
private var multipleKnockRequestsTitle: String {
@@ -138,7 +136,7 @@ private struct MultipleKnockRequestsBannerContent: View {
var body: some View {
VStack(spacing: 14) {
HStack(spacing: 10) {
StackedAvatarsView(overlap: 16, lineWidth: 2, shouldStackFromLast: true, avatars: avatars, avatarSize: .user(on: .knockingUsersBannerStack), mediaProvider: mediaProvider)
StackedAvatarsView(overlap: 16, lineWidth: 2, avatars: avatars, avatarSize: .user(on: .knockingUsersBannerStack), mediaProvider: mediaProvider)
HStack(alignment: .top, spacing: 0) {
Text(multipleKnockRequestsTitle)
.lineLimit(2)
@@ -173,18 +171,22 @@ private struct KnockRequestsBannerDismissButton: View {
}
struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview {
static let singleRequest: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil)]
static let singleRequest: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil, eventID: "1")]
static let singleRequestWithReason: [KnockRequestInfo] = [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Hey, I’d like to join this room because of xyz topic and I’d like to participate in the room.")]
static let singleRequestWithReason: [KnockRequestInfo] = [.init(displayName: "Alice",
avatarURL: nil,
userID: "@alice:matrix.org",
reason: "Hey, I’d like to join this room because of xyz topic and I’d like to participate in the room.",
eventID: "1")]
static let singleRequestNoDisplayName: [KnockRequestInfo] = [.init(displayName: nil, avatarURL: nil, userID: "@alice:matrix.org", reason: nil)]
static let singleRequestNoDisplayName: [KnockRequestInfo] = [.init(displayName: nil, avatarURL: nil, userID: "@alice:matrix.org", reason: nil, eventID: "1")]
static let multipleRequests: [KnockRequestInfo] = [
.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil),
.init(displayName: "Bob", avatarURL: nil, userID: "@bob:matrix.org", reason: nil),
.init(displayName: "Charlie", avatarURL: nil, userID: "@charlie:matrix.org", reason: nil),
.init(displayName: "Dan", avatarURL: nil, userID: "@dan:matrix.org", reason: nil),
.init(displayName: "Test", avatarURL: nil, userID: "@dan:matrix.org", reason: nil)
.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: nil, eventID: "1"),
.init(displayName: "Bob", avatarURL: nil, userID: "@bob:matrix.org", reason: nil, eventID: "2"),
.init(displayName: "Charlie", avatarURL: nil, userID: "@charlie:matrix.org", reason: nil, eventID: "3"),
.init(displayName: "Dan", avatarURL: nil, userID: "@dan:matrix.org", reason: nil, eventID: "4"),
.init(displayName: "Test", avatarURL: nil, userID: "@dan:matrix.org", reason: nil, eventID: "5")
]
static var previews: some View {
@@ -137,7 +137,7 @@ struct RoomScreen: View {
private var knockRequestsBanner: some View {
Group {
if roomContext.viewState.shouldSeeKnockRequests {
KnockRequestsBannerView(requests: roomContext.viewState.unseenKnockRequests,
KnockRequestsBannerView(requests: roomContext.viewState.displayedKnockRequests,
onDismiss: dismissKnockRequestsBanner,
onAccept: roomContext.viewState.canAcceptKnocks ? acceptKnockRequest : nil,
onViewAll: onViewAllKnockRequests,
@@ -153,8 +153,8 @@ struct RoomScreen: View {
roomContext.send(viewAction: .dismissKnockRequests)
}
private func acceptKnockRequest(userID: String) {
roomContext.send(viewAction: .acceptKnock(userID: userID))
private func acceptKnockRequest(eventID: String) {
roomContext.send(viewAction: .acceptKnock(eventID: eventID))
}
private func onViewAllKnockRequests() {
@@ -1146,10 +1146,31 @@ private extension RoomPreviewDetails {
topic: roomPreviewInfo.topic,
avatarURL: roomPreviewInfo.avatarUrl.flatMap(URL.init(string:)),
memberCount: UInt(roomPreviewInfo.numJoinedMembers),
isHistoryWorldReadable: roomPreviewInfo.isHistoryWorldReadable,
isHistoryWorldReadable: roomPreviewInfo.isHistoryWorldReadable ?? false,
isJoined: roomPreviewInfo.membership == .joined,
isInvited: roomPreviewInfo.membership == .invited,
isPublic: roomPreviewInfo.joinRule == .public,
canKnock: roomPreviewInfo.joinRule == .knock)
isPublic: roomPreviewInfo.isPublic,
canKnock: roomPreviewInfo.canKnock)
}
}
private extension RoomPreviewInfo {
var canKnock: Bool {
switch joinRule {
case .knock, .knockRestricted:
return true
default:
return false
}
}
var isPublic: Bool {
switch joinRule {
// for restricted rooms we want to show optimistically that the we may be able to join the room
case .public, .restricted:
return true
default:
return false
}
}
}
@@ -60,6 +60,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
private var typingNotificationObservationToken: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var identityStatusChangesObservationToken: TaskHandle?
// periphery:ignore - required for instance retention in the rust codebase
private var knockRequestsChangesObservationToken: TaskHandle?
private var subscribedForUpdates = false
@@ -83,6 +85,11 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
identityStatusChangesSubject.asCurrentValuePublisher()
}
private let knockRequestsStateSubject = CurrentValueSubject<KnockRequestsState, Never>(.loading)
var knockRequestsStatePublisher: CurrentValuePublisher<KnockRequestsState, Never> {
knockRequestsStateSubject.asCurrentValuePublisher()
}
// A room identifier is constant and lazy stops it from being fetched
// multiple times over FFI
lazy var id: String = room.id()
@@ -131,6 +138,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
}
subscribeToTypingNotifications()
await subscribeToKnockRequests()
}
func subscribeToRoomInfoUpdates() {
@@ -645,6 +654,19 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
identityStatusChangesSubject.send(changes)
})
}
private func subscribeToKnockRequests() async {
do {
knockRequestsChangesObservationToken = try await room.subscribeToKnockRequests(listener: RoomKnockRequestsListener { [weak self] requests in
guard let self else { return }
MXLog.info("Received requests to join update, requests id: \(requests.map(\.eventId))")
knockRequestsStateSubject.send(.loaded(requests.map(KnockRequestProxy.init)))
})
} catch {
MXLog.error("Failed observing requests to join with error: \(error)")
}
}
}
private final class RoomInfoUpdateListener: RoomInfoListener {
@@ -682,3 +704,15 @@ private final class RoomIdentityStatusChangeListener: IdentityStatusChangeListen
onUpdateClosure(identityStatusChange)
}
}
private final class RoomKnockRequestsListener: KnockRequestsListener {
private let onUpdateClosure: ([KnockRequest]) -> Void
init(_ onUpdateClosure: @escaping ([KnockRequest]) -> Void) {
self.onUpdateClosure = onUpdateClosure
}
func call(joinRequests: [KnockRequest]) {
onUpdateClosure(joinRequests)
}
}
@@ -0,0 +1,90 @@
//
// 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
struct KnockRequestProxy: KnockRequestProxyProtocol {
private let knockRequest: KnockRequest
init(knockRequest: KnockRequest) {
self.knockRequest = knockRequest
}
var eventID: String {
knockRequest.eventId
}
var userID: String {
knockRequest.userId
}
var displayName: String? {
knockRequest.displayName
}
var avatarURL: URL? {
knockRequest.avatarUrl.flatMap(URL.init)
}
var reason: String? {
knockRequest.reason
}
var formattedTimestamp: String? {
guard let timestamp = knockRequest.timestamp else {
return nil
}
return Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)).formattedMinimal()
}
var isSeen: Bool {
knockRequest.isSeen
}
func accept() async -> Result<Void, KnockRequestProxyError> {
do {
try await knockRequest.actions.accept()
return .success(())
} catch {
MXLog.error("Failed accepting request with eventID: \(eventID) to join error: \(error)")
return .failure(.sdkError(error))
}
}
func decline() async -> Result<Void, KnockRequestProxyError> {
do {
// As of right now we don't provide reasons in the app for declining
try await knockRequest.actions.decline(reason: nil)
return .success(())
} catch {
MXLog.error("Failed declining request with eventID: \(eventID) to join error: \(error)")
return .failure(.sdkError(error))
}
}
func ban() async -> Result<Void, KnockRequestProxyError> {
do {
// As of right now we don't provide reasons in the app for declining and banning
try await knockRequest.actions.declineAndBan(reason: nil)
return .success(())
} catch {
MXLog.error("Failed declining and banning user for request with eventID: \(eventID) with error: \(error)")
return .failure(.sdkError(error))
}
}
func markAsSeen() async -> Result<Void, KnockRequestProxyError> {
do {
try await knockRequest.actions.markAsSeen()
return .success(())
} catch {
MXLog.error("Failed marking request with eventID: \(eventID) to join as seen error: \(error)")
return .failure(.sdkError(error))
}
}
}
@@ -0,0 +1,28 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
enum KnockRequestProxyError: Error {
case sdkError(Error)
}
// sourcery: AutoMockable
protocol KnockRequestProxyProtocol {
var eventID: String { get }
var userID: String { get }
var displayName: String? { get }
var avatarURL: URL? { get }
var reason: String? { get }
var formattedTimestamp: String? { get }
var isSeen: Bool { get }
func accept() async -> Result<Void, KnockRequestProxyError>
func decline() async -> Result<Void, KnockRequestProxyError>
func ban() async -> Result<Void, KnockRequestProxyError>
func markAsSeen() async -> Result<Void, KnockRequestProxyError>
}
@@ -48,6 +48,11 @@ enum JoinedRoomProxyAction: Equatable {
case roomInfoUpdate
}
enum KnockRequestsState {
case loading
case loaded([KnockRequestProxyProtocol])
}
// sourcery: AutoMockable
protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
var isEncrypted: Bool { get }
@@ -60,6 +65,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> { get }
var knockRequestsStatePublisher: CurrentValuePublisher<KnockRequestsState, Never> { get }
var timeline: TimelineProxyProtocol { get }
var pinnedEventsTimeline: TimelineProxyProtocol? { get async }
@@ -9,7 +9,7 @@ import Foundation
import MatrixRustSDK
struct RoomSummary {
enum JoinRequestType {
enum KnockRequestType {
case invite(inviter: RoomMemberProxyProtocol?)
case knock
@@ -34,7 +34,7 @@ struct RoomSummary {
let id: String
let joinRequestType: JoinRequestType?
let knockRequestType: KnockRequestType?
let name: String
let isDirect: Bool
@@ -103,7 +103,7 @@ extension RoomSummary {
canonicalAlias = nil
hasOngoingCall = false
joinRequestType = nil
knockRequestType = nil
isMarkedUnread = false
isFavourite = false
}
@@ -255,7 +255,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
let notificationMode = roomInfo.cachedUserDefinedNotificationMode.flatMap { RoomNotificationModeProxy.from(roomNotificationMode: $0) }
let joinRequestType: RoomSummary.JoinRequestType? = switch roomInfo.membership {
let knockRequestType: RoomSummary.KnockRequestType? = switch roomInfo.membership {
case .invited: .invite(inviter: inviterProxy)
case .knocked: .knock
default: nil
@@ -263,7 +263,7 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
return RoomSummary(roomListItem: roomListItem,
id: roomInfo.id,
joinRequestType: joinRequestType,
knockRequestType: knockRequestType,
name: roomInfo.displayName ?? roomInfo.id,
isDirect: roomInfo.isDirect,
avatarURL: roomInfo.avatarUrl.flatMap(URL.init(string:)),
@@ -59,7 +59,7 @@ struct RoomStateEventStringBuilder {
case .knocked:
return memberIsYou ? L10n.stateEventRoomKnockByYou : L10n.stateEventRoomKnock(member)
case .knockAccepted:
return senderIsYou ? L10n.stateEventRoomKnockAcceptedByYou(senderDisplayName) : L10n.stateEventRoomKnockAccepted(senderDisplayName, member)
return senderIsYou ? L10n.stateEventRoomKnockAcceptedByYou(member) : L10n.stateEventRoomKnockAccepted(senderDisplayName, member)
case .knockRetracted:
return memberIsYou ? L10n.stateEventRoomKnockRetractedByYou : L10n.stateEventRoomKnockRetracted(member)
case .knockDenied:
@@ -153,7 +153,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
case .sentBeforeWeJoined:
encryptionType = .megolmV1AesSha2(sessionID: sessionID, cause: .sentBeforeWeJoined)
errorLabel = L10n.commonUnableToDecryptNoAccess
case .historicalMessage:
case .historicalMessageAndBackupIsDisabled, .historicalMessageAndDeviceIsUnverified:
encryptionType = .megolmV1AesSha2(sessionID: sessionID, cause: .historicalMessage)
errorLabel = L10n.timelineDecryptionFailureHistoricalEventNoKeyBackup
case .withheldForUnverifiedOrInsecureDevice:
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3879eccedadd6ef4dd6ae91070a0271b0fa064c025614933d0ac364ded809340
size 91991
oid sha256:cfccc8cc6f248c09748e20cfdf5cb663dccb783b5c4af1eee7e73e925c282a10
size 92265
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:494a4003821e6bdad2cafeaf1d3403de5801450a268b1a444fa227b3f04ecff9
size 92830
oid sha256:b774a8180dd03f6b1d99132c2e240a454ba27d7d562ea18f1fdf5edcc8eccdeb
size 93111
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:415af5d4976c78a0ea3d69441cf77498d3c2412592c8c4c920105bc1f5e0805d
size 50054
oid sha256:5d6532b2e58457f3c6534634134680e2c2365752f3da94dc236f76da3e905cb0
size 50411
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:69c9eb6ef65eb92d7270002ceaa4a4e3d2326980c8656b6a5ea34f3c3cc62e98
size 49455
oid sha256:bbc7b975d03869322a908426a86dba2d8c3e6601bc56cc5ae41c3725288e1228
size 49774
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ba63d3fcb12367a3a5c0e940698e71aec8ecfc0ff9c0cb598823860cedee53c
size 37802
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6825687515e2794f01e0f14a350221725379f9f03e70c3fe7a8f6a2c8d1a87ae
size 96599
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ba63d3fcb12367a3a5c0e940698e71aec8ecfc0ff9c0cb598823860cedee53c
size 37802
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d3c6d54a59b7c801a771949489fd3624684c87d557f84432da0547b50e7ce544
size 98936
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b649e830e7ab43a1b8828a83886f65904e4efe808d07edc92aaf1b10fb2c065
size 17355
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1f80a2cff38248a36a08765294fec6d23a84af19f853baf768adef8f529b6ded
size 52364
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b649e830e7ab43a1b8828a83886f65904e4efe808d07edc92aaf1b10fb2c065
size 17355
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1ce6690f1099bbc9f5a904dc49edb3a985d9490312565509077f0228df3ac864
size 60386
+1 -1
View File
@@ -23,7 +23,7 @@ class HomeScreenRoomTests: XCTestCase {
hasOngoingCall: Bool) {
roomSummary = RoomSummary(roomListItem: .init(noPointer: .init()),
id: "Test room",
joinRequestType: nil,
knockRequestType: nil,
name: "Test room",
isDirect: false,
avatarURL: nil,
@@ -18,6 +18,209 @@ class KnockRequestsListScreenViewModelTests: XCTestCase {
}
override func setUpWithError() throws {
viewModel = KnockRequestsListScreenViewModel(roomProxy: JoinedRoomProxyMock(.init()), mediaProvider: MediaProviderMock())
AppSettings.resetAllSettings()
}
func testLoadingState() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loading, joinRule: .knock))
viewModel = KnockRequestsListScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
let deferred = deferFulfillment(context.$viewState) { state in
!state.shouldDisplayRequests &&
state.isKnockableRoom &&
state.canAccept &&
!state.canBan &&
!state.canDecline &&
state.isLoading &&
!state.shouldDisplayEmptyView
}
try await deferred.fulfill()
}
func testEmptyState() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([]), joinRule: .knock))
viewModel = KnockRequestsListScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
let deferred = deferFulfillment(context.$viewState) { state in
!state.shouldDisplayRequests &&
state.isKnockableRoom &&
state.canAccept &&
!state.canBan &&
!state.canDecline &&
!state.isLoading &&
state.shouldDisplayEmptyView
}
try await deferred.fulfill()
}
func testLoadedState() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(members: [.mockAdmin],
knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org")),
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org")),
KnockRequestProxyMock(.init(eventID: "3", userID: "@charlie:matrix.org")),
KnockRequestProxyMock(.init(eventID: "4", userID: "@dan:matrix.org"))]),
ownUserID: RoomMemberProxyMock.mockAdmin.userID,
joinRule: .knock))
viewModel = KnockRequestsListScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
var deferred = deferFulfillment(context.$viewState) { state in
state.shouldDisplayRequests &&
state.isKnockableRoom &&
state.canAccept &&
state.canBan &&
state.canDecline &&
!state.isLoading &&
!state.shouldDisplayEmptyView &&
state.displayedRequests.count == 4 &&
state.handledEventIDs.isEmpty &&
state.shouldDisplayAcceptAllButton
}
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { state in
state.shouldDisplayRequests &&
state.handledEventIDs == ["1"] &&
!state.shouldDisplayEmptyView &&
state.displayedRequests.count == 3 &&
state.shouldDisplayAcceptAllButton
}
context.send(viewAction: .acceptRequest(eventID: "1"))
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { state in
state.bindings.alertInfo?.id == .declineRequest
}
context.send(viewAction: .declineRequest(eventID: "2"))
try await deferred.fulfill()
guard let declineAlertInfo = context.alertInfo else {
XCTFail("Can't be nil")
return
}
deferred = deferFulfillment(context.$viewState) { state in
state.shouldDisplayRequests &&
state.handledEventIDs == ["1", "2"] &&
!state.shouldDisplayEmptyView &&
state.displayedRequests.count == 2 &&
state.shouldDisplayAcceptAllButton
}
declineAlertInfo.primaryButton.action?()
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { state in
state.bindings.alertInfo?.id == .declineAndBan
}
context.send(viewAction: .ban(eventID: "3"))
try await deferred.fulfill()
guard let banAlertInfo = context.alertInfo else {
XCTFail("Can't be nil")
return
}
deferred = deferFulfillment(context.$viewState) { state in
state.shouldDisplayRequests &&
state.handledEventIDs == ["1", "2", "3"] &&
!state.shouldDisplayEmptyView &&
state.displayedRequests.count == 1 &&
!state.shouldDisplayAcceptAllButton
}
banAlertInfo.primaryButton.action?()
try await deferred.fulfill()
}
func testAcceptAll() async throws {
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org")),
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org")),
KnockRequestProxyMock(.init(eventID: "3", userID: "@charlie:matrix.org")),
KnockRequestProxyMock(.init(eventID: "4", userID: "@dan:matrix.org"))]),
joinRule: .knock))
viewModel = KnockRequestsListScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
var deferred = deferFulfillment(context.$viewState) { state in
state.shouldDisplayRequests &&
state.isKnockableRoom &&
state.canAccept &&
!state.canBan &&
!state.canDecline &&
!state.isLoading &&
!state.shouldDisplayEmptyView &&
state.displayedRequests.count == 4 &&
state.handledEventIDs.isEmpty &&
state.shouldDisplayAcceptAllButton
}
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { state in
state.bindings.alertInfo?.id == .acceptAllRequests
}
context.send(viewAction: .acceptAllRequests)
try await deferred.fulfill()
guard let alertInfo = context.alertInfo else {
XCTFail("Can't be nil")
return
}
deferred = deferFulfillment(context.$viewState) { state in
!state.shouldDisplayRequests &&
state.handledEventIDs == ["1", "2", "3", "4"] &&
!state.isLoading &&
state.shouldDisplayEmptyView
}
alertInfo.primaryButton.action?()
try await deferred.fulfill()
}
func testLoadedStateBecomesEmptyIfTheJoinRuleIsNotKnocking() async throws {
// If there is a sudden change in the rule, but the requests are still published, we want to hide all of them and show the empty view
let roomProxyMock = JoinedRoomProxyMock(.init(members: [.mockAdmin],
knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org")),
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org")),
KnockRequestProxyMock(.init(eventID: "3", userID: "@charlie:matrix.org")),
KnockRequestProxyMock(.init(eventID: "4", userID: "@dan:matrix.org"))]),
ownUserID: RoomMemberProxyMock.mockAdmin.userID,
joinRule: .invite))
viewModel = KnockRequestsListScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
let deferred = deferFulfillment(context.$viewState) { state in
!state.shouldDisplayRequests &&
state.shouldDisplayEmptyView &&
!state.isLoading &&
!state.isKnockableRoom
}
try await deferred.fulfill()
}
func testLoadedStateBecomesEmptyIfPermissionsAreRemoved() async throws {
// If there is a sudden change in permissions, and the user can't do any other action, we hide all the requests and shoe the empty view
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org")),
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org")),
KnockRequestProxyMock(.init(eventID: "3", userID: "@charlie:matrix.org")),
KnockRequestProxyMock(.init(eventID: "4", userID: "@dan:matrix.org"))]),
canUserInvite: false,
joinRule: .knock))
viewModel = KnockRequestsListScreenViewModel(roomProxy: roomProxyMock,
mediaProvider: MediaProviderMock(),
userIndicatorController: UserIndicatorControllerMock())
let deferred = deferFulfillment(context.$viewState) { state in
!state.shouldDisplayRequests &&
state.shouldDisplayEmptyView &&
!state.canAccept &&
!state.canBan &&
!state.canDecline &&
!state.isLoading
}
try await deferred.fulfill()
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ class LoggingTests: XCTestCase {
let heroName = "Pseudonym"
let roomSummary = RoomSummary(roomListItem: .init(noPointer: .init()),
id: "myroomid",
joinRequestType: nil,
knockRequestType: nil,
name: roomName,
isDirect: true,
avatarURL: nil,
@@ -21,6 +21,7 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
var cancellables = Set<AnyCancellable>()
override func setUp() {
AppSettings.resetAllSettings()
cancellables.removeAll()
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test"))
notificationSettingsProxyMock = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
@@ -33,8 +34,6 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
AppSettings.resetAllSettings()
}
func testLeaveRoomTappedWhenPublic() async throws {
@@ -672,4 +671,99 @@ class RoomDetailsScreenViewModelTests: XCTestCase {
XCTFail("invalid state")
}
}
// MARK: - Knock Requests
func testKnockRequestsCounter() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, isPublic: false, knockRequestsState: .loaded(mockedRequests), joinRule: .knock))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MediaProviderMock(configuration: .init()),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxyMock,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
let deferred = deferFulfillment(context.$viewState) { state in
state.knockRequestsCount == 2 && state.canSeeKnockingRequests
}
try await deferred.fulfill()
let deferredAction = deferFulfillment(viewModel.actions) { $0 == .displayKnockingRequests }
context.send(viewAction: .processTapRequestsToJoin)
try await deferredAction.fulfill()
}
func testKnockRequestsCounterIsLoading() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, isPublic: false, knockRequestsState: .loading, joinRule: .knock))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MediaProviderMock(configuration: .init()),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxyMock,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
let deferred = deferFulfillment(context.$viewState) { state in
state.knockRequestsCount == 0 && state.canSeeKnockingRequests
}
try await deferred.fulfill()
}
func testKnockRequestsCounterIsNotShownIfNoPermissions() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: false, isPublic: false, knockRequestsState: .loaded(mockedRequests), canUserInvite: false, joinRule: .knock))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MediaProviderMock(configuration: .init()),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxyMock,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
let deferred = deferFulfillment(context.$viewState) { state in
state.knockRequestsCount == 2 &&
state.dmRecipient == nil &&
!state.canSeeKnockingRequests &&
!state.canInviteUsers
}
try await deferred.fulfill()
}
func testKnockRequestsCounterIsNotShownIfDM() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let mockedRequests: [KnockRequestProxyMock] = [.init(), .init()]
let mockedMembers: [RoomMemberProxyMock] = [.mockMe, .mockAlice]
roomProxyMock = JoinedRoomProxyMock(.init(name: "Test", isDirect: true, isPublic: false, members: mockedMembers, knockRequestsState: .loaded(mockedRequests), joinRule: .knock))
viewModel = RoomDetailsScreenViewModel(roomProxy: roomProxyMock,
clientProxy: ClientProxyMock(.init()),
mediaProvider: MediaProviderMock(configuration: .init()),
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
notificationSettingsProxy: notificationSettingsProxyMock,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings)
let deferred = deferFulfillment(context.$viewState) { state in
state.knockRequestsCount == 2 &&
!state.canSeeKnockingRequests &&
state.dmRecipient != nil &&
state.canInviteUsers
}
try await deferred.fulfill()
}
}
@@ -239,4 +239,116 @@ class RoomScreenViewModelTests: XCTestCase {
// Then the call button should remain visible shown.
XCTAssertTrue(viewModel.state.shouldShowCallButton)
}
// MARK: - Knock Requests
func testKnockRequestBanner() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!")),
// This one should be filtered
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org", isSeen: true))]),
joinRule: .knock))
let viewModel = RoomScreenViewModel(clientProxy: ClientProxyMock(),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MediaProviderMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
var deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.shouldSeeKnockRequests &&
state.unseenKnockRequests == [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Hello World!", eventID: "1")]
}
try await deferred.fulfill()
let deferredAction = deferFulfillment(viewModel.actions) { $0 == .displayKnockRequests }
viewModel.context.send(viewAction: .viewKnockRequests)
try await deferredAction.fulfill()
deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.handledEventIDs == ["1"] &&
!state.shouldSeeKnockRequests
}
viewModel.context.send(viewAction: .acceptKnock(eventID: "1"))
try await deferred.fulfill()
}
func testKnockRequestBannerMarkAsSeen() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!")),
// This one should be filtered
KnockRequestProxyMock(.init(eventID: "2", userID: "@bob:matrix.org"))]),
joinRule: .knock))
let viewModel = RoomScreenViewModel(clientProxy: ClientProxyMock(),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MediaProviderMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
var deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.shouldSeeKnockRequests &&
state.unseenKnockRequests == [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Hello World!", eventID: "1"),
.init(displayName: nil, avatarURL: nil, userID: "@bob:matrix.org", reason: nil, eventID: "2")]
}
try await deferred.fulfill()
deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.handledEventIDs == ["1", "2"] &&
!state.shouldSeeKnockRequests
}
viewModel.context.send(viewAction: .dismissKnockRequests)
try await deferred.fulfill()
}
func testLoadingKnockRequests() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loading,
joinRule: .knock))
let viewModel = RoomScreenViewModel(clientProxy: ClientProxyMock(),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MediaProviderMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
// Loading state just does not appear at all
let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.shouldSeeKnockRequests }
try await deferred.fulfill()
}
func testKnockRequestsBannerDoesNotAppearIfUserHasNoPermission() async throws {
ServiceLocator.shared.settings.knockingEnabled = true
let roomProxyMock = JoinedRoomProxyMock(.init(knockRequestsState: .loaded([KnockRequestProxyMock(.init(eventID: "1", userID: "@alice:matrix.org", displayName: "Alice", reason: "Hello World!"))]),
canUserInvite: false,
joinRule: .knock))
let viewModel = RoomScreenViewModel(clientProxy: ClientProxyMock(),
roomProxy: roomProxyMock,
initialSelectedPinnedEventID: nil,
mediaProvider: MediaProviderMock(configuration: .init()),
ongoingCallRoomIDPublisher: .init(.init(nil)),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
self.viewModel = viewModel
var deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.unseenKnockRequests == [.init(displayName: "Alice", avatarURL: nil, userID: "@alice:matrix.org", reason: "Hello World!", eventID: "1")] &&
!state.shouldSeeKnockRequests
}
try await deferred.fulfill()
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ class RoomSummaryTests: XCTestCase {
func makeSummary(isDirect: Bool, hasRoomAvatar: Bool) -> RoomSummary {
RoomSummary(roomListItem: .init(noPointer: .init()),
id: roomDetails.id,
joinRequestType: nil,
knockRequestType: nil,
name: roomDetails.name,
isDirect: isDirect,
avatarURL: hasRoomAvatar ? roomDetails.avatarURL : nil,
+1 -1
View File
@@ -61,7 +61,7 @@ packages:
# Element/Matrix dependencies
MatrixRustSDK:
url: https://github.com/element-hq/matrix-rust-components-swift
exactVersion: 1.0.80
exactVersion: 1.0.81
# path: ../matrix-rust-sdk
Compound:
url: https://github.com/element-hq/compound-ios