* 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
267 lines
9.9 KiB
Swift
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)
|
|
}
|
|
}
|