mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Invited members in room's members list (#880)
* Add invited people in RoomMembersListScreen * Optimize main thread perfomance in RoomDetailsScreenViewModel * Fix member count in RoomDetailsScreenViewModel * Add warning * Add performance warnings * Build members in RoomMembersListScreenViewModel in global queue * Refactor RoomDetailsScreenViewModel build members process * Localize strings * Cleanup * Fix RoomDetailsScreenViewModelTests UTs * Fix UI tests * Add RoomMembersListScreenViewModel UTs * Add UI tests * Amend UI tests * Use clearAndTypeText
This commit is contained in:
@@ -55,6 +55,18 @@ extension RoomMemberProxyMock {
|
||||
isAccountOwner: false,
|
||||
isIgnored: false))
|
||||
}
|
||||
|
||||
static var mockInvitedAlice: RoomMemberProxyMock {
|
||||
RoomMemberProxyMock(with: .init(userID: "@alice:matrix.org",
|
||||
displayName: "Alice",
|
||||
avatarURL: nil,
|
||||
membership: .invite,
|
||||
isNameAmbiguous: false,
|
||||
powerLevel: 50,
|
||||
normalizedPowerLevel: 50,
|
||||
isAccountOwner: false,
|
||||
isIgnored: false))
|
||||
}
|
||||
|
||||
static var mockBob: RoomMemberProxyMock {
|
||||
RoomMemberProxyMock(with: .init(userID: "@bob:matrix.org",
|
||||
|
||||
@@ -39,6 +39,7 @@ struct RoomDetailsScreenViewState: BindableState {
|
||||
var avatarURL: URL?
|
||||
let permalink: URL?
|
||||
var members: [RoomMemberDetails] = []
|
||||
var joinedMembersCount = 0
|
||||
var isProcessingIgnoreRequest = false
|
||||
|
||||
var isLoadingMembers: Bool {
|
||||
|
||||
@@ -21,26 +21,11 @@ typealias RoomDetailsScreenViewModelType = StateStoreViewModel<RoomDetailsScreen
|
||||
|
||||
class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScreenViewModelProtocol {
|
||||
private let roomProxy: RoomProxyProtocol
|
||||
private var members: [RoomMemberProxyProtocol] = [] {
|
||||
didSet {
|
||||
state.members = members.map { RoomMemberDetails(withProxy: $0) }
|
||||
if roomProxy.isDirect, roomProxy.isEncrypted, members.count == 2 {
|
||||
dmRecipient = members.first(where: { !$0.isAccountOwner })
|
||||
} else {
|
||||
dmRecipient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dmRecipient: RoomMemberProxyProtocol? {
|
||||
didSet {
|
||||
if let dmRecipient {
|
||||
state.dmRecipient = RoomMemberDetails(withProxy: dmRecipient)
|
||||
} else {
|
||||
state.dmRecipient = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
private var members: [RoomMemberProxyProtocol] = []
|
||||
private var dmRecipient: RoomMemberProxyProtocol?
|
||||
|
||||
@CancellableTask
|
||||
private var buildMembersDetailsTask: Task<Void, Never>?
|
||||
|
||||
var callback: ((RoomDetailsScreenViewModelAction) -> Void)?
|
||||
|
||||
@@ -57,11 +42,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
permalink: roomProxy.permalink,
|
||||
bindings: .init()),
|
||||
imageProvider: mediaProvider)
|
||||
|
||||
roomProxy.membersPublisher.sink { [weak self] members in
|
||||
self?.members = members
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupSubscriptions()
|
||||
|
||||
Task {
|
||||
await roomProxy.updateMembers()
|
||||
@@ -96,6 +78,49 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func setupSubscriptions() {
|
||||
roomProxy.membersPublisher
|
||||
.sink { [weak self] members in
|
||||
guard let self else { return }
|
||||
|
||||
buildMembersDetailsTask = Task {
|
||||
let (membersDetails, joinedMembersCount) = await self.buildMembersDetails(members: members)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
if self.roomProxy.isDirect, self.roomProxy.isEncrypted, members.count == 2 {
|
||||
self.dmRecipient = members.first(where: { !$0.isAccountOwner })
|
||||
}
|
||||
|
||||
self.state.members = membersDetails
|
||||
self.state.joinedMembersCount = joinedMembersCount
|
||||
self.state.dmRecipient = self.dmRecipient.map(RoomMemberDetails.init(withProxy:))
|
||||
self.members = members
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> (memberDetails: [RoomMemberDetails], joinedMembersCount: Int) {
|
||||
await Task.detached {
|
||||
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
|
||||
var roomMembersDetails: [RoomMemberDetails] = []
|
||||
var joinedMembersCount = 0
|
||||
roomMembersDetails.reserveCapacity(members.count)
|
||||
|
||||
for member in members {
|
||||
roomMembersDetails.append(RoomMemberDetails(withProxy: member))
|
||||
|
||||
if member.membership == .join {
|
||||
joinedMembersCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (roomMembersDetails, joinedMembersCount)
|
||||
}
|
||||
.value
|
||||
}
|
||||
|
||||
private static let leaveRoomLoadingID = "LeaveRoomLoading"
|
||||
|
||||
private func leaveRoom() async {
|
||||
|
||||
@@ -120,7 +120,7 @@ struct RoomDetailsScreen: View {
|
||||
if context.viewState.isLoadingMembers {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(String(context.viewState.members.count))
|
||||
Text(String(context.viewState.joinedMembersCount))
|
||||
.foregroundColor(.element.tertiaryContent)
|
||||
.font(.compound.bodyLG)
|
||||
}
|
||||
|
||||
@@ -21,19 +21,32 @@ enum RoomMembersListScreenViewModelAction {
|
||||
}
|
||||
|
||||
struct RoomMembersListScreenViewState: BindableState {
|
||||
var members: [RoomMemberDetails]
|
||||
|
||||
private var joinedMembers: [RoomMemberDetails]
|
||||
private var invitedMembers: [RoomMemberDetails]
|
||||
var bindings: RoomMembersListScreenViewStateBindings
|
||||
|
||||
init(joinedMembers: [RoomMemberDetails] = [], invitedMembers: [RoomMemberDetails] = [], bindings: RoomMembersListScreenViewStateBindings = .init()) {
|
||||
self.joinedMembers = joinedMembers
|
||||
self.invitedMembers = invitedMembers
|
||||
self.bindings = bindings
|
||||
}
|
||||
|
||||
var visibleMembers: [RoomMemberDetails] {
|
||||
if bindings.searchQuery.isEmpty {
|
||||
return members
|
||||
}
|
||||
|
||||
return members.lazy.filter { member in
|
||||
member.id.localizedCaseInsensitiveContains(bindings.searchQuery) ||
|
||||
member.name?.localizedCaseInsensitiveContains(bindings.searchQuery) ?? false
|
||||
}
|
||||
var visibleJoinedMembers: [RoomMemberDetails] {
|
||||
joinedMembers.lazy
|
||||
.filter { member in
|
||||
member.matches(searchQuery: bindings.searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
var visibleInvitedMembers: [RoomMemberDetails] {
|
||||
invitedMembers.lazy
|
||||
.filter { member in
|
||||
member.matches(searchQuery: bindings.searchQuery)
|
||||
}
|
||||
}
|
||||
|
||||
var joinedMembersCount: Int {
|
||||
joinedMembers.count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +60,13 @@ struct RoomMembersListScreenViewStateBindings {
|
||||
enum RoomMembersListScreenViewAction {
|
||||
case selectMember(id: String)
|
||||
}
|
||||
|
||||
private extension RoomMemberDetails {
|
||||
func matches(searchQuery: String) -> Bool {
|
||||
guard !searchQuery.isEmpty else {
|
||||
return true
|
||||
}
|
||||
|
||||
return id.localizedCaseInsensitiveContains(searchQuery) || name?.localizedCaseInsensitiveContains(searchQuery) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,12 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
|
||||
|
||||
var callback: ((RoomMembersListScreenViewModelAction) -> Void)?
|
||||
|
||||
init(mediaProvider: MediaProviderProtocol,
|
||||
members: [RoomMemberProxyProtocol]) {
|
||||
init(mediaProvider: MediaProviderProtocol, members: [RoomMemberProxyProtocol]) {
|
||||
self.mediaProvider = mediaProvider
|
||||
self.members = members
|
||||
super.init(initialViewState: .init(members: members.map { RoomMemberDetails(withProxy: $0) },
|
||||
bindings: .init()),
|
||||
imageProvider: mediaProvider)
|
||||
super.init(initialViewState: .init(), imageProvider: mediaProvider)
|
||||
|
||||
buildMembersDetails(members: members)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -45,4 +44,35 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe
|
||||
callback?(.selectMember(member))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
func buildMembersDetails(members: [RoomMemberProxyProtocol]) {
|
||||
Task {
|
||||
let indicatorId = UUID().uuidString
|
||||
defer {
|
||||
ServiceLocator.shared.userIndicatorController.retractIndicatorWithId(indicatorId)
|
||||
}
|
||||
ServiceLocator.shared.userIndicatorController.submitIndicator(UserIndicator(id: indicatorId, type: .modal, title: L10n.commonLoading, persistent: true))
|
||||
|
||||
let (invitedMembers, joinedMembers) = await split(members: members)
|
||||
self.state = .init(joinedMembers: joinedMembers, invitedMembers: invitedMembers)
|
||||
}
|
||||
}
|
||||
|
||||
func split(members: [RoomMemberProxyProtocol]) async -> (invited: [RoomMemberDetails], joined: [RoomMemberDetails]) {
|
||||
await Task.detached {
|
||||
// accessing RoomMember's properties is very slow. We need to do it in a background thread.
|
||||
let invitedMembers = members
|
||||
.filter { $0.membership == .invite }
|
||||
.map(RoomMemberDetails.init(withProxy:))
|
||||
|
||||
let joinedMembers = members
|
||||
.filter { $0.membership == .join }
|
||||
.map(RoomMemberDetails.init(withProxy:))
|
||||
|
||||
return (invitedMembers, joinedMembers)
|
||||
}
|
||||
.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,18 +25,8 @@ struct RoomMembersListScreen: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading) {
|
||||
Section {
|
||||
ForEach(context.viewState.visibleMembers) { member in
|
||||
RoomMembersListScreenMemberCell(member: member, context: context)
|
||||
.id(member.id)
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.commonMemberCount(context.viewState.members.count))
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.font(.compound.bodyLG)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
membersSection(data: context.viewState.visibleInvitedMembers, sectionTitle: L10n.screenRoomMemberListPendingHeaderTitle)
|
||||
membersSection(data: context.viewState.visibleJoinedMembers, sectionTitle: L10n.screenRoomMemberListHeaderTitle(context.viewState.joinedMembersCount))
|
||||
}
|
||||
}
|
||||
.searchable(text: $context.searchQuery, placement: .navigationBarDrawer(displayMode: .always))
|
||||
@@ -45,6 +35,24 @@ struct RoomMembersListScreen: View {
|
||||
.navigationTitle(L10n.commonPeople)
|
||||
.alert(item: $context.alertInfo) { $0.alert }
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func membersSection(data: [RoomMemberDetails], sectionTitle: String) -> some View {
|
||||
Section {
|
||||
ForEach(data, id: \.id) { member in
|
||||
RoomMembersListScreenMemberCell(member: member, context: context)
|
||||
}
|
||||
} header: {
|
||||
if !data.isEmpty {
|
||||
Text(sectionTitle)
|
||||
.foregroundColor(.element.secondaryContent)
|
||||
.font(.compound.bodyLG)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
@@ -68,7 +68,7 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
|
||||
}
|
||||
|
||||
func ignoreUser() async -> Result<Void, RoomMemberProxyError> {
|
||||
sendAccountDataEventBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
|
||||
sendAccountDataEventBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
|
||||
defer {
|
||||
sendAccountDataEventBackgroundTask?.stop()
|
||||
}
|
||||
@@ -84,7 +84,7 @@ final class RoomMemberProxy: RoomMemberProxyProtocol {
|
||||
}
|
||||
|
||||
func unignoreUser() async -> Result<Void, RoomMemberProxyError> {
|
||||
sendAccountDataEventBackgroundTask = backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
|
||||
sendAccountDataEventBackgroundTask = await backgroundTaskService.startBackgroundTask(withName: backgroundAccountDataTaskName, isReusable: true)
|
||||
defer {
|
||||
sendAccountDataEventBackgroundTask?.stop()
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ enum RoomMemberProxyError: Error {
|
||||
case unignoreUserFailed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
// sourcery: AutoMockable
|
||||
protocol RoomMemberProxyProtocol {
|
||||
var userID: String { get }
|
||||
|
||||
@@ -24,7 +24,6 @@ struct RoomMemberDetails: Identifiable, Equatable {
|
||||
let isAccountOwner: Bool
|
||||
var isIgnored: Bool
|
||||
|
||||
@MainActor
|
||||
init(withProxy proxy: RoomMemberProxyProtocol) {
|
||||
id = proxy.userID
|
||||
name = proxy.displayName
|
||||
|
||||
@@ -307,6 +307,14 @@ class MockScreen: Identifiable {
|
||||
members: members))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .roomMembersListScreenPendingInvites:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let members: [RoomMemberProxyMock] = [.mockInvitedAlice, .mockBob, .mockCharlie]
|
||||
let coordinator = RoomMembersListScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
members: members))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
case .reportContent:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let coordinator = ReportContentScreenCoordinator(parameters: .init(itemID: "test",
|
||||
|
||||
@@ -45,6 +45,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case roomDetailsScreenWithRoomAvatar
|
||||
case roomDetailsScreenDmDetails
|
||||
case roomMembersListScreen
|
||||
case roomMembersListScreenPendingInvites
|
||||
case roomMemberDetailsAccountOwner
|
||||
case roomMemberDetails
|
||||
case roomMemberDetailsIgnoredUser
|
||||
|
||||
Reference in New Issue
Block a user