Files
Mauro 2d486ece33 Use iOS 26 Observation for Unit and Preview tests. (#5797)
* use observation in unit and preview tests instead of the async stream

* use a parsing approach that is independent of the sourcery annotation

* Fix observation deferred fulfillments not receiving the initial value

Observation's observe only emits transitions, not the value present at subscription time. Drop the leading initial value from the affected transitionValues sequences and assert it directly with #expect instead.

* improved docs
2026-06-30 14:01:08 +02:00

517 lines
23 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2022-2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct RoomDetailsScreen: View {
@Bindable var context: RoomDetailsScreenViewModel.Context
@State private var isTopicExpanded = false
var body: some View {
Form {
roomHeaderSection
topicSection
aboutSection
// The invitation flow is different for DMs
if context.viewState.dmRecipientInfo != nil {
inviteToNewRoomSection
profileSection
} else {
peopleSection
}
configurationSection
securitySection
if let recipient = context.viewState.dmRecipientInfo?.member {
ignoreUserSection(user: recipient)
} else if context.viewState.reportRoomEnabled {
reportRoomSection
}
leaveRoomSection
}
.compoundList()
.alert(item: $context.alertInfo)
.alert(item: $context.leaveRoomAlertItem,
actions: leaveRoomAlertActions,
message: leaveRoomAlertMessage)
.alert(item: $context.ignoreUserRoomAlertItem,
actions: blockUserAlertActions,
message: blockUserAlertMessage)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if context.viewState.canEditBaseInfo {
Button(L10n.actionEdit) {
context.send(viewAction: .processTapEdit)
}
}
}
}
.navigationTitle(L10n.screenRoomDetailsTitle)
.navigationBarTitleDisplayMode(.inline)
.track(screen: .RoomDetails)
.interactiveQuickLook(item: $context.mediaPreviewItem, allowEditing: false)
}
// MARK: - Private
private var roomHeaderSection: some View {
AvatarHeaderView(room: context.viewState.details,
avatarSize: .room(on: .details),
mediaProvider: context.mediaProvider) { url in
context.send(viewAction: .displayAvatar(url))
} footer: {
if !context.viewState.shortcuts.isEmpty {
headerSectionShortcuts
}
}
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.avatar)
}
private var headerSectionShortcuts: some View {
HStack(spacing: 8) {
ForEach(context.viewState.shortcuts, id: \.self) { shortcut in
switch shortcut {
case .mute:
toggleMuteButton
case .share(let permalink):
ShareLink(item: permalink) {
CompoundIcon(\.shareIos)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionShare))
case .voiceCall:
Button {
context.send(viewAction: .processTapCall(isVoiceCall: true))
} label: {
CompoundIcon(\.voiceCall)
}
.accessibilityLabel(L10n.a11yStartVoiceCall)
.buttonStyle(FormActionButtonStyle(title: L10n.actionCall))
case .videoCall:
Button {
context.send(viewAction: .processTapCall(isVoiceCall: false))
} label: {
CompoundIcon(\.videoCall)
}
.accessibilityLabel(L10n.a11yStartVideoCall)
.buttonStyle(FormActionButtonStyle(title: L10n.commonVideo))
case .invite:
Button {
context.send(viewAction: .processTapInvite)
} label: {
CompoundIcon(\.userAdd)
}
.buttonStyle(FormActionButtonStyle(title: L10n.actionInvite))
}
}
}
.padding(.top, 32)
}
@ViewBuilder
private var topicSection: some View {
if context.viewState.hasTopicSection {
Section {
if let topic = context.viewState.topic, !topic.characters.isEmpty, let topicSummary = context.viewState.topicSummary {
ListRow(kind: .custom {
Text(isTopicExpanded ? topic : topicSummary)
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.lineLimit(isTopicExpanded ? nil : 3)
.accentColor(.compound.textLinkExternal)
.padding(ListRowPadding.insets)
.textSelection(.enabled)
})
.onTapGesture {
isTopicExpanded.toggle()
}
} else {
ListRow(label: .plain(title: L10n.screenRoomDetailsAddTopicTitle),
kind: .button { context.send(viewAction: .processTapAddTopic) })
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.addTopic)
}
} header: {
Text(L10n.commonTopic)
.compoundListSectionHeader()
}
}
}
private var aboutSection: some View {
Section {
ListRow(label: .default(title: L10n.screenMediaBrowserTitle, icon: \.image),
kind: .navigationLink {
context.send(viewAction: .processTapMediaEvents)
})
ListRow(label: .default(title: L10n.screenRoomDetailsPinnedEventsRowTitle, icon: \.pin),
details: context.viewState.pinnedEventsActionState.isLoading ? .isWaiting(true) : .title(context.viewState.pinnedEventsActionState.count),
kind: context.viewState.pinnedEventsActionState.isLoading ? .label : .navigationLink {
context.send(viewAction: .processTapPinnedEvents)
})
.disabled(context.viewState.pinnedEventsActionState.isLoading)
ListRow(label: .default(title: L10n.screenPollsHistoryTitle, icon: \.polls),
kind: .navigationLink {
context.send(viewAction: .processTapPolls)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.pollsHistory)
}
}
private var inviteToNewRoomSection: some View {
Section {
ListRow(label: .default(title: L10n.actionInvite, icon: \.userAdd),
kind: .navigationLink {
context.send(viewAction: .processTapInvite)
})
}
}
private var profileSection: some View {
Section {
if context.viewState.dmRecipientInfo != nil {
let details: ListRowDetails? = switch context.viewState.dmRecipientInfo?.verificationState {
case .verified:
.icon(CompoundIcon(\.verified).foregroundStyle(.compound.iconSuccessPrimary))
case .verificationViolation:
.icon(CompoundIcon(\.infoSolid).foregroundStyle(.compound.iconCriticalPrimary))
case .notVerified, .none:
nil
}
ListRow(label: .default(title: L10n.screenRoomDetailsProfileRowTitle, icon: \.userProfile),
details: details,
kind: .navigationLink {
context.send(viewAction: .processTapRecipientProfile)
})
}
}
}
private var configurationSection: some View {
Section {
ListRow(label: .default(title: L10n.screenRoomDetailsNotificationTitle, icon: \.notifications),
details: context.viewState.notificationSettingsState.isLoading ? .isWaiting(true)
: context.viewState.notificationSettingsState.isError ? .systemIcon(.exclamationmarkCircle)
: .title(context.viewState.notificationSettingsState.label),
kind: .navigationLink {
context.send(viewAction: .processTapNotifications)
})
.disabled(context.viewState.notificationSettingsState.isLoading)
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.notifications)
ListRow(label: .default(title: L10n.commonFavourite, icon: \.favourite),
kind: .toggle($context.isFavourite))
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.favourite)
.onChange(of: context.isFavourite) { _, newValue in
context.send(viewAction: .toggleFavourite(isFavourite: newValue))
}
if context.viewState.canSeeSecurityAndPrivacy {
ListRow(label: .default(title: L10n.screenRoomDetailsSecurityAndPrivacyTitle, icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .processTapSecurityAndPrivacy)
})
}
}
}
private var peopleSection: some View {
Section {
if context.viewState.hasMemberIdentityVerificationStateViolations {
ListRow(label: .default(title: L10n.commonPeople, icon: \.user),
details: .icon(CompoundIcon(\.infoSolid).foregroundStyle(.compound.iconCriticalPrimary)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
} else {
ListRow(label: .default(title: L10n.commonPeople, icon: \.user),
details: .title(String(context.viewState.joinedMembersCount)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
}
if context.viewState.canSeeKnockingRequests {
ListRow(label: .default(title: L10n.screenRoomDetailsRequestsToJoinTitle, icon: \.askToJoin),
details: context.viewState.knockRequestsCount > 0 ? .counter(context.viewState.knockRequestsCount) : nil,
kind: .navigationLink {
context.send(viewAction: .processTapRequestsToJoin)
})
}
if context.viewState.canEditRolesOrPermissions, context.viewState.dmRecipientInfo == nil {
ListRow(label: .default(title: L10n.screenRoomDetailsRolesAndPermissions, icon: \.admin),
kind: .navigationLink {
context.send(viewAction: .processTapRolesAndPermissions)
})
}
}
}
private var toggleMuteButton: some View {
Button {
context.send(viewAction: .processToggleMuteNotifications)
} label: {
if context.viewState.isProcessingMuteToggleAction {
ProgressView()
} else {
CompoundIcon(context.viewState.notificationShortcutButtonIcon)
}
}
.buttonStyle(FormActionButtonStyle(title: context.viewState.notificationShortcutButtonTitle))
.disabled(context.viewState.isProcessingMuteToggleAction)
}
@ViewBuilder
private var securitySection: some View {
if context.viewState.isEncrypted {
Section {
ListRow(label: .default(title: L10n.screenRoomDetailsEncryptionEnabledTitle,
description: L10n.screenRoomDetailsEncryptionEnabledSubtitle,
icon: \.lock),
kind: .label)
.accessibilityAddTraits(.isHeader)
} header: {
Text(L10n.commonSecurity)
.compoundListSectionHeader()
}
}
}
private func ignoreUserSection(user: RoomMemberDetails) -> some View {
Section {
ListRow(label: .default(title: user.isIgnored ? L10n.screenDmDetailsUnblockUser : L10n.screenDmDetailsBlockUser,
icon: \.block,
role: user.isIgnored ? nil : .destructive),
details: .isWaiting(context.viewState.isProcessingIgnoreRequest),
kind: .button {
context.send(viewAction: user.isIgnored ? .processTapUnignore : .processTapIgnore)
})
.disabled(context.viewState.isProcessingIgnoreRequest)
}
}
private var reportRoomSection: some View {
Section {
if context.viewState.reportRoomEnabled {
ListRow(label: .action(title: L10n.actionReportRoom,
icon: \.chatProblem,
role: .destructive),
kind: .button { context.send(viewAction: .processTapReport) })
}
}
}
private var leaveRoomSection: some View {
Section {
ListRow(label: .action(title: L10n.screenRoomDetailsLeaveRoomTitle,
icon: \.leave,
role: .destructive),
kind: .button { context.send(viewAction: .processTapLeave) })
}
}
@ViewBuilder
private func leaveRoomAlertActions(_ item: LeaveRoomAlertItem) -> some View {
Button(item.cancelTitle, role: .cancel) { }
Button(item.confirmationTitle, role: .destructive) {
context.send(viewAction: .confirmLeave)
}
}
private func leaveRoomAlertMessage(_ item: LeaveRoomAlertItem) -> some View {
Text(item.subtitle)
}
@ViewBuilder
private func blockUserAlertActions(_ item: RoomDetailsScreenViewStateBindings.IgnoreUserAlertItem) -> some View {
Button(item.cancelTitle, role: .cancel) { }
Button(item.confirmationTitle,
role: item.action == .ignore ? .destructive : nil) {
context.send(viewAction: item.viewAction)
}
}
private func blockUserAlertMessage(_ item: RoomDetailsScreenViewStateBindings.IgnoreUserAlertItem) -> some View {
Text(item.description)
}
}
// MARK: - Previews
import MatrixRustSDK
@available(iOS 26.0, *)
struct RoomDetailsScreen_Previews: PreviewProvider, TestablePreview {
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: genericJoinedRoomViewModel.context)
.snapshotPreferences(expect: genericJoinedRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
.previewDisplayName("Generic Room - Joined History Visibility")
.previewLayout(.sizeThatFits)
.frame(height: 1400)
RoomDetailsScreen(context: genericWorldReadableRoomViewModel.context)
.snapshotPreferences(expect: genericWorldReadableRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
.previewDisplayName("Generic Room - World Readable History Visibility")
.previewLayout(.sizeThatFits)
.frame(height: 1400)
RoomDetailsScreen(context: simpleRoomViewModel.context)
.snapshotPreferences(expect: simpleRoomViewModel.context.observe(\.viewState.permalink).map { $0 != nil })
.previewDisplayName("Simple Room")
.previewLayout(.sizeThatFits)
.frame(height: 1200)
RoomDetailsScreen(context: dmRoomViewModel.context)
.snapshotPreferences(expect: dmRoomViewModel.context.observe(\.viewState.accountOwner).map { $0 != nil })
.previewDisplayName("DM Room")
.previewLayout(.sizeThatFits)
.frame(height: 1350)
RoomDetailsScreen(context: dmRoomVerifiedViewModel.context)
.snapshotPreferences(expect: dmRoomVerifiedViewModel.context.observe(\.viewState.dmRecipientInfo?.verificationState).map { $0 == .verified })
.previewDisplayName("DM Room Verified")
.previewLayout(.sizeThatFits)
.frame(height: 1350)
RoomDetailsScreen(context: dmRoomVerificationViolationViewModel.context)
.snapshotPreferences(expect: dmRoomVerificationViolationViewModel.context.observe(\.viewState.accountOwner).map { $0 != nil })
.previewDisplayName("DM Room Verification Violation")
.previewLayout(.sizeThatFits)
.frame(height: 1350)
}
private static func makeGenericRoomViewModel(historyVisibility: RoomHistoryVisibility) -> RoomDetailsScreenViewModel {
let knockRequests: [KnockRequestProxyMock] = [.init()]
let members: [RoomMemberProxyMock] = [
.mockMeAdmin,
.mockAlice,
.mockBob,
.mockCharlie
]
let roomProxy = JoinedRoomProxyMock(.init(id: "room_a_id",
name: "Room A",
topic: """
Discussions about Element X iOS | https://github.com/vector-im/element-x-ios
Feature Status: https://github.com/vector-im/element-x-ios/issues/1225
App Store: https://apple.co/3r6LJHZ
TestFlight: https://testflight.apple.com/join/uZbeZCOi
""",
isDirect: false,
isEncrypted: true,
canonicalAlias: "#alias:domain.com",
historyVisibility: historyVisibility,
members: members,
knockRequestsState: .loaded(knockRequests),
joinRule: .knock))
let notificationSettingsProxyMockConfiguration = NotificationSettingsProxyMockConfiguration()
notificationSettingsProxyMockConfiguration.roomMode.isDefault = false
let notificationSettingsProxy = NotificationSettingsProxyMock(with: notificationSettingsProxyMockConfiguration)
return .init(roomProxy: roomProxy,
userSession: UserSessionMock(.init()),
analyticsService: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
notificationSettingsProxy: notificationSettingsProxy,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appSettings: .volatile())
}
private static func makeSimpleRoomViewModel() -> RoomDetailsScreenViewModel {
let knockRequests: [KnockRequestProxyMock] = [.init()]
let members: [RoomMemberProxyMock] = [
.mockMeAdmin,
.mockAlice,
.mockBob,
.mockCharlie
]
let roomProxy = JoinedRoomProxyMock(.init(id: "simple_room_id",
name: "Room A",
isDirect: false,
isEncrypted: false,
members: members,
knockRequestsState: .loaded(knockRequests),
joinRule: .knock))
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
return .init(roomProxy: roomProxy,
userSession: UserSessionMock(.init()),
analyticsService: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
notificationSettingsProxy: notificationSettingsProxy,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appSettings: .volatile())
}
private static func makeDMViewModel(verificationState: UserIdentityVerificationState) -> RoomDetailsScreenViewModel {
let members: [RoomMemberProxyMock] = [
.mockMe,
.mockDan
]
let roomProxy = JoinedRoomProxyMock(.init(id: "dm_room_id",
name: "Dan",
topic: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
isDirect: true,
isEncrypted: true,
members: members,
heroes: [.mockDan]))
let clientProxyMock = ClientProxyMock(.init())
clientProxyMock.userIdentityForFallBackToServerClosure = { userID, _ in
let identity = switch userID {
case RoomMemberProxyMock.mockDan.userID:
UserIdentityProxyMock(.init(verificationState: verificationState))
default:
UserIdentityProxyMock(.init())
}
return .success(identity)
}
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
return .init(roomProxy: roomProxy,
userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
analyticsService: AnalyticsServiceMock(.init()),
userIndicatorController: UserIndicatorControllerMock(),
notificationSettingsProxy: notificationSettingsProxy,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
appSettings: .volatile())
}
}