diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d0a5dfe789..bfcc5a75da 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1035,6 +1035,7 @@ E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E543072DE58E751F028998 /* TimelineProxy.swift */; }; E84ADFE9696936C18C2424B5 /* SecureBackupScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A00BB9CD12CF6AC98D5485 /* SecureBackupScreen.swift */; }; E89536FC8C0E4B79E9842A78 /* RoomTimelineControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C0197EAE9D45A662B8847B6 /* RoomTimelineControllerProtocol.swift */; }; + E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */; }; E9347F56CF0683208F4D9249 /* RoomNotificationSettingsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A9B5225D0881CEFA2CF7C9 /* RoomNotificationSettingsScreenViewModel.swift */; }; E9560744F7B0292E20ECE5F2 /* RoomDetailsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E8A1E8EE094F570573B6E8 /* RoomDetailsScreenViewModelProtocol.swift */; }; E96005321849DBD7C72A28F2 /* UITestsAppCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46C208DA43CE25D13E670F40 /* UITestsAppCoordinator.swift */; }; @@ -1488,6 +1489,7 @@ 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementCallServiceConstants.swift; sourceTree = ""; }; 40B21E611DADDEF00307E7AC /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 4100DDE6BF3C566AB66B80CC /* MentionSuggestionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionSuggestionItemView.swift; sourceTree = ""; }; + 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenFooterView.swift; sourceTree = ""; }; 4176C3E20C772DE8D182863C /* LegalInformationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreen.swift; sourceTree = ""; }; 419957D7B1C983D7B3B93678 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/InfoPlist.strings"; sourceTree = ""; }; 41BB37D96C3EA18F3CE8675D /* RoomDirectorySearchScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDirectorySearchScreenModels.swift; sourceTree = ""; }; @@ -4018,6 +4020,7 @@ children = ( 422724361B6555364C43281E /* RoomHeaderView.swift */, 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */, + 4137900E28201C314C835C11 /* RoomScreenFooterView.swift */, 4552D3466B1453F287223ADA /* SwipeRightAction.swift */, 464C6BFAA853DC755B9C1F60 /* PinnedItemsBanner */, ); @@ -6788,6 +6791,7 @@ F8F47CE757EE656905F01F2C /* RoomRolesAndPermissionsScreenViewModelProtocol.swift in Sources */, C55A44C99F64A479ABA85B46 /* RoomScreen.swift in Sources */, A851635B3255C6DC07034A12 /* RoomScreenCoordinator.swift in Sources */, + E8C65C19F7C40EE545172DD6 /* RoomScreenFooterView.swift in Sources */, 352C439BE0F75E101EF11FB1 /* RoomScreenModels.swift in Sources */, 7BB31E67648CF32D2AB5E502 /* RoomScreenViewModel.swift in Sources */, 617624A97BDBB75ED3DD8156 /* RoomScreenViewModelProtocol.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index 413594832b..0e4fecdf67 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -226,6 +226,7 @@ "common_voice_message" = "Voice message"; "common_waiting" = "Waiting…"; "common_waiting_for_decryption_key" = "Waiting for this message"; +"common.copied_to_clipboard" = "Copied to clipboard"; "common.do_not_show_this_again" = "Do not show this again"; "common.open_source_licenses" = "Open source licenses"; "common.pinned" = "Pinned"; @@ -240,6 +241,7 @@ "confirm_recovery_key_banner_message" = "Your chat backup is currently out of sync. You need to enter your recovery key to maintain access to your chat backup."; "confirm_recovery_key_banner_title" = "Enter your recovery key"; "crash_detection_dialog_content" = "%1$@ crashed the last time it was used. Would you like to share a crash report with us?"; +"crypto_identity_change_pin_violation" = "%1$@'s identity appears to have changed. %2$@"; "dialog_permission_camera" = "In order to let the application use the camera, please grant the permission in the system settings."; "dialog_permission_generic" = "Please grant the permission in the system settings."; "dialog_permission_location_description_ios" = "Grant access in Settings -> Location."; @@ -481,7 +483,7 @@ "screen_edit_profile_updating_details" = "Updating profile…"; "screen_encryption_reset_action_continue_reset" = "Continue reset"; "screen_encryption_reset_bullet_1" = "Your account details, contacts, preferences, and chat list will be kept"; -"screen_encryption_reset_bullet_2" = "You will lose your existing message history"; +"screen_encryption_reset_bullet_2" = "You will lose your existing message history unless it is stored on another device"; "screen_encryption_reset_bullet_3" = "You will need to verify all your existing devices and contacts again"; "screen_encryption_reset_footer" = "Only reset your identity if you don’t have access to another signed-in device and you’ve lost your recovery key."; "screen_encryption_reset_title" = "Can't confirm? You’ll need to reset your identity."; diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 542d58057a..91659f124a 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -124,6 +124,8 @@ final class AppSettings { let encryptionURL: URL = "https://element.io/help#encryption" /// A URL where users can go read more about the chat backup. let chatBackupDetailsURL: URL = "https://element.io/help#encryption5" + /// A URL where users can go read more about identity pinning violations + let identityPinningViolationDetailsURL: URL = "https://element.io/help" /// Any domains that Element web may be hosted on - used for handling links. let elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"] diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 55bb4b85db..36f6866c76 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -520,6 +520,10 @@ internal enum L10n { internal static func crashDetectionDialogContent(_ p1: Any) -> String { return L10n.tr("Localizable", "crash_detection_dialog_content", String(describing: p1)) } + /// %1$@'s identity appears to have changed. %2$@ + internal static func cryptoIdentityChangePinViolation(_ p1: Any, _ p2: Any) -> String { + return L10n.tr("Localizable", "crypto_identity_change_pin_violation", String(describing: p1), String(describing: p2)) + } /// In order to let the application use the camera, please grant the permission in the system settings. internal static var dialogPermissionCamera: String { return L10n.tr("Localizable", "dialog_permission_camera") } /// Please grant the permission in the system settings. @@ -1119,7 +1123,7 @@ internal enum L10n { internal static var screenEncryptionResetActionContinueReset: String { return L10n.tr("Localizable", "screen_encryption_reset_action_continue_reset") } /// Your account details, contacts, preferences, and chat list will be kept internal static var screenEncryptionResetBullet1: String { return L10n.tr("Localizable", "screen_encryption_reset_bullet_1") } - /// You will lose your existing message history + /// You will lose your existing message history unless it is stored on another device internal static var screenEncryptionResetBullet2: String { return L10n.tr("Localizable", "screen_encryption_reset_bullet_2") } /// You will need to verify all your existing devices and contacts again internal static var screenEncryptionResetBullet3: String { return L10n.tr("Localizable", "screen_encryption_reset_bullet_3") } @@ -2410,6 +2414,8 @@ internal enum L10n { } internal enum Common { + /// Copied to clipboard + internal static var copiedToClipboard: String { return L10n.tr("Localizable", "common.copied_to_clipboard") } /// Do not show this again internal static var doNotShowThisAgain: String { return L10n.tr("Localizable", "common.do_not_show_this_again") } /// Open source licenses diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 356a7b892d..518c26166d 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -5791,6 +5791,11 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol { set(value) { underlyingTypingMembersPublisher = value } } var underlyingTypingMembersPublisher: CurrentValuePublisher<[String], Never>! + var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> { + get { return underlyingIdentityStatusChangesPublisher } + set(value) { underlyingIdentityStatusChangesPublisher = value } + } + var underlyingIdentityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never>! var actionsPublisher: AnyPublisher { get { return underlyingActionsPublisher } set(value) { underlyingActionsPublisher = value } @@ -7077,6 +7082,76 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol { return withdrawVerificationAndResendUserIDsItemIDReturnValue } } + //MARK: - pinCurrentIdentity + + var pinCurrentIdentityUserIDUnderlyingCallsCount = 0 + var pinCurrentIdentityUserIDCallsCount: Int { + get { + if Thread.isMainThread { + return pinCurrentIdentityUserIDUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = pinCurrentIdentityUserIDUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinCurrentIdentityUserIDUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + pinCurrentIdentityUserIDUnderlyingCallsCount = newValue + } + } + } + } + var pinCurrentIdentityUserIDCalled: Bool { + return pinCurrentIdentityUserIDCallsCount > 0 + } + var pinCurrentIdentityUserIDReceivedUserID: String? + var pinCurrentIdentityUserIDReceivedInvocations: [String] = [] + + var pinCurrentIdentityUserIDUnderlyingReturnValue: Result! + var pinCurrentIdentityUserIDReturnValue: Result! { + get { + if Thread.isMainThread { + return pinCurrentIdentityUserIDUnderlyingReturnValue + } else { + var returnValue: Result? = nil + DispatchQueue.main.sync { + returnValue = pinCurrentIdentityUserIDUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinCurrentIdentityUserIDUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + pinCurrentIdentityUserIDUnderlyingReturnValue = newValue + } + } + } + } + var pinCurrentIdentityUserIDClosure: ((String) async -> Result)? + + func pinCurrentIdentity(userID: String) async -> Result { + pinCurrentIdentityUserIDCallsCount += 1 + pinCurrentIdentityUserIDReceivedUserID = userID + DispatchQueue.main.async { + self.pinCurrentIdentityUserIDReceivedInvocations.append(userID) + } + if let pinCurrentIdentityUserIDClosure = pinCurrentIdentityUserIDClosure { + return await pinCurrentIdentityUserIDClosure(userID) + } else { + return pinCurrentIdentityUserIDReturnValue + } + } //MARK: - flagAsUnread var flagAsUnreadUnderlyingCallsCount = 0 @@ -12495,6 +12570,7 @@ class RoomMemberProxyMock: RoomMemberProxyProtocol { } var underlyingUserID: String! var displayName: String? + var disambiguatedDisplayName: String? var avatarURL: URL? var membership: MembershipState { get { return underlyingMembership } diff --git a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift index 4cd45da022..319584131d 100644 --- a/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/SDKGeneratedMocks.swift @@ -625,6 +625,52 @@ open class ClientSDKMock: MatrixRustSDK.Client { } } + //MARK: - customLoginWithJwt + + open var customLoginWithJwtJwtInitialDeviceNameDeviceIdThrowableError: Error? + var customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount = 0 + open var customLoginWithJwtJwtInitialDeviceNameDeviceIdCallsCount: Int { + get { + if Thread.isMainThread { + return customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + customLoginWithJwtJwtInitialDeviceNameDeviceIdUnderlyingCallsCount = newValue + } + } + } + } + open var customLoginWithJwtJwtInitialDeviceNameDeviceIdCalled: Bool { + return customLoginWithJwtJwtInitialDeviceNameDeviceIdCallsCount > 0 + } + open var customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedArguments: (jwt: String, initialDeviceName: String?, deviceId: String?)? + open var customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedInvocations: [(jwt: String, initialDeviceName: String?, deviceId: String?)] = [] + open var customLoginWithJwtJwtInitialDeviceNameDeviceIdClosure: ((String, String?, String?) async throws -> Void)? + + open override func customLoginWithJwt(jwt: String, initialDeviceName: String?, deviceId: String?) async throws { + if let error = customLoginWithJwtJwtInitialDeviceNameDeviceIdThrowableError { + throw error + } + customLoginWithJwtJwtInitialDeviceNameDeviceIdCallsCount += 1 + customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedArguments = (jwt: jwt, initialDeviceName: initialDeviceName, deviceId: deviceId) + DispatchQueue.main.async { + self.customLoginWithJwtJwtInitialDeviceNameDeviceIdReceivedInvocations.append((jwt: jwt, initialDeviceName: initialDeviceName, deviceId: deviceId)) + } + try await customLoginWithJwtJwtInitialDeviceNameDeviceIdClosure?(jwt, initialDeviceName, deviceId) + } + //MARK: - deactivateAccount open var deactivateAccountAuthDataEraseDataThrowableError: Error? @@ -4953,6 +4999,77 @@ open class ClientBuilderSDKMock: MatrixRustSDK.ClientBuilder { } } + //MARK: - roomDecryptionTrustRequirement + + var roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount = 0 + open var roomDecryptionTrustRequirementTrustRequirementCallsCount: Int { + get { + if Thread.isMainThread { + return roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + roomDecryptionTrustRequirementTrustRequirementUnderlyingCallsCount = newValue + } + } + } + } + open var roomDecryptionTrustRequirementTrustRequirementCalled: Bool { + return roomDecryptionTrustRequirementTrustRequirementCallsCount > 0 + } + open var roomDecryptionTrustRequirementTrustRequirementReceivedTrustRequirement: TrustRequirement? + open var roomDecryptionTrustRequirementTrustRequirementReceivedInvocations: [TrustRequirement] = [] + + var roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue: ClientBuilder! + open var roomDecryptionTrustRequirementTrustRequirementReturnValue: ClientBuilder! { + get { + if Thread.isMainThread { + return roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue + } else { + var returnValue: ClientBuilder? = nil + DispatchQueue.main.sync { + returnValue = roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + roomDecryptionTrustRequirementTrustRequirementUnderlyingReturnValue = newValue + } + } + } + } + open var roomDecryptionTrustRequirementTrustRequirementClosure: ((TrustRequirement) -> ClientBuilder)? + + open override func roomDecryptionTrustRequirement(trustRequirement: TrustRequirement) -> ClientBuilder { + roomDecryptionTrustRequirementTrustRequirementCallsCount += 1 + roomDecryptionTrustRequirementTrustRequirementReceivedTrustRequirement = trustRequirement + DispatchQueue.main.async { + self.roomDecryptionTrustRequirementTrustRequirementReceivedInvocations.append(trustRequirement) + } + if let roomDecryptionTrustRequirementTrustRequirementClosure = roomDecryptionTrustRequirementTrustRequirementClosure { + return roomDecryptionTrustRequirementTrustRequirementClosure(trustRequirement) + } else { + return roomDecryptionTrustRequirementTrustRequirementReturnValue + } + } + //MARK: - roomKeyRecipientStrategy var roomKeyRecipientStrategyStrategyUnderlyingCallsCount = 0 @@ -12296,6 +12413,52 @@ open class RoomSDKMock: MatrixRustSDK.Room { } } + //MARK: - pinUserIdentity + + open var pinUserIdentityUserIdThrowableError: Error? + var pinUserIdentityUserIdUnderlyingCallsCount = 0 + open var pinUserIdentityUserIdCallsCount: Int { + get { + if Thread.isMainThread { + return pinUserIdentityUserIdUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = pinUserIdentityUserIdUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + pinUserIdentityUserIdUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + pinUserIdentityUserIdUnderlyingCallsCount = newValue + } + } + } + } + open var pinUserIdentityUserIdCalled: Bool { + return pinUserIdentityUserIdCallsCount > 0 + } + open var pinUserIdentityUserIdReceivedUserId: String? + open var pinUserIdentityUserIdReceivedInvocations: [String] = [] + open var pinUserIdentityUserIdClosure: ((String) async throws -> Void)? + + open override func pinUserIdentity(userId: String) async throws { + if let error = pinUserIdentityUserIdThrowableError { + throw error + } + pinUserIdentityUserIdCallsCount += 1 + pinUserIdentityUserIdReceivedUserId = userId + DispatchQueue.main.async { + self.pinUserIdentityUserIdReceivedInvocations.append(userId) + } + try await pinUserIdentityUserIdClosure?(userId) + } + //MARK: - pinnedEventsTimeline open var pinnedEventsTimelineInternalIdPrefixMaxEventsToLoadMaxConcurrentRequestsThrowableError: Error? @@ -13068,6 +13231,77 @@ open class RoomSDKMock: MatrixRustSDK.Room { try await setUnreadFlagNewValueClosure?(newValue) } + //MARK: - subscribeToIdentityStatusChanges + + var subscribeToIdentityStatusChangesListenerUnderlyingCallsCount = 0 + open var subscribeToIdentityStatusChangesListenerCallsCount: Int { + get { + if Thread.isMainThread { + return subscribeToIdentityStatusChangesListenerUnderlyingCallsCount + } else { + var returnValue: Int? = nil + DispatchQueue.main.sync { + returnValue = subscribeToIdentityStatusChangesListenerUnderlyingCallsCount + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + subscribeToIdentityStatusChangesListenerUnderlyingCallsCount = newValue + } else { + DispatchQueue.main.sync { + subscribeToIdentityStatusChangesListenerUnderlyingCallsCount = newValue + } + } + } + } + open var subscribeToIdentityStatusChangesListenerCalled: Bool { + return subscribeToIdentityStatusChangesListenerCallsCount > 0 + } + open var subscribeToIdentityStatusChangesListenerReceivedListener: IdentityStatusChangeListener? + open var subscribeToIdentityStatusChangesListenerReceivedInvocations: [IdentityStatusChangeListener] = [] + + var subscribeToIdentityStatusChangesListenerUnderlyingReturnValue: TaskHandle! + open var subscribeToIdentityStatusChangesListenerReturnValue: TaskHandle! { + get { + if Thread.isMainThread { + return subscribeToIdentityStatusChangesListenerUnderlyingReturnValue + } else { + var returnValue: TaskHandle? = nil + DispatchQueue.main.sync { + returnValue = subscribeToIdentityStatusChangesListenerUnderlyingReturnValue + } + + return returnValue! + } + } + set { + if Thread.isMainThread { + subscribeToIdentityStatusChangesListenerUnderlyingReturnValue = newValue + } else { + DispatchQueue.main.sync { + subscribeToIdentityStatusChangesListenerUnderlyingReturnValue = newValue + } + } + } + } + open var subscribeToIdentityStatusChangesListenerClosure: ((IdentityStatusChangeListener) -> TaskHandle)? + + open override func subscribeToIdentityStatusChanges(listener: IdentityStatusChangeListener) -> TaskHandle { + subscribeToIdentityStatusChangesListenerCallsCount += 1 + subscribeToIdentityStatusChangesListenerReceivedListener = listener + DispatchQueue.main.async { + self.subscribeToIdentityStatusChangesListenerReceivedInvocations.append(listener) + } + if let subscribeToIdentityStatusChangesListenerClosure = subscribeToIdentityStatusChangesListenerClosure { + return subscribeToIdentityStatusChangesListenerClosure(listener) + } else { + return subscribeToIdentityStatusChangesListenerReturnValue + } + } + //MARK: - subscribeToRoomInfoUpdates var subscribeToRoomInfoUpdatesListenerUnderlyingCallsCount = 0 diff --git a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift index 9825257d0a..2074c014b1 100644 --- a/ElementX/Sources/Mocks/RoomMemberProxyMock.swift +++ b/ElementX/Sources/Mocks/RoomMemberProxyMock.swift @@ -25,6 +25,11 @@ extension RoomMemberProxyMock { self.init() userID = configuration.userID displayName = configuration.displayName + + if let displayName = configuration.displayName { + disambiguatedDisplayName = "\(displayName) (\(userID))" + } + avatarURL = configuration.avatarURL membership = configuration.membership diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index f1bbc1506d..3e37f39764 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -67,7 +67,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher, appMediator: parameters.appMediator, appSettings: ServiceLocator.shared.settings, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy, focussedEventID: parameters.focussedEvent?.eventID, @@ -149,10 +150,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol { .store(in: &cancellables) roomViewModel.actions - .sink { [weak self] actions in + .sink { [weak self] action in guard let self else { return } - switch actions { + switch action { case .focusEvent(eventID: let eventID): focusOnEvent(FocusEvent(eventID: eventID, shouldSetPin: false)) case .displayPinnedEventsTimeline: diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 9f4bb68a22..92c9bfdbe2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -21,6 +21,7 @@ enum RoomScreenViewAction { case viewAllPins case displayRoomDetails case displayCall + case footerViewAction(RoomScreenFooterViewAction) } struct RoomScreenViewState: BindableState { @@ -38,11 +39,21 @@ struct RoomScreenViewState: BindableState { var hasOngoingCall: Bool var shouldShowCallButton = true + var footerDetails: RoomScreenFooterViewDetails? + var bindings: RoomScreenViewStateBindings } struct RoomScreenViewStateBindings { } +enum RoomScreenFooterViewAction { + case resolvePinViolation(userID: String) +} + +enum RoomScreenFooterViewDetails { + case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL) +} + enum PinnedEventsBannerState: Equatable { case loading(numbersOfEvents: Int) case loaded(state: PinnedEventsState) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 08beb380b2..9c91d544a2 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -7,6 +7,7 @@ import Combine import Foundation +import MatrixRustSDK import OrderedCollections import SwiftUI @@ -17,8 +18,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private let appMediator: AppMediatorProtocol private let appSettings: AppSettings private let analyticsService: AnalyticsService - private let pinnedEventStringBuilder: RoomEventStringBuilder + private let userIndicatorController: UserIndicatorControllerProtocol + private var initialSelectedPinnedEventID: String? + private let pinnedEventStringBuilder: RoomEventStringBuilder + + private var identityPinningViolations = [String: RoomMemberProxyProtocol]() private let actionsSubject: PassthroughSubject = .init() var actions: AnyPublisher { @@ -49,11 +54,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol ongoingCallRoomIDPublisher: CurrentValuePublisher, appMediator: AppMediatorProtocol, appSettings: AppSettings, - analyticsService: AnalyticsService) { + analyticsService: AnalyticsService, + userIndicatorController: UserIndicatorControllerProtocol) { self.roomProxy = roomProxy self.appMediator = appMediator self.appSettings = appSettings self.analyticsService = analyticsService + self.userIndicatorController = userIndicatorController + self.initialSelectedPinnedEventID = initialSelectedPinnedEventID pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) @@ -87,6 +95,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol actionsSubject.send(.displayCall) actionsSubject.send(.removeComposerFocus) analyticsService.trackInteraction(name: .MobileRoomCallButton) + case .footerViewAction(let action): + switch action { + case .resolvePinViolation(let userID): + Task { await resolveIdentityPinningViolation(userID) } + } } } @@ -98,6 +111,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.pinnedEventsBannerState.setSelectedPinnedEventID(eventID) } + // MARK: - Private + private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher) { let roomInfoSubscription = roomProxy .actionsPublisher @@ -124,6 +139,21 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } .store(in: &cancellables) + let identityStatusChangesPublisher = roomProxy.identityStatusChangesPublisher.receive(on: DispatchQueue.main) + + Task { [weak self] in + for await changes in identityStatusChangesPublisher.values { + guard let self else { return } + + guard !Task.isCancelled else { + return + } + + await self.processIdentityStatusChanges(changes) + } + } + .store(in: &cancellables) + appMediator.networkMonitor.reachabilityPublisher .filter { $0 == .reachable } .receive(on: DispatchQueue.main) @@ -141,6 +171,43 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol .store(in: &cancellables) } + private func processIdentityStatusChanges(_ changes: [IdentityStatusChange]) async { + for change in changes { + switch change.changedTo { + case .pinned: + identityPinningViolations[change.userId] = nil + case .pinViolation: + guard case let .success(member) = await roomProxy.getMember(userID: change.userId) else { + MXLog.error("Failed retrieving room member for identity status change: \(change)") + continue + } + + identityPinningViolations[change.userId] = member + default: + break + } + } + + if let member = identityPinningViolations.values.first { + state.footerDetails = .pinViolation(member: member, + learnMoreURL: appSettings.identityPinningViolationDetailsURL) + } else { + state.footerDetails = nil + } + } + + private func resolveIdentityPinningViolation(_ userID: String) async { + defer { + hideLoadingIndicator() + } + + showLoadingIndicator() + + if case .failure = await roomProxy.pinCurrentIdentity(userID: userID) { + userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError) + } + } + private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) { var pinnedEventContents = OrderedDictionary() @@ -190,6 +257,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } } } + + // MARK: Loading indicators + + private static let loadingIndicatorIdentifier = "\(RoomScreenViewModel.self)-Loading" + + private func showLoadingIndicator() { + userIndicatorController.submitIndicator(.init(id: Self.loadingIndicatorIdentifier, type: .toast, title: L10n.commonLoading)) + } + + private func hideLoadingIndicator() { + userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier) + } } extension RoomScreenViewModel { @@ -200,6 +279,7 @@ extension RoomScreenViewModel { ongoingCallRoomIDPublisher: .init(.init(nil)), appMediator: AppMediatorMock.default, appSettings: ServiceLocator.shared.settings, - analyticsService: ServiceLocator.shared.analytics) + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index dee0516fb5..5e747b27ec 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -29,21 +29,29 @@ struct RoomScreen: View { timeline .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) .safeAreaInset(edge: .bottom, spacing: 0) { - composerToolbar - .padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12) - .background { - if composerToolbarContext.composerFormattingEnabled { - RoundedRectangle(cornerRadius: 20) - .stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5) - .ignoresSafeArea() - } + VStack(spacing: 0) { + RoomScreenFooterView(details: roomContext.viewState.footerDetails, + mediaProvider: roomContext.mediaProvider) { action in + roomContext.send(viewAction: .footerViewAction(action)) } - .padding(.top, 8) - .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) - .environmentObject(timelineContext) - .environment(\.timelineContext, timelineContext) - // Make sure the reply header honours the hideTimelineMedia setting too. - .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) + + composerToolbar + .padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12) + .background { + if composerToolbarContext.composerFormattingEnabled { + RoundedRectangle(cornerRadius: 20) + .stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5) + .ignoresSafeArea() + } + } + .padding(.top, 8) + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .environmentObject(timelineContext) + .environment(\.timelineContext, timelineContext) + // Make sure the reply header honours the hideTimelineMedia setting too. + .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) + + } } .overlay(alignment: .top) { Group { @@ -216,7 +224,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, timelineController: MockRoomTimelineController(), - mediaProvider: MediaProviderMock(configuration: .init()), + mediaProvider: MockMediaProvider(), mediaPlayerProvider: MediaPlayerProviderMock(), voiceMessageMediaManager: VoiceMessageMediaManagerMock(), userIndicatorController: ServiceLocator.shared.userIndicatorController, diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift new file mode 100644 index 0000000000..d788ff139d --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift @@ -0,0 +1,78 @@ +// +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. +// + +import SwiftUI + +struct RoomScreenFooterView: View { + let details: RoomScreenFooterViewDetails? + let mediaProvider: MediaProviderProtocol? + let callback: (RoomScreenFooterViewAction) -> Void + + var body: some View { + if let details { + ZStack(alignment: .top) { + VStack(spacing: 0) { + Color.compound.borderInfoSubtle + .frame(height: 1) + LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault], + startPoint: .top, + endPoint: .bottom) + } + + switch details { + case .pinViolation(let member, let learnMoreURL): + pinViolation(member: member, learnMoreURL: learnMoreURL) + } + } + .padding(.top, 8) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func pinViolation(member: RoomMemberProxyProtocol, + learnMoreURL: URL) -> some View { + VStack(spacing: 16) { + HStack(spacing: 16) { + LoadableAvatarImage(url: member.avatarURL, + name: member.disambiguatedDisplayName, + contentID: member.userID, + avatarSize: .user(on: .timeline), + mediaProvider: mediaProvider) + + Text(pinViolationDescriptionWithLearnMoreLink(displayName: member.disambiguatedDisplayName ?? member.userID, + url: learnMoreURL)) + .font(.compound.bodyMD) + .foregroundColor(.compound.textPrimary) + } + + Button(L10n.actionOk) { + callback(.resolvePinViolation(userID: member.userID)) + } + .buttonStyle(.compound(.primary, size: .medium)) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + + private func pinViolationDescriptionWithLearnMoreLink(displayName: String, url: URL) -> AttributedString { + let linkPlaceholder = "{link}" + var description = AttributedString(L10n.cryptoIdentityChangePinViolation(displayName, linkPlaceholder)) + var linkString = AttributedString(L10n.actionLearnMore) + linkString.link = url + linkString.bold() + description.replace(linkPlaceholder, with: linkString) + return description + } +} + +struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview { + static var previews: some View { + RoomScreenFooterView(details: .pinViolation(member: RoomMemberProxyMock.mockBob, learnMoreURL: "https://element.io/"), + mediaProvider: MockMediaProvider()) { _ in } + } +} diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 9555d62e8b..049f4c49bf 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -295,10 +295,12 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe roomProxy .actionsPublisher - .map { action -> (Bool, [String]) in + .compactMap { action -> (Bool, [String])? in switch action { case .roomInfoUpdate: return (roomProxy.hasOngoingCall, roomProxy.activeRoomCallParticipants) + default: + return nil } } .removeDuplicates { $0 == $1 } diff --git a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift index b1fcd8bf7c..d9d3ef07c2 100644 --- a/ElementX/Sources/Services/Room/JoinedRoomProxy.swift +++ b/ElementX/Sources/Services/Room/JoinedRoomProxy.swift @@ -58,6 +58,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { private var roomInfoObservationToken: TaskHandle? // periphery:ignore - required for instance retention in the rust codebase private var typingNotificationObservationToken: TaskHandle? + // periphery:ignore - required for instance retention in the rust codebase + private var identityStatusChangesObservationToken: TaskHandle? private var subscribedForUpdates = false @@ -70,6 +72,11 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { var typingMembersPublisher: CurrentValuePublisher<[String], Never> { typingMembersSubject.asCurrentValuePublisher() } + + private let identityStatusChangesSubject = CurrentValueSubject<[IdentityStatusChange], Never>([]) + var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> { + identityStatusChangesSubject.asCurrentValuePublisher() + } private let actionsSubject = PassthroughSubject() var actionsPublisher: AnyPublisher { @@ -193,6 +200,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { subscribeToRoomInfoUpdates() + subscribeToIdentityStatusChanges() + subscribeToTypingNotifications() } @@ -423,6 +432,18 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { } } + func pinCurrentIdentity(userID: String) async -> Result { + MXLog.info("Pinning current identity for user: \(userID)") + + do { + try await room.pinUserIdentity(userId: userID) + return .success(()) + } catch { + MXLog.error("Failed pinning current identity for user: \(error)") + return .failure(.sdkError(error)) + } + } + // MARK: - Room flags func flagAsUnread(_ isUnread: Bool) async -> Result { @@ -708,6 +729,16 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol { typingMembersSubject.send(typingMembers) }) } + + private func subscribeToIdentityStatusChanges() { + identityStatusChangesObservationToken = room.subscribeToIdentityStatusChanges(listener: RoomIdentityStatusChangeListener { [weak self] changes in + guard let self else { return } + + MXLog.info("Received identity status changes: \(changes)") + + identityStatusChangesSubject.send(changes) + }) + } } private final class RoomInfoUpdateListener: RoomInfoListener { @@ -733,3 +764,15 @@ private final class RoomTypingNotificationUpdateListener: TypingNotificationsLis onUpdateClosure(typingUserIds) } } + +private final class RoomIdentityStatusChangeListener: IdentityStatusChangeListener { + private let onUpdateClosure: ([IdentityStatusChange]) -> Void + + init(_ onUpdateClosure: @escaping ([IdentityStatusChange]) -> Void) { + self.onUpdateClosure = onUpdateClosure + } + + func call(identityStatusChange: [IdentityStatusChange]) { + onUpdateClosure(identityStatusChange) + } +} diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift index 856ec67ae2..e583a83c14 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxy.swift @@ -16,12 +16,24 @@ final class RoomMemberProxy: RoomMemberProxyProtocol { } var userID: String { member.userId } + var displayName: String? { member.displayName } + + var disambiguatedDisplayName: String? { + guard let displayName else { + return nil + } + + return member.isNameAmbiguous ? "\(displayName) (\(userID))" : displayName + } + var avatarURL: URL? { member.avatarUrl.flatMap(URL.init(string:)) } var membership: MembershipState { member.membership } + var isIgnored: Bool { member.isIgnored } var powerLevel: Int { Int(member.powerLevel) } + var role: RoomMemberRole { member.suggestedRoleForPowerLevel } } diff --git a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift index 6c1fe0afe2..817f6b57d4 100644 --- a/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomMember/RoomMemberProxyProtocol.swift @@ -11,13 +11,18 @@ import MatrixRustSDK // sourcery: AutoMockable protocol RoomMemberProxyProtocol: AnyObject { var userID: String { get } + var displayName: String? { get } + var disambiguatedDisplayName: String? { get } + var avatarURL: URL? { get } var membership: MembershipState { get } + var isIgnored: Bool { get } var powerLevel: Int { get } + var role: RoomMemberRole { get } } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index d2d99406cb..f2ed7e46c3 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -56,7 +56,7 @@ protocol InvitedRoomProxyProtocol: RoomProxyProtocol { func acceptInvitation() async -> Result } -enum JoinedRoomProxyAction { +enum JoinedRoomProxyAction: Equatable { case roomInfoUpdate } @@ -73,6 +73,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { var typingMembersPublisher: CurrentValuePublisher<[String], Never> { get } + var identityStatusChangesPublisher: CurrentValuePublisher<[IdentityStatusChange], Never> { get } + var actionsPublisher: AnyPublisher { get } var timeline: TimelineProxyProtocol { get } @@ -118,6 +120,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol { func withdrawVerificationAndResend(userIDs: [String], itemID: TimelineItemIdentifier) async -> Result + func pinCurrentIdentity(userID: String) async -> Result + // MARK: - Room Flags func flagAsUnread(_ isUnread: Bool) async -> Result diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/EncryptionAuthenticity.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/EncryptionAuthenticity.swift index 847e0ce9d5..67b50ea408 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/EncryptionAuthenticity.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/EncryptionAuthenticity.swift @@ -19,7 +19,7 @@ enum EncryptionAuthenticity: Hashable { case unknownDevice(color: Color) case unsignedDevice(color: Color) case unverifiedIdentity(color: Color) - case previouslyVerified(color: Color) + case verificationViolation(color: Color) case sentInClear(color: Color) var message: String { @@ -32,7 +32,7 @@ enum EncryptionAuthenticity: Hashable { L10n.eventShieldReasonUnsignedDevice case .unverifiedIdentity: L10n.eventShieldReasonUnverifiedIdentity - case .previouslyVerified: + case .verificationViolation: L10n.eventShieldReasonPreviouslyVerified case .sentInClear: L10n.eventShieldReasonSentInClear @@ -45,7 +45,7 @@ enum EncryptionAuthenticity: Hashable { .unknownDevice(let color), .unsignedDevice(let color), .unverifiedIdentity(let color), - .previouslyVerified(let color), + .verificationViolation(let color), .sentInClear(let color): color } @@ -54,7 +54,7 @@ enum EncryptionAuthenticity: Hashable { var icon: KeyPath { switch self { case .notGuaranteed: \.info - case .unknownDevice, .unsignedDevice, .unverifiedIdentity, .previouslyVerified: \.helpSolid + case .unknownDevice, .unsignedDevice, .unverifiedIdentity, .verificationViolation: \.helpSolid case .sentInClear: \.lockOff } } @@ -82,8 +82,8 @@ extension EncryptionAuthenticity { self = .unsignedDevice(color: color) case .unverifiedIdentity: self = .unverifiedIdentity(color: color) - case .previouslyVerified: - self = .previouslyVerified(color: color) + case .verificationViolation: + self = .verificationViolation(color: color) case .sentInClear: self = .sentInClear(color: color) } diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index 5819975f1c..615b072af3 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -689,6 +689,12 @@ extension PreviewTests { } } + func test_roomScreenFooterView() { + for preview in RoomScreenFooterView_Previews._allPreviews { + assertSnapshots(matching: preview) + } + } + func test_roomScreen() { for preview in RoomScreen_Previews._allPreviews { assertSnapshots(matching: preview) diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-en-GB.1.png new file mode 100644 index 0000000000..43ba189302 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ff5ac1cb8df1556b0d4ba1f119e65a182cb3ece6003f58db8a6129ef701dd94 +size 156835 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-pseudo.1.png new file mode 100644 index 0000000000..8e484ff041 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPad-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9693534082a1948f05d6f50c7e0cb8db768fdf6fe01189af4df64638edaae37 +size 166386 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-en-GB.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-en-GB.1.png new file mode 100644 index 0000000000..2019d816bd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-en-GB.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b02b43b978488a8e7b81d2767862a9a5289df638c502570557ef5f5e7a8dd4cc +size 77863 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-pseudo.1.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-pseudo.1.png new file mode 100644 index 0000000000..658879046e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/test_roomScreenFooterView-iPhone-16-pseudo.1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c7ab7912c6041b90042ed19c52ae21ee6ea4e88f5592b692607e7f8eb01adc40 +size 99897