diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 329432610..8f51741f3 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -327,6 +327,7 @@ 386720B603F87D156DB01FB2 /* VoiceMessageMediaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40076C770A5FB83325252973 /* VoiceMessageMediaManager.swift */; }; 388D39ED9FE1122EA6D76BF2 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1BC84BA0AF11C2128D58ABD /* Common.swift */; }; 3895969759E68FAB90C63EF7 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; + 38E0F9A4CB5237F039A2EEE6 /* RoomHistorySharingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */; }; 3982C505960006B341CFD0C6 /* UserDetailsEditScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D0EA07BD545CC9F234DB8D /* UserDetailsEditScreenModels.swift */; }; 3982E60F9C126437D5E488A3 /* PillContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A6314FDC51DA25712D9A81 /* PillContextTests.swift */; }; 39A987B3E41B976D1DF944C6 /* CallScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A63A59BFDDC494B1C20119 /* CallScreenViewModel.swift */; }; @@ -2163,6 +2164,7 @@ 71BC7CA1BC1041E93077BBA1 /* HomeScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenModels.swift; sourceTree = ""; }; 71D52BAA5BADB06E5E8C295D /* Assets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileScreenViewModelTests.swift; sourceTree = ""; }; + 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomHistorySharingState.swift; sourceTree = ""; }; 723B055A57857BFF0F18D9CB /* test_rotated_image.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = test_rotated_image.jpg; sourceTree = ""; }; 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemStatusView.swift; sourceTree = ""; }; 726E901DF76393E335FD7E8E /* ManageAuthorizedSpacesScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAuthorizedSpacesScreenViewModel.swift; sourceTree = ""; }; @@ -4030,6 +4032,7 @@ C07851F4EA81AA3339806A7B /* KnockRequestProxyProtocol.swift */, D78C13EF5035879B6131030F /* Room.swift */, B6404166CBF5CC88673FF9E2 /* RoomDetails.swift */, + 722013E20EEED73553EC6F8D /* RoomHistorySharingState.swift */, 40A66E8BC8D9AE4A08EFB2DF /* RoomInfoProxy.swift */, 14517E5597594956FCE1950D /* RoomInfoProxyProtocol.swift */, 974AEAF3FE0C577A6C04AD6E /* RoomPermissions.swift */, @@ -8459,6 +8462,7 @@ D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */, 8DF0EBD97753033C715D716E /* RoomFlowCoordinatorStateMachine.swift in Sources */, 9C63171267E22FEB288EC860 /* RoomHeaderView.swift in Sources */, + 38E0F9A4CB5237F039A2EEE6 /* RoomHistorySharingState.swift in Sources */, 5C8804B4F25903516E2DAB81 /* RoomInfoProxy.swift in Sources */, 432EA37BDC97CEDBAB7B23A6 /* RoomInfoProxyProtocol.swift in Sources */, 8A83D715940378B9BA9F739E /* RoomInviterLabel.swift in Sources */, diff --git a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift index 940803240..2d01d7a67 100644 --- a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift +++ b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift @@ -27,6 +27,7 @@ struct JoinedRoomProxyMockConfiguration { var canonicalAlias: String? var alternativeAliases: [String] = [] var pinnedEventIDs: Set = [] + var historyVisibility: RoomHistoryVisibility = .shared var timelineStartReached = false @@ -190,7 +191,7 @@ extension RoomInfoProxyMock { unreadMentionsCount = 0 pinnedEventIDs = configuration.pinnedEventIDs joinRule = configuration.joinRule - historyVisibility = .shared + historyVisibility = configuration.historyVisibility powerLevels = RoomPowerLevelsProxyMock(configuration: configuration.powerLevelsConfiguration) } diff --git a/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift b/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift index 3f85bb0e5..c569f17d8 100644 --- a/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift +++ b/ElementX/Sources/Other/SwiftUI/Views/RoomHeaderView.swift @@ -15,6 +15,7 @@ struct RoomHeaderView: View { var roomSubtitle: String? let roomAvatar: RoomAvatar var dmRecipientVerificationState: UserIdentityVerificationState? + var roomHistorySharingState: RoomHistorySharingState? let mediaProvider: MediaProviderProtocol? @@ -63,6 +64,11 @@ struct RoomHeaderView: View { if let dmRecipientVerificationState { VerificationBadge(verificationState: dmRecipientVerificationState, size: .xSmall, relativeTo: .compound.bodyMDSemibold) } + + if let historySharingIcon { + CompoundIcon(historySharingIcon, size: .xSmall, relativeTo: .compound.bodyMDSemibold) + .foregroundStyle(.compound.iconInfoPrimary) + } } } } @@ -73,6 +79,14 @@ struct RoomHeaderView: View { mediaProvider: mediaProvider) .accessibilityIdentifier(A11yIdentifiers.roomScreen.avatar) } + + private var historySharingIcon: KeyPath? { + switch roomHistorySharingState { + case .shared: \.history + case .worldReadable: \.userProfileSolid + case .none: nil + } + } } extension RoomHeaderView { @@ -106,19 +120,26 @@ struct RoomHeaderView_Previews: PreviewProvider, TestablePreview { makeHeader(avatarURL: .mockMXCAvatar, roomSubtitle: "Subtitle", verificationState: .verified) + makeHeader(avatarURL: .mockMXCAvatar, verificationState: .notVerified, historySharingState: .shared) + makeHeader(avatarURL: .mockMXCAvatar, verificationState: .notVerified, historySharingState: .worldReadable) + makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verified, historySharingState: .shared) + makeHeader(avatarURL: .mockMXCAvatar, verificationState: .verificationViolation, historySharingState: .worldReadable) } .previewLayout(.sizeThatFits) } static func makeHeader(avatarURL: URL?, roomSubtitle: String? = nil, - verificationState: UserIdentityVerificationState) -> some View { + verificationState: UserIdentityVerificationState, + historySharingState: RoomHistorySharingState? = nil) -> some View { RoomHeaderView(roomName: "Some Room name", roomSubtitle: roomSubtitle, roomAvatar: .room(id: "1", name: "Some Room Name", avatarURL: avatarURL), dmRecipientVerificationState: verificationState, + roomHistorySharingState: historySharingState, + mediaProvider: MediaProviderMock(configuration: .init())) { } .padding() } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 720e5d726..fbc6d9658 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -7,6 +7,7 @@ // import Foundation +import MatrixRustSDK import OrderedCollections enum RoomScreenViewModelAction: Equatable { @@ -80,6 +81,9 @@ struct RoomScreenViewState: BindableState { (canAcceptKnocks || canDeclineKnocks || canBan) } + /// If `enableKeyShareOnInvite` is set, determines the current history sharing state. + var roomHistorySharingState: RoomHistorySharingState? + var footerDetails: RoomScreenFooterViewDetails? var bindings = RoomScreenViewStateBindings() diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 92ad22650..5ef834065 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -67,10 +67,17 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.initialSelectedPinnedEventID = initialSelectedPinnedEventID pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID) + let roomHistorySharingState: RoomHistorySharingState? = if appSettings.enableKeyShareOnInvite { + roomProxy.infoPublisher.value.historySharingState + } else { + nil + } + let viewState = RoomScreenViewState(roomTitle: roomProxy.infoPublisher.value.displayName ?? roomProxy.id, roomAvatar: roomProxy.infoPublisher.value.avatar, hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall, - hasSuccessor: roomProxy.infoPublisher.value.successor != nil) + hasSuccessor: roomProxy.infoPublisher.value.successor != nil, + roomHistorySharingState: roomHistorySharingState) super.init(initialViewState: appHooks.roomScreenHook.update(viewState), mediaProvider: userSession.mediaProvider) @@ -342,6 +349,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.canDeclineKnocks = powerLevels.canOwnUserKick() state.canBan = powerLevels.canOwnUserBan() } + + // This causes the UI to become inconsistent with the user's mental model if the user + // does not restart the app after disabling the feature flag. We can probably ignore + // such cases, since we explicitly ask for an app restart in the caption of the feature + // flag switch. + if appSettings.enableKeyShareOnInvite { + state.roomHistorySharingState = roomInfo.historySharingState + } } private func setupPinnedEventsTimelineItemProviderIfNeeded() { diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index cc2f8942a..050c14ebe 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -157,6 +157,7 @@ struct RoomScreen: View { RoomHeaderView(roomName: context.viewState.roomTitle, roomAvatar: context.viewState.roomAvatar, dmRecipientVerificationState: context.viewState.dmRecipientVerificationState, + roomHistorySharingState: context.viewState.roomHistorySharingState, mediaProvider: context.mediaProvider) { context.send(viewAction: .displayRoomDetails) } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift index 8690f8b33..71c190f47 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift @@ -17,6 +17,7 @@ struct ThreadTimelineScreenViewState: BindableState { var roomAvatar: RoomAvatar var canSendMessage = true var dmRecipientVerificationState: UserIdentityVerificationState? + var roomHistorySharingState: RoomHistorySharingState? var bindings = ThreadTimelineScreenViewStateBindings() } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index 81bfeb713..ed7633f32 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -65,6 +65,7 @@ struct ThreadTimelineScreen: View { roomSubtitle: context.viewState.roomTitle, roomAvatar: context.viewState.roomAvatar, dmRecipientVerificationState: context.viewState.dmRecipientVerificationState, + roomHistorySharingState: context.viewState.roomHistorySharingState, mediaProvider: context.mediaProvider) { // There is no action but the iOS 26 designs have it looking like a button. } diff --git a/ElementX/Sources/Services/Room/RoomHistorySharingState.swift b/ElementX/Sources/Services/Room/RoomHistorySharingState.swift new file mode 100644 index 000000000..0c3473c21 --- /dev/null +++ b/ElementX/Sources/Services/Room/RoomHistorySharingState.swift @@ -0,0 +1,15 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +/// Enumeration of the two possible cases in which history sharing under MSC4268 is enabled. These +/// variants implicitly assume that the feature flag, `enableKeyShareOnInvite`, is set. +enum RoomHistorySharingState: Equatable { + /// The feature flag is set, and the room history visibility is set to `shared`. + case shared + /// The feature flag is set, and the room history visibility is set to `world_readable`. + case worldReadable +} diff --git a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift index b6bce5d64..6c10744e7 100644 --- a/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomInfoProxyProtocol.swift @@ -134,4 +134,20 @@ extension RoomInfoProxyProtocol { // And finally return whatever the first alternative alias is return alternativeAliases.first } + + /// If present, the state of history sharing in this room. This *does not* consider the `enableKeyShareOnInvite` + /// feature flag, so consumers should be careful to check the flag is true before utilising this property. + var historySharingState: RoomHistorySharingState? { + guard isEncrypted else { + return nil + } + return switch historyVisibility { + case .shared: + .shared + case .worldReadable: + .worldReadable + default: + nil + } + } } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-en-GB-0.png index 2b96425d4..5b191261d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e0e6a96f699edf80a25da1158581da37cfa3ee2e44a404c51a20a9305c77f77 -size 101376 +oid sha256:25d819f52ad5fb46aacca695198b7d2fa275a7b205c08bdebc8c1724cf6a2c4b +size 191605 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-pseudo-0.png index 2b96425d4..5b191261d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPad-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e0e6a96f699edf80a25da1158581da37cfa3ee2e44a404c51a20a9305c77f77 -size 101376 +oid sha256:25d819f52ad5fb46aacca695198b7d2fa275a7b205c08bdebc8c1724cf6a2c4b +size 191605 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-en-GB-0.png index b2ee63094..4d45f8300 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-en-GB-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-en-GB-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37b7a926dcf3071ff7a5eed78141438240ebd99e2d4fb0b474156d0f261580d3 -size 76207 +oid sha256:ee0792530f8ff2020d174a79b0d6f2edd6ec3f970d01f71cc54292ad91b7fc4a +size 145478 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-pseudo-0.png index b2ee63094..4d45f8300 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-pseudo-0.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomHeaderView.iPhone-pseudo-0.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37b7a926dcf3071ff7a5eed78141438240ebd99e2d4fb0b474156d0f261580d3 -size 76207 +oid sha256:ee0792530f8ff2020d174a79b0d6f2edd6ec3f970d01f71cc54292ad91b7fc4a +size 145478 diff --git a/UnitTests/Sources/RoomScreenViewModelTests.swift b/UnitTests/Sources/RoomScreenViewModelTests.swift index b79bc6993..3fd25e3ef 100644 --- a/UnitTests/Sources/RoomScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomScreenViewModelTests.swift @@ -438,4 +438,94 @@ class RoomScreenViewModelTests: XCTestCase { } try await deferred.fulfill() } + + // MARK: - History Sharing + + func testRoomWithSharedHistoryDoesNotDisplayBadgeIfFeatureFlagNotSet() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = false + + var configuration = JoinedRoomProxyMockConfiguration(historyVisibility: .joined) + let infoSubject = CurrentValueSubject(RoomInfoProxyMock(configuration)) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + // setup the room proxy actions publisher + 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 deferredInvisible = deferFailure(viewModel.context.$viewState, + timeout: 1, + message: "The icon should not be shown when the room history visibility is not .shared or .worldReadable") { viewState in + viewState.roomHistorySharingState != nil + } + try await deferredInvisible.fulfill() + + configuration.historyVisibility = .shared + infoSubject.send(RoomInfoProxyMock(configuration)) + let deferredShared = deferFailure(viewModel.context.$viewState, + timeout: 1, + message: "The icon should not be shown when the room history visibility is .shared, since the flag isn't set") { viewState in + viewState.roomHistorySharingState != nil + } + try await deferredShared.fulfill() + } + + func testRoomWithSharedHistoryDisplaysBadgeWhenFeatureFlagSet() async throws { + ServiceLocator.shared.settings.enableKeyShareOnInvite = true + + var configuration = JoinedRoomProxyMockConfiguration(isEncrypted: false, historyVisibility: .joined) + let infoSubject = CurrentValueSubject(RoomInfoProxyMock(configuration)) + let roomProxyMock = JoinedRoomProxyMock(configuration) + + // setup the room proxy actions publisher + 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 deferredInvisible = deferFailure(viewModel.context.$viewState, + timeout: 1, + message: "The icon should be hidden when the room history visibility is not .shared or .worldReadable") { viewState in + viewState.roomHistorySharingState != nil + } + try await deferredInvisible.fulfill() + + configuration.historyVisibility = .shared + infoSubject.send(RoomInfoProxyMock(configuration)) + let deferredInvisibleUnencrypted = deferFailure(viewModel.context.$viewState, + timeout: 1, + message: "The icon should not be shown when the room is unencrypted") { viewState in + viewState.roomHistorySharingState != nil + } + try await deferredInvisibleUnencrypted.fulfill() + + configuration.isEncrypted = true + infoSubject.send(RoomInfoProxyMock(configuration)) + let deferredShared = deferFulfillment(viewModel.context.$viewState, + message: "The icon should be shown when the room history visibility is .shared") { viewState in + viewState.roomHistorySharingState == .shared + } + try await deferredShared.fulfill() + + configuration.historyVisibility = .worldReadable + infoSubject.send(RoomInfoProxyMock(configuration)) + let deferredWorldReadable = deferFulfillment(viewModel.context.$viewState, + message: "The icon should be shown when the room history visibility is .worldReadable") { viewState in + viewState.roomHistorySharingState == .worldReadable + } + try await deferredWorldReadable.fulfill() + } }