diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 51288d59bf..0aef119bfc 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -32,6 +32,7 @@ final class AppSettings { case seenInvites case hasSeenSpacesAnnouncement case hasSeenNewSoundBanner + case acknowledgedHistoryVisibleRooms case appLockNumberOfPINAttempts case appLockNumberOfBiometricAttempts case timelineStyle @@ -125,6 +126,7 @@ final class AppSettings { deviceVerificationURL: URL, chatBackupDetailsURL: URL, identityPinningViolationDetailsURL: URL, + historyVisibleDetailsURL: URL, elementWebHosts: [String], accountProvisioningHost: String, bugReportApplicationID: String, @@ -144,6 +146,7 @@ final class AppSettings { self.deviceVerificationURL = deviceVerificationURL self.chatBackupDetailsURL = chatBackupDetailsURL self.identityPinningViolationDetailsURL = identityPinningViolationDetailsURL + self.historyVisibleDetailsURL = historyVisibleDetailsURL self.elementWebHosts = elementWebHosts self.accountProvisioningHost = accountProvisioningHost self.bugReportApplicationID = bugReportApplicationID @@ -171,6 +174,10 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.hasSeenNewSoundBanner, defaultValue: true, storageType: .userDefaults(store)) var hasSeenNewSoundBanner + /// The Set of room identifiers that the user has acknowledged have visible history. + @UserPreference(key: UserDefaultsKeys.acknowledgedHistoryVisibleRooms, defaultValue: [], storageType: .userDefaults(store)) + var acknowledgedHistoryVisibleRooms: Set + /// The initial set of account providers shown to the user in the authentication flow. /// /// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should @@ -202,6 +209,8 @@ final class AppSettings { private(set) var chatBackupDetailsURL: URL = "https://element.io/help#encryption5" /// A URL where users can go read more about identity pinning violations private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18" + /// A URL where users can go to read more about room history sharing. + private(set) var historyVisibleDetailsURL: URL = "https://element.io/en/help#e2ee-history-sharing" /// Any domains that Element web may be hosted on - used for handling links. private(set) var elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"] /// The domain that account provisioning links will be hosted on - used for handling the links. diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 6f70de9a29..0d6463de36 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -93,11 +93,13 @@ struct RoomScreenViewStateBindings { enum RoomScreenFooterViewAction { case resolvePinViolation(userID: String) case resolveVerificationViolation(userID: String) + case dismissHistoryVisibleAlert } enum RoomScreenFooterViewDetails { case pinViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL) case verificationViolation(member: RoomMemberProxyProtocol, learnMoreURL: URL) + case historyVisible(learnMoreURL: URL) } enum PinnedEventsBannerState: Equatable { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 92ad226508..9f0c5d9524 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -101,6 +101,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol Task { await resolveIdentityPinningViolation(userID) } case .resolveVerificationViolation(let userID): Task { await resolveIdentityVerificationViolation(userID) } + case .dismissHistoryVisibleAlert: + appSettings.acknowledgedHistoryVisibleRooms.insert(roomProxy.id) + state.footerDetails = nil } case .acceptKnock(let eventID): Task { await acceptKnock(eventID: eventID) } @@ -342,6 +345,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.canDeclineKnocks = powerLevels.canOwnUserKick() state.canBan = powerLevels.canOwnUserBan() } + + if appSettings.enableKeyShareOnInvite { + // Whever the user opens a room with joined history visibility, we clear the dismiss flag to ensure that the banner is displayed again if the history is made visible in the future. + if roomInfo.historyVisibility == RoomHistoryVisibility.joined { + appSettings.acknowledgedHistoryVisibleRooms.remove(roomInfo.id) + state.footerDetails = nil + } + // Whenever the user opens an encrypted room with non-join history visbility, we show them a warning banner if they have not already dismissed it. + else if appSettings.enableKeyShareOnInvite, roomInfo.isEncrypted, !appSettings.acknowledgedHistoryVisibleRooms.contains(roomInfo.id) { + state.footerDetails = .historyVisible(learnMoreURL: appSettings.historyVisibleDetailsURL) + } + } } private func setupPinnedEventsTimelineItemProviderIfNeeded() { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift index b9574fed7b..939f182b91 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift @@ -16,7 +16,7 @@ struct RoomScreenFooterView: View { private var borderColor: Color { switch details { - case .pinViolation: + case .pinViolation, .historyVisible: .compound.borderInfoSubtle case .verificationViolation: .compound.borderCriticalSubtle @@ -27,7 +27,7 @@ struct RoomScreenFooterView: View { private var gradient: Gradient { switch details { - case .pinViolation: + case .pinViolation, .historyVisible: .compound.info case .verificationViolation: Gradient(colors: [.compound.bgCriticalSubtle, .clear]) @@ -54,6 +54,8 @@ struct RoomScreenFooterView: View { pinViolation(member: member, learnMoreURL: learnMoreURL) case .verificationViolation(member: let member, learnMoreURL: let learnMoreURL): verificationViolation(member: member, learnMoreURL: learnMoreURL) + case .historyVisible(learnMoreURL: let learnMoreURL): + historyVisibleAlert(learnMoreURL: learnMoreURL) } } @@ -151,10 +153,43 @@ struct RoomScreenFooterView: View { return description } + private func historyVisibleAlertDescriptionWithLearnMoreLink(learnMoreURL: URL) -> AttributedString { + let linkPlaceholder = "{link}" + var description = AttributedString(L10n.cryptoHistoryVisible(linkPlaceholder)) + var linkString = AttributedString(L10n.actionLearnMore) + linkString.link = learnMoreURL + linkString.bold() + description.replace(linkPlaceholder, with: linkString) + return description + } + private func fallbackDisplayName(_ userID: String) -> String { guard let localpart = userID.components(separatedBy: ":").first else { return userID } return String(localpart.trimmingPrefix("@")) } + + private func historyVisibleAlert(learnMoreURL: URL) -> some View { + let description = historyVisibleAlertDescriptionWithLearnMoreLink(learnMoreURL: learnMoreURL) + + return VStack(spacing: 16) { + HStack(spacing: 16) { + CompoundIcon(\.info).foregroundColor(.compound.iconInfoPrimary) + Text(description) + .font(.compound.bodyMD) + .foregroundColor(.compound.textInfoPrimary) + } + Button { + callback(.dismissHistoryVisibleAlert) + } label: { + Text(L10n.actionDismiss) + .frame(maxWidth: .infinity) + } + .buttonStyle(.compound(.primary, size: .medium)) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } } struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview { @@ -166,6 +201,8 @@ struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview { static let verificationViolationDetails: RoomScreenFooterViewDetails = .verificationViolation(member: RoomMemberProxyMock.mockBob, learnMoreURL: "https://element.io/") + static let historyVisibleDetails: RoomScreenFooterViewDetails = .historyVisible(learnMoreURL: "https://element.io") + static var previews: some View { RoomScreenFooterView(details: bobDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in } .previewDisplayName("With displayname") @@ -173,5 +210,6 @@ struct RoomScreenFooterView_Previews: PreviewProvider, TestablePreview { .previewDisplayName("Without displayname") RoomScreenFooterView(details: verificationViolationDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in } .previewDisplayName("Verification Violation") + RoomScreenFooterView(details: historyVisibleDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }.previewDisplayName("History Visible") } } diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 477f71686d..9bbdabe127 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -140,6 +140,7 @@ class MockScreen: Identifiable { deviceVerificationURL: appSettings.deviceVerificationURL, chatBackupDetailsURL: appSettings.chatBackupDetailsURL, identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL, + historyVisibleDetailsURL: appSettings.historyVisibleDetailsURL, elementWebHosts: appSettings.elementWebHosts, accountProvisioningHost: appSettings.accountProvisioningHost, bugReportApplicationID: appSettings.bugReportApplicationID, diff --git a/Enterprise b/Enterprise index e2c37065c6..6fafddc467 160000 --- a/Enterprise +++ b/Enterprise @@ -1 +1 @@ -Subproject commit e2c37065c61558b89129f90d71bd772bc30d3b22 +Subproject commit 6fafddc4676d14efcf13d95682e75353e633f368 diff --git a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift index 8a3021e513..c08ad4c4c1 100644 --- a/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift +++ b/UnitTests/Sources/AuthenticationStartScreenViewModelTests.swift @@ -172,6 +172,7 @@ class AuthenticationStartScreenViewModelTests: XCTestCase { deviceVerificationURL: appSettings.deviceVerificationURL, chatBackupDetailsURL: appSettings.chatBackupDetailsURL, identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL, + historyVisibleDetailsURL: appSettings.historyVisibleDetailsURL, elementWebHosts: appSettings.elementWebHosts, accountProvisioningHost: appSettings.accountProvisioningHost, bugReportApplicationID: appSettings.bugReportApplicationID, diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index c99bc693c5..51ca8dfc9d 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -438,4 +438,214 @@ class RoomScreenViewModelTests: XCTestCase { } try await deferred.fulfill() } + + // MARK: History Sharing + + func testHistoryVisibleBannerDoesNotAppearIfFeatureDisabled() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = false + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + + let configuration = JoinedRoomProxyMockConfiguration(isEncrypted: false) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + let roomInfoProxyMock = RoomInfoProxyMock(configuration) + roomInfoProxyMock.historyVisibility = .shared + + let infoSubject = CurrentValueSubject(roomInfoProxyMock) + roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher() + + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + self.viewModel = viewModel + + let deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + try await deferred.fulfill() + } + + func testHistoryVisibleBannerDoesNotAppearIfNotEncrypted() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + + let roomProxyMock = JoinedRoomProxyMock(.init(isEncrypted: false)) + + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + self.viewModel = viewModel + + let deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + try await deferred.fulfill() + } + + func testHistoryVisibleBannerDoesNotAppearIfJoined() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + + let configuration = JoinedRoomProxyMockConfiguration(isEncrypted: false) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + let roomInfoProxyMock = RoomInfoProxyMock(configuration) + roomInfoProxyMock.historyVisibility = .joined + + let infoSubject = CurrentValueSubject(roomInfoProxyMock) + roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher() + + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + self.viewModel = viewModel + + let deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + try await deferred.fulfill() + } + + func testHistoryVisibleBannerDoesNotAppearIfAcknowledged() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms.insert("$room:example.com") + + let configuration = JoinedRoomProxyMockConfiguration(id: "$room:example.com", isEncrypted: false) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + let roomInfoProxyMock = RoomInfoProxyMock(configuration) + roomInfoProxyMock.historyVisibility = .shared + + let infoSubject = CurrentValueSubject(roomInfoProxyMock) + roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher() + + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + self.viewModel = viewModel + + let deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + try await deferred.fulfill() + } + + func testHistoryVisibleBannerAppearsThenDisappearsOnAcknowledge() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + + let configuration = JoinedRoomProxyMockConfiguration(id: "$room:example.com", isEncrypted: true) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + let roomInfoProxyMock = RoomInfoProxyMock(configuration) + roomInfoProxyMock.historyVisibility = .shared + + let infoSubject = CurrentValueSubject(roomInfoProxyMock) + roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher() + + let viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + self.viewModel = viewModel + + var deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails != nil + } + try await deferred.fulfill() + + deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms.insert("$room:example.com") + viewModel.context.send(viewAction: .footerViewAction(RoomScreenFooterViewAction.dismissHistoryVisibleAlert)) + + try await deferred.fulfill() + } + + func testHistoryVisibleBannerAppearsFullFlow() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = false + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + + let configuration = JoinedRoomProxyMockConfiguration(id: "$room:example.com", isEncrypted: true) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + let roomInfoProxyMock = RoomInfoProxyMock(configuration) + roomInfoProxyMock.historyVisibility = .joined + + let infoSubject = CurrentValueSubject(roomInfoProxyMock) + roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher() + + var viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + + self.viewModel = viewModel + + // When the history is not shared, the banner should not be visible. + var deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + try await deferred.fulfill() + + roomInfoProxyMock.historyVisibility = .shared + infoSubject.send(roomInfoProxyMock) + + // When the feature is off, the banner should not be visible. + deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails == nil + } + try await deferred.fulfill() + + // When the history is shared, and the feature is on, the banner should be visible. + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + viewModel = RoomScreenViewModel(userSession: UserSessionMock(.init()), + roomProxy: roomProxyMock, + initialSelectedPinnedEventID: nil, + ongoingCallRoomIDPublisher: .init(.init(nil)), + appSettings: ServiceLocator.shared.settings, + appHooks: AppHooks(), + analyticsService: ServiceLocator.shared.analytics, + userIndicatorController: ServiceLocator.shared.userIndicatorController) + deferred = deferFulfillment(viewModel.context.$viewState) { state in + state.footerDetails != nil + } + try await deferred.fulfill() + } } diff --git a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift index 3ee1bea2aa..8e5a2b4f8e 100644 --- a/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift +++ b/UnitTests/Sources/ServerConfirmationScreenViewModelTests.swift @@ -324,6 +324,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase { deviceVerificationURL: appSettings.deviceVerificationURL, chatBackupDetailsURL: appSettings.chatBackupDetailsURL, identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL, + historyVisibleDetailsURL: appSettings.historyVisibleDetailsURL, elementWebHosts: appSettings.elementWebHosts, accountProvisioningHost: appSettings.accountProvisioningHost, bugReportApplicationID: appSettings.bugReportApplicationID,