Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions ElementX/Sources/Application/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class AppSettings {
case seenInvites
case hasSeenSpacesAnnouncement
case hasSeenNewSoundBanner
case hasSeenHistoryVisibleBannerRooms
case appLockNumberOfPINAttempts
case appLockNumberOfBiometricAttempts
case timelineStyle
Expand Down Expand Up @@ -125,6 +126,7 @@ final class AppSettings {
deviceVerificationURL: URL,
chatBackupDetailsURL: URL,
identityPinningViolationDetailsURL: URL,
historyVisibleDetailsURL: URL,
elementWebHosts: [String],
accountProvisioningHost: String,
bugReportApplicationID: String,
Expand All @@ -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
Expand Down Expand Up @@ -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.hasSeenHistoryVisibleBannerRooms, defaultValue: [], storageType: .userDefaults(store))
var hasSeenHistoryVisibleBannerRooms: 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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await resolveIdentityPinningViolation(userID) }
case .resolveVerificationViolation(let userID):
Task { await resolveIdentityVerificationViolation(userID) }
case .dismissHistoryVisibleAlert:
appSettings.hasSeenHistoryVisibleBannerRooms.insert(roomProxy.id)
state.footerDetails = nil
}
case .acceptKnock(let eventID):
Task { await acceptKnock(eventID: eventID) }
Expand Down Expand Up @@ -342,6 +345,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.canDeclineKnocks = powerLevels.canOwnUserKick()
state.canBan = powerLevels.canOwnUserBan()
}

// 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.hasSeenHistoryVisibleBannerRooms.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 roomInfo.isEncrypted, !appSettings.hasSeenHistoryVisibleBannerRooms.contains(roomInfo.id) {
state.footerDetails = .historyVisible(learnMoreURL: appSettings.historyVisibleDetailsURL)
}
}

private func setupPinnedEventsTimelineItemProviderIfNeeded() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct RoomScreenFooterView: View {

private var borderColor: Color {
switch details {
case .pinViolation:
case .pinViolation, .historyVisible:
.compound.borderInfoSubtle
case .verificationViolation:
.compound.borderCriticalSubtle
Expand All @@ -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])
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -166,12 +201,15 @@ 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")
RoomScreenFooterView(details: noNameDetails, mediaProvider: MediaProviderMock(configuration: .init())) { _ in }
.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")
}
}
1 change: 1 addition & 0 deletions ElementX/Sources/UITests/UITestsAppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
114 changes: 114 additions & 0 deletions UnitTests/Sources/RoomScreenViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,118 @@ class RoomScreenViewModelTests: XCTestCase {
}
try await deferred.fulfill()
}

// MARK: History Sharing

func testHistoryVisibleBannerDoesNotAppearIfNotEncrypted() async throws {
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 {
let configuration = JoinedRoomProxyMockConfiguration(isEncrypted: false)
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

let deferred = deferFulfillment(viewModel.context.$viewState) { state in
state.footerDetails == nil
}
try await deferred.fulfill()
}

func testHistoryVisibleBannerDoesNotAppearIfAcknowledged() async throws {
ServiceLocator.shared.settings.hasSeenHistoryVisibleBannerRooms.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<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 = deferFulfillment(viewModel.context.$viewState) { state in
state.footerDetails == nil
}
try await deferred.fulfill()
}

func testHistoryVisibleBannerAppearsThenDisappearsOnAcknowledge() async throws {
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.hasSeenHistoryVisibleBannerRooms.insert("$room:example.com")
viewModel.context.send(viewAction: RoomScreenViewAction.footerViewAction(RoomScreenFooterViewAction.dismissHistoryVisibleAlert))

try await deferred.fulfill()
}
}
Loading