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 88cd816392
commit 6153480d90
9 changed files with 297 additions and 9 deletions
@@ -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()
}
}