diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 96cd6a158..fc26261cc 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 @@ -173,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 @@ -206,7 +211,6 @@ final class AppSettings { private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18" /// A URL describing how history sharing works private(set) var historySharingDetailsURL: 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 6f70de9a2..ec7cb6f05 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -80,7 +80,12 @@ struct RoomScreenViewState: BindableState { (canAcceptKnocks || canDeclineKnocks || canBan) } - var footerDetails: RoomScreenFooterViewDetails? + var identityViolationDetails: RoomScreenFooterViewDetails? + var historyVisibleDetails: RoomScreenFooterViewDetails? + + var footerDetails: RoomScreenFooterViewDetails? { + identityViolationDetails ?? historyVisibleDetails + } var bindings = RoomScreenViewStateBindings() } @@ -93,11 +98,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 92ad22650..524da2f2b 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.historyVisibleDetails = nil } case .acceptKnock(let eventID): Task { await acceptKnock(eventID: eventID) } @@ -244,13 +247,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } if let member = identityVerificationViolations.values.first { - state.footerDetails = .verificationViolation(member: member, - learnMoreURL: appSettings.identityPinningViolationDetailsURL) + state.identityViolationDetails = .verificationViolation(member: member, + learnMoreURL: appSettings.identityPinningViolationDetailsURL) } else if let member = identityPinningViolations.values.first { - state.footerDetails = .pinViolation(member: member, - learnMoreURL: appSettings.identityPinningViolationDetailsURL) + state.identityViolationDetails = .pinViolation(member: member, + learnMoreURL: appSettings.identityPinningViolationDetailsURL) } else { - state.footerDetails = nil + state.identityViolationDetails = nil } } @@ -342,6 +345,20 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.canDeclineKnocks = powerLevels.canOwnUserKick() state.canBan = powerLevels.canOwnUserBan() } + + let isHistoryVisible = roomInfo.historyVisibility == .shared || roomInfo.historyVisibility == .worldReadable + let isHistoryVisibleBannerAcknowledged = appSettings.acknowledgedHistoryVisibleRooms.contains(roomInfo.id) + + if appSettings.enableKeyShareOnInvite, roomInfo.isEncrypted { + if isHistoryVisible, !isHistoryVisibleBannerAcknowledged { + // Whenever the user opens an encrypted room with shared/world-readable history visbility, we show them a warning banner if they have not already dismissed it. + state.historyVisibleDetails = .historyVisible(learnMoreURL: appSettings.historySharingDetailsURL) + } else if !isHistoryVisible, isHistoryVisibleBannerAcknowledged { + // Whenever the user opens a room with non-shared history visibility, we clear the dismiss flag to ensure that the banner is displayed again if the history is made visible in the future. + appSettings.acknowledgedHistoryVisibleRooms.remove(roomInfo.id) + state.historyVisibleDetails = nil + } + } } private func setupPinnedEventsTimelineItemProviderIfNeeded() { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreenFooterView.swift index b9574fed7..1702a7d64 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,7 @@ 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/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPad-en-GB.png new file mode 100644 index 000000000..d14e45ee9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79e30c512ac42d208dc253b969812b6d3f2d85be8676befde4dc67d8287aeebc +size 150023 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPad-pseudo.png new file mode 100644 index 000000000..2fcc2645c --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce717389949a6c0d853d27fdecbfc09b80dc239eae801c89ea1b0ba338bf6a61 +size 176311 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPhone-en-GB.png new file mode 100644 index 000000000..834708271 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef89b3e0c89f13371380a6f38e350c1b885c79a7c07b6fd215d7c56dbb71f6ef +size 78568 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPhone-pseudo.png new file mode 100644 index 000000000..15e3042c1 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreenFooterView.History-Visible-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09b99546030959ee3a87cda4dfda37b43bc537f7eb22725c4057c68f4a0d8ab9 +size 109019 diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index c99bc693c..9c6b5d3c0 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -438,4 +438,213 @@ 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: 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 + + let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.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 = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.footerDetails != nil } + try await deferred.fulfill() + } + + func testHistoryVisibleBannerDoesNotAppearIfJoinedOrInvited() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + ServiceLocator.shared.settings.acknowledgedHistoryVisibleRooms = Set() + + let configuration = JoinedRoomProxyMockConfiguration(isEncrypted: true) + 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 + + var deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.footerDetails != nil } + try await deferred.fulfill() + + // Update visibility to invited + roomInfoProxyMock.historyVisibility = .invited + infoSubject.send(roomInfoProxyMock) + + deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.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: 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 + + let deferred = deferFailure(viewModel.context.$viewState, timeout: 1) { $0.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() + } }