Add history sharing badges to room details (#5043)

* chore: Add dependency `SwiftFlow` for use in avatar header badges.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* feat: Display history sharing pill in room details.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* tests: Add tests for history sharing room details pill.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* tests: Update screenshots for RoomDetailsScreen preview tests.

* fix: Use Localazy translations for room details badges

* fix: Address review comments

* fix: Remove line limit from room details badges

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
This commit is contained in:
Skye Elliot
2026-02-11 12:22:39 +00:00
committed by GitHub
parent 992cec91d6
commit 4841a3bdaf
57 changed files with 333 additions and 67 deletions
@@ -7,6 +7,7 @@
//
import Compound
import Flow
import SwiftUI
struct AvatarHeaderView<Footer: View>: View {
@@ -17,6 +18,7 @@ struct AvatarHeaderView<Footer: View>: View {
private enum Badge: Hashable {
case encrypted(Bool)
case historySharingState(RoomHistorySharingState)
case `public`
case verified
}
@@ -57,6 +59,9 @@ struct AvatarHeaderView<Footer: View>: View {
if room.isPublic {
badges.append(.public)
}
if let state = room.historySharingState {
badges.append(.historySharingState(state))
}
self.badges = badges
}
@@ -129,7 +134,10 @@ struct AvatarHeaderView<Footer: View>: View {
}
private var badgesStack: some View {
HStack(spacing: 8) {
HFlow(horizontalAlignment: .center,
verticalAlignment: .top,
horizontalSpacing: 8.0,
verticalSpacing: 8.0) {
ForEach(badges, id: \.self) { badge in
switch badge {
case .encrypted(true):
@@ -148,6 +156,18 @@ struct AvatarHeaderView<Footer: View>: View {
BadgeLabel(title: L10n.commonVerified,
icon: \.verified,
style: .accent)
case .historySharingState(.hidden):
BadgeLabel(title: L10n.cryptoHistorySharingRoomInfoHiddenBadgeContent,
icon: \.visibilityOff,
style: .info)
case .historySharingState(.shared):
BadgeLabel(title: L10n.cryptoHistorySharingRoomInfoSharedBadgeContent,
icon: \.history,
style: .info)
case .historySharingState(.worldReadable):
BadgeLabel(title: L10n.cryptoHistorySharingRoomInfoWorldReadableBadgeContent,
icon: \.userProfileSolid,
style: .info)
}
}
}
@@ -233,7 +253,8 @@ struct AvatarHeaderView_Previews: PreviewProvider, TestablePreview {
canonicalAlias: "#test:matrix.org",
isEncrypted: true,
isPublic: true,
isDirect: false),
isDirect: false,
historySharingState: nil),
avatarSize: .room(on: .details),
mediaProvider: MediaProviderMock(configuration: .init())) {
HStack(spacing: 32) {
@@ -279,5 +300,34 @@ struct AvatarHeaderView_Previews: PreviewProvider, TestablePreview {
.background(Color.compound.bgSubtleSecondaryLevel0)
.previewLayout(.sizeThatFits)
.previewDisplayName("Members")
makeHistorySharingPreview(state: .hidden).previewDisplayName("History Sharing - Hidden")
makeHistorySharingPreview(state: .shared).previewDisplayName("History Sharing - Shared")
makeHistorySharingPreview(state: .worldReadable).previewDisplayName("History Sharing - World Readable")
}
private static func makeHistorySharingPreview(state: RoomHistorySharingState) -> some View {
Form {
AvatarHeaderView(room: .init(id: "@test:matrix.org",
name: "Test Room",
avatar: .room(id: "@test:matrix.org",
name: "Test Room",
avatarURL: .mockMXCAvatar),
canonicalAlias: "#test:matrix.org",
isEncrypted: true,
isPublic: true,
isDirect: false,
historySharingState: state),
avatarSize: .room(on: .details),
mediaProvider: MediaProviderMock(configuration: .init())) {
HStack(spacing: 32) {
ShareLink(item: "test") {
CompoundIcon(\.shareIos)
}
.buttonStyle(FormActionButtonStyle(title: "Test"))
}
.padding(.top, 32)
}
}
}
}
@@ -83,6 +83,9 @@ struct BadgeLabel_Previews: PreviewProvider, TestablePreview {
BadgeLabel(title: "1234",
icon: \.userProfile,
style: .default)
BadgeLabel(title: "Very long text that potentially will wrap around in constrained environments, maybe into two or three lines, depending on the exact length of the text",
icon: \.userProfile,
style: .default)
}
}
}
@@ -82,9 +82,9 @@ struct RoomHeaderView: View {
private var historySharingIcon: KeyPath<CompoundIcons, Image>? {
switch roomHistorySharingState {
case .none, .hidden: nil
case .shared: \.history
case .worldReadable: \.userProfileSolid
case .none: nil
}
}
}
@@ -61,7 +61,13 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
let topic = attributedStringBuilder.fromPlain(roomProxy.infoPublisher.value.topic)
super.init(initialViewState: .init(details: roomProxy.details,
// Clear details.historySharingState manually while we are still behind a feature flag.
var details = roomProxy.details
if !appSettings.enableKeyShareOnInvite {
details.historySharingState = nil
}
super.init(initialViewState: .init(details: details,
isEncrypted: roomProxy.infoPublisher.value.isEncrypted,
isDirect: roomProxy.infoPublisher.value.isDirect,
topic: topic,
@@ -275,6 +281,14 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
state.details = roomProxy.details
// Set state.details.historySharingState manually while we are still behind
// a feature flag.
if appSettings.enableKeyShareOnInvite {
state.details.historySharingState = roomInfo.historySharingState
} else {
state.details.historySharingState = nil
}
let topic = attributedStringBuilder.fromPlain(roomInfo.topic)
state.topic = topic
state.topicSummary = topic?.unattributedStringByReplacingNewlinesWithSpaces()
@@ -328,17 +328,24 @@ struct RoomDetailsScreen: View {
// MARK: - Previews
import MatrixRustSDK
struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
static let genericRoomViewModel = makeGenericRoomViewModel()
static let genericWorldReadableRoomViewModel = makeGenericRoomViewModel(historyVisibility: .worldReadable)
static let genericJoinedRoomViewModel = makeGenericRoomViewModel(historyVisibility: .joined)
static let simpleRoomViewModel = makeSimpleRoomViewModel()
static let dmRoomViewModel = makeDMViewModel(verificationState: .notVerified)
static let dmRoomVerifiedViewModel = makeDMViewModel(verificationState: .verified)
static let dmRoomVerificationViolationViewModel = makeDMViewModel(verificationState: .verificationViolation)
static var previews: some View {
RoomDetailsScreen(context: genericRoomViewModel.context)
.snapshotPreferences(expect: genericRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
.previewDisplayName("Generic Room")
RoomDetailsScreen(context: genericJoinedRoomViewModel.context)
.snapshotPreferences(expect: genericJoinedRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
.previewDisplayName("Generic Room - Joined History Visibility")
RoomDetailsScreen(context: genericWorldReadableRoomViewModel.context)
.snapshotPreferences(expect: genericWorldReadableRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
.previewDisplayName("Generic Room - World Readable History Visibility")
RoomDetailsScreen(context: simpleRoomViewModel.context)
.snapshotPreferences(expect: simpleRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
@@ -357,7 +364,8 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
.previewDisplayName("DM Room Verification Violation")
}
private static func makeGenericRoomViewModel() -> RoomDetailsScreenViewModel {
private static func makeGenericRoomViewModel(historyVisibility: RoomHistoryVisibility) -> RoomDetailsScreenViewModel {
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
ServiceLocator.shared.settings.knockingEnabled = true
let knockRequests: [KnockRequestProxyMock] = [.init()]
@@ -381,6 +389,7 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
isDirect: false,
isEncrypted: true,
canonicalAlias: "#alias:domain.com",
historyVisibility: historyVisibility,
members: members,
knockRequestsState: .loaded(knockRequests),
joinRule: .knock))
@@ -400,6 +409,7 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
}
private static func makeSimpleRoomViewModel() -> RoomDetailsScreenViewModel {
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
ServiceLocator.shared.settings.knockingEnabled = true
let knockRequests: [KnockRequestProxyMock] = [.init()]
@@ -429,6 +439,8 @@ struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
}
private static func makeDMViewModel(verificationState: UserIdentityVerificationState) -> RoomDetailsScreenViewModel {
ServiceLocator.shared.settings.enableKeyShareOnInvite = true
let members: [RoomMemberProxyMock] = [
.mockMe,
.mockDan
@@ -16,4 +16,5 @@ struct RoomDetails {
let isEncrypted: Bool
let isPublic: Bool
let isDirect: Bool
var historySharingState: RoomHistorySharingState?
}
@@ -8,8 +8,13 @@
/// 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`.
/// The feature flag is set, and the room history visibility is either `invited` or `joined`. New
/// members of the room cannot read the room history.
case hidden
/// The feature flag is set, and the room history visibility is set to `shared`. New members of the
/// room can read the room history.
case shared
/// The feature flag is set, and the room history visibility is set to `world_readable`.
/// The feature flag is set, and the room history visibility is set to `world_readable`. Anyone
/// can read the room history.
case worldReadable
}
@@ -142,11 +142,13 @@ extension RoomInfoProxyProtocol {
return nil
}
return switch historyVisibility {
case .joined, .invited:
.hidden
case .shared:
.shared
case .worldReadable:
.worldReadable
default:
case .custom:
nil
}
}
@@ -196,13 +196,20 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
extension JoinedRoomProxyProtocol {
var details: RoomDetails {
RoomDetails(id: id,
name: infoPublisher.value.displayName,
avatar: infoPublisher.value.avatar,
canonicalAlias: infoPublisher.value.canonicalAlias,
isEncrypted: infoPublisher.value.isEncrypted,
isPublic: !(infoPublisher.value.isPrivate ?? false),
isDirect: infoPublisher.value.isDirect)
let historySharingState: RoomHistorySharingState? = if infoPublisher.value.isEncrypted {
infoPublisher.value.historySharingState
} else {
nil
}
return RoomDetails(id: id,
name: infoPublisher.value.displayName,
avatar: infoPublisher.value.avatar,
canonicalAlias: infoPublisher.value.canonicalAlias,
isEncrypted: infoPublisher.value.isEncrypted,
isPublic: !(infoPublisher.value.isPrivate ?? false),
isDirect: infoPublisher.value.isDirect,
historySharingState: historySharingState)
}
var isDirectOneToOneRoom: Bool {