Add a banner to encrypted rooms with visible history. (#4851)

* Add a banner to encrypted rooms with visible history. (#4738)

* feat: Add history visible alert.

- Adds a dismissable alert that is displayed whenever the user
  opens a room with `history_visibility` != `joined`. When cleared,
  this is recorded in the app's data store.
- When opening a room with `history_visibility` = `joined`, this
  flag is cleared.

Issue: element-hq/element-meta#2875

* tests: Add unit tests for history sharing in `RoomScreenFooterView`.

* feat: Rename flag to `hasSeenHistoryVisibleBannerRooms`, document.

* refactor: Merge enum variants, use function for banner description.

* feat: Use `AppSettings.historyVisibleDetailsURL` over hard-coded value.

* tests: Correct potential race condition with deferred assertion.

* chore: Use Localazy translation string over project-defined.

* fix: Final tweaks and review comments.

* chore: Checkout `Enterprise` submodule.

* tests: Final fixes.

* fix: Condition banner visibility on feature flag state.

* fix: Prioritise identity violations over history visibility banner.

* tests: Add snapshots for history visible banner.

* tests: Use deferred failure timeout for improved test power.

* chore: Tweaks to spelling, simplify state logic.

* fix: Remove "g".

* fix: Show banner for shared/world-readable rooms, not invited.

* refactor: Use `else-if` instead of `if`.
This commit is contained in:
Skye Elliot
2025-12-19 15:26:45 +00:00
committed by GitHub
parent 3626f94289
commit ae38dc54d4
9 changed files with 297 additions and 9 deletions

View File

@@ -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<String>
/// 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.

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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")
}
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:79e30c512ac42d208dc253b969812b6d3f2d85be8676befde4dc67d8287aeebc
size 150023

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce717389949a6c0d853d27fdecbfc09b80dc239eae801c89ea1b0ba338bf6a61
size 176311

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ef89b3e0c89f13371380a6f38e350c1b885c79a7c07b6fd215d7c56dbb71f6ef
size 78568

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:09b99546030959ee3a87cda4dfda37b43bc537f7eb22725c4057c68f4a0d8ab9
size 109019

View File

@@ -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<RoomInfoProxyProtocol, Never>(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<RoomInfoProxyProtocol, Never>(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<RoomInfoProxyProtocol, Never>(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<RoomInfoProxyProtocol, Never>(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<RoomInfoProxyProtocol, Never>(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()
}
}