Files
letro-ios/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift
Mauro 56eec826df Fix A11y tests (#5104)
* replace NavigationStack with ElementNavigationStack to allow the content to be rendered without a NavigationStack in a11y tests

* fix a11y tests

* update xcodeproject

* swiftformat fix

* use iOS 26.1 for CI

* use a wrapper to solve the issue for a11y tests

* ElementNavigationStack only uses the trick in DEBUG mode, and added a swiftlint rule to prevent the usage of NavigationStack
2026-02-13 16:45:58 +01:00

267 lines
9.9 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 RoomMembersListScreen: View {
@ObservedObject var context: RoomMembersListScreenViewModel.Context
var body: some View {
ScrollView {
if context.viewState.canBanUsers, context.viewState.bannedMembersCount > 0 {
Picker("", selection: $context.mode) {
Text(L10n.screenRoomMemberListModeMembers)
.tag(RoomMembersListScreenMode.members)
Text(L10n.screenRoomMemberListModeBanned)
.tag(RoomMembersListScreenMode.banned)
}
.pickerStyle(.segmented)
.padding(ListRowPadding.insets)
}
if context.viewState.shouldShowEmptyState {
emptySearchView
} else {
Spacer()
.frame(height: 18)
switch context.mode {
case .members:
roomMembers
case .banned:
bannedUsers
}
}
}
.compoundList()
.searchable(text: $context.searchQuery,
placement: .navigationBarDrawer(displayMode: .always),
prompt: L10n.commonSearchForSomeone)
.compoundSearchField()
.autocorrectionDisabled()
.navigationTitle(L10n.commonPeople)
.navigationBarTitleDisplayMode(.inline)
.sheet(item: $context.manageMemeberViewModel) {
ManageRoomMemberSheetView(context: $0.context)
}
.alert(item: $context.alertInfo)
.toolbar { toolbar }
.track(screen: .RoomMembers)
}
// MARK: - Private
@ViewBuilder
var roomMembers: some View {
membersSection(entries: context.viewState.visibleInvitedMembers, section: .invited)
membersSection(entries: context.viewState.visibleJoinedMembers, section: .joined)
}
var bannedUsers: some View {
membersSection(entries: context.viewState.visibleBannedMembers, section: .banned)
}
@ViewBuilder
private func membersSection(entries: [RoomMemberListScreenEntry], section: MembersSection) -> some View {
if !entries.isEmpty {
Section {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(entries, id: \.member.id) { entry in
ListRow(kind: .custom {
RoomMembersListScreenMemberCell(listEntry: entry, isLast: entries.last == entry, context: context)
})
}
}
.background(.compound.bgCanvasDefaultLevel1)
.clipShape(sectionShape)
.padding(.bottom, 32)
} header: {
section.header(count: entries.count)
}
.padding(.horizontal, 16)
}
}
private var sectionShape: AnyShape {
if #available(iOS 26, *) {
AnyShape(ConcentricRectangle(corners: .concentric(minimum: 26)))
} else {
AnyShape(RoundedRectangle(cornerRadius: 8))
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
if context.viewState.canInviteUsers {
Button(L10n.actionInvite) {
context.send(viewAction: .invite)
}
.accessibilityIdentifier(A11yIdentifiers.roomMembersListScreen.invite)
}
}
}
private var emptySearchView: some View {
VStack(spacing: 16) {
BigIcon(icon: \.search, style: .default)
.accessibilityHidden(true)
VStack(spacing: 8) {
Text(L10n.screenRoomMemberListEmptySearchTitle(context.searchQuery))
.font(.compound.headingMDBold)
.foregroundStyle(.compound.textPrimary)
.frame(maxWidth: .infinity)
Text(L10n.screenRoomMemberListEmptySearchSubtitle)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.frame(maxWidth: .infinity)
}
}
.accessibilityElement(children: .combine)
.padding(.horizontal, 24)
.padding(.top, 40)
}
}
private enum MembersSection {
case joined
case invited
case banned
private func sectionTitle(count: Int) -> String {
switch self {
case .banned:
L10n.screenRoomMemberListBannedHeaderTitle(count)
case .invited:
L10n.screenRoomMemberListPendingHeaderTitle(count)
case .joined:
L10n.screenRoomMemberListHeaderTitle(count)
}
}
@ViewBuilder
private func text(count: Int) -> some View {
switch self {
case .invited, .joined:
Text(sectionTitle(count: count))
case .banned:
Text(sectionTitle(count: count))
.foregroundStyle(.compound.bgCriticalPrimary)
}
}
func header(count: Int) -> some View {
text(count: count)
.compoundListSectionHeader()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 16)
}
}
// MARK: - Previews
struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static let invitesViewModel = makeViewModel(withInvites: true)
static let adminViewModel = makeViewModel(isAdmin: true, initialMode: .members)
static let bannedViewModel = makeViewModel(isAdmin: true, initialMode: .banned)
static let emptyBannedViewModel = makeViewModel(withBanned: false, isAdmin: false, initialMode: .members)
static var previews: some View {
ElementNavigationStack {
RoomMembersListScreen(context: viewModel.context)
}
.snapshotPreferences(expect: viewModel.context.$viewState.map { state in
!state.visibleJoinedMembers.isEmpty
})
.previewDisplayName("Member")
ElementNavigationStack {
RoomMembersListScreen(context: invitesViewModel.context)
}
.snapshotPreferences(expect: invitesViewModel.context.$viewState.map { state in
!state.visibleJoinedMembers.isEmpty
})
.previewDisplayName("Invites")
ElementNavigationStack {
RoomMembersListScreen(context: adminViewModel.context)
}
.snapshotPreferences(expect: adminViewModel.context.$viewState.map { state in
state.canBanUsers == true
})
.previewDisplayName("Admin: Members")
ElementNavigationStack {
RoomMembersListScreen(context: bannedViewModel.context)
}
.snapshotPreferences(expect: bannedViewModel.context.$viewState.map { state in
state.canBanUsers == true
})
.previewDisplayName("Admin: Banned")
ElementNavigationStack {
RoomMembersListScreen(context: emptyBannedViewModel.context)
.onAppear { emptyBannedViewModel.context.searchQuery = "Dan" }
}
.snapshotPreferences(expect: emptyBannedViewModel.context.$viewState.map(\.shouldShowEmptyState))
.previewDisplayName("Empty Search")
}
static func makeViewModel(withInvites: Bool = false,
withBanned: Bool = true,
isAdmin: Bool = false,
initialMode: RoomMembersListScreenMode = .members,
searchQuery: String = "") -> RoomMembersListScreenViewModel {
let mockAdmin = RoomMemberProxyMock.mockAdmin
let ownUserID = isAdmin ? mockAdmin.userID : RoomMemberProxyMock.mockMe.userID
var members: [RoomMemberProxyMock] = [
.mockAlice,
.mockBob,
.mockCharlie,
mockAdmin,
.mockCreator,
.mockOwner,
.mockModerator
]
if withBanned {
members.append(contentsOf: RoomMemberProxyMock.mockBanned)
}
if withInvites {
members.append(.mockInvited)
}
let clientProxyMock = ClientProxyMock(.init())
clientProxyMock.userIdentityForFallBackToServerClosure = { userID, _ in
let identity = switch userID {
case RoomMemberProxyMock.mockAlice.userID:
UserIdentityProxyMock(configuration: .init(verificationState: .verified))
case RoomMemberProxyMock.mockBob.userID:
UserIdentityProxyMock(configuration: .init(verificationState: .verificationViolation))
default:
UserIdentityProxyMock(configuration: .init())
}
return .success(identity)
}
return RoomMembersListScreenViewModel(initialMode: initialMode,
userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
roomProxy: JoinedRoomProxyMock(.init(name: "Some room",
members: members,
ownUserID: ownUserID,
powerLevelsConfiguration: .init(canUserInvite: false))),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
analytics: ServiceLocator.shared.analytics)
}
}