From 3ab0aacabf031f06e004ffe08f8043ff8395a22e Mon Sep 17 00:00:00 2001 From: Kai <6895854+nankeen@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:37:31 +0100 Subject: [PATCH] Add call icon next to members in call (#5941) * feat: add call icon next to members in call * feat: use matrix rtc room status for in-call * chore: revert xcodeproj change * chore: update preview tests * chore: Update ElementX.xcscheme * chore: undo room member status proxy --------- Co-authored-by: Kai --- .../Sources/Mocks/JoinedRoomProxyMock.swift | 3 +- .../RoomMembersListScreenModels.swift | 2 ++ .../RoomMembersListScreenViewModel.swift | 14 +++++++--- .../View/RoomMembersListScreen.swift | 18 +++++++++++- .../RoomMembersListScreenMemberCell.swift | 9 ++++++ ...ersListScreen.Admin-In-Call-iPad-en-GB.png | 3 ++ ...rsListScreen.Admin-In-Call-iPad-pseudo.png | 3 ++ ...sListScreen.Admin-In-Call-iPhone-en-GB.png | 3 ++ ...ListScreen.Admin-In-Call-iPhone-pseudo.png | 3 ++ ...omMembersListScreen.In-Call-iPad-en-GB.png | 3 ++ ...mMembersListScreen.In-Call-iPad-pseudo.png | 3 ++ ...MembersListScreen.In-Call-iPhone-en-GB.png | 3 ++ ...embersListScreen.In-Call-iPhone-pseudo.png | 3 ++ .../RoomMembersListScreenViewModelTests.swift | 28 +++++++++++++++++-- 14 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-pseudo.png diff --git a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift index 463a0150c..1c92a760c 100644 --- a/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift +++ b/ElementX/Sources/Mocks/JoinedRoomProxyMock.swift @@ -24,6 +24,7 @@ struct JoinedRoomProxyMockConfiguration { var isSpace = false var isEncrypted = true var hasOngoingCall = true + var activeRoomCallParticipants: [String] = [] var canonicalAlias: String? var alternativeAliases: [String] = [] var pinnedEventIDs: Set = [] @@ -163,7 +164,7 @@ extension RoomInfoProxyMock { joinedMembersCount = configuration.members.filter { $0.membership == .join }.count isDM = isDirect && activeMembersCount <= 2 hasRoomCall = configuration.hasOngoingCall - activeRoomCallParticipants = [] + activeRoomCallParticipants = configuration.activeRoomCallParticipants pinnedEventIDs = configuration.pinnedEventIDs joinRule = configuration.joinRule historyVisibility = configuration.historyVisibility diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift index 8da1b775b..110e995ee 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenModels.swift @@ -33,6 +33,8 @@ enum RoomMembersListScreenMode { nonisolated struct RoomMemberListScreenEntry: Equatable { let member: RoomMemberDetails let verificationState: UserIdentityVerificationState + /// Whether the member is currently joined to the room's active MatrixRTC call (e.g. Element Call). + var isInCall = false } struct RoomMembersListScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift index ae8df8ba5..8a6ae00aa 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/RoomMembersListScreenViewModel.swift @@ -125,8 +125,9 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe private func buildMembersDetails(members: [RoomMemberProxyProtocol]) async -> RoomMembersDetails { // We don't care about identity statuses on non-encrypted rooms let isEncrypted = roomProxy.infoPublisher.value.isEncrypted + let activeRoomCallParticipants = Set(roomProxy.infoPublisher.value.activeRoomCallParticipants) - return await Task.detached { [weak self] in + let membersDetails: RoomMembersDetails = await Task.detached { [weak self] in // accessing RoomMember's properties is very slow. We need to do it in a background thread. var invitedMembers: [RoomMemberListScreenEntry] = .init() var joinedMembers: [RoomMemberListScreenEntry] = .init() @@ -137,14 +138,15 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe if isEncrypted, let fetchedState = await self?.userIdentityVerificationState(for: member.userID) { verificationState = fetchedState } + let isInCall = activeRoomCallParticipants.contains(member.userID) switch member.membership { case .invite: - invitedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState)) + invitedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState, isInCall: isInCall)) case .join: - joinedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState)) + joinedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState, isInCall: isInCall)) case .ban: - bannedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState)) + bannedMembers.append(.init(member: .init(withProxy: member), verificationState: verificationState, isInCall: isInCall)) default: continue } @@ -155,6 +157,10 @@ class RoomMembersListScreenViewModel: RoomMembersListScreenViewModelType, RoomMe bannedMembers: bannedMembers.sorted { $0.member.id.localizedStandardCompare($1.member.id) == .orderedAscending }) // Re-sort ignoring display name. } .value + + return .init(invitedMembers: membersDetails.invitedMembers, + joinedMembers: membersDetails.joinedMembers.filter(\.isInCall) + membersDetails.joinedMembers.filter { !$0.isInCall }, + bannedMembers: membersDetails.bannedMembers) } /// The client proxy isn't Sendable, fetch identities through this helper so diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift index dfdfc3a6b..65ad51c93 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreen.swift @@ -171,6 +171,7 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { 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 let adminInCallViewModel = makeViewModel(withInCall: true, isAdmin: true) static var previews: some View { ElementNavigationStack { @@ -205,6 +206,14 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { }) .previewDisplayName("Admin: Banned") + ElementNavigationStack { + RoomMembersListScreen(context: adminInCallViewModel.context) + } + .snapshotPreferences(expect: adminInCallViewModel.context.$viewState.map { state in + state.canBanUsers == true + }) + .previewDisplayName("Admin: In-Call") + ElementNavigationStack { RoomMembersListScreen(context: emptyBannedViewModel.context) .onAppear { emptyBannedViewModel.context.searchQuery = "Dan" } @@ -215,6 +224,7 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { static func makeViewModel(withInvites: Bool = false, withBanned: Bool = true, + withInCall: Bool = false, isAdmin: Bool = false, initialMode: RoomMembersListScreenMode = .members) -> RoomMembersListScreenViewModel { let mockAdmin = RoomMemberProxyMock.mockAdmin @@ -228,7 +238,8 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { mockAdmin, .mockCreator, .mockOwner, - .mockModerator + .mockModerator, + .mockFrank ] if withBanned { @@ -239,6 +250,10 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { members.append(.mockInvited) } + let activeRoomCallParticipants = withInCall ? [RoomMemberProxyMock.mockAlice.userID, + RoomMemberProxyMock.mockOwner.userID, + mockAdmin.userID] : [] + let clientProxyMock = ClientProxyMock(.init()) clientProxyMock.userIdentityForFallBackToServerClosure = { userID, _ in let identity = switch userID { @@ -256,6 +271,7 @@ struct RoomMembersListScreen_Previews: PreviewProvider, TestablePreview { return RoomMembersListScreenViewModel(initialMode: initialMode, userSession: UserSessionMock(.init(clientProxy: clientProxyMock)), roomProxy: JoinedRoomProxyMock(.init(name: "Some room", + activeRoomCallParticipants: activeRoomCallParticipants, members: members, ownUserID: ownUserID, powerLevelsConfiguration: .init(canUserInvite: false))), diff --git a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreenMemberCell.swift b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreenMemberCell.swift index 894e1e756..76c390064 100644 --- a/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreenMemberCell.swift +++ b/ElementX/Sources/Screens/RoomMemberListScreen/View/RoomMembersListScreenMemberCell.swift @@ -55,6 +55,12 @@ struct RoomMembersListScreenMemberCell: View { .font(.compound.bodyLG) .foregroundStyle(.compound.textSecondary) } + + if listEntry.isInCall { + CompoundIcon(\.videoCallSolid, size: .small, relativeTo: .compound.bodyLG) + .foregroundStyle(.compound.iconAccentPrimary) + .accessibilityLabel(L10n.commonOnACall) + } } .overlay(alignment: .bottom) { if !isLast { @@ -117,6 +123,9 @@ struct RoomMembersListMemberCell_Previews: PreviewProvider, TestablePreview { verificationState: .verified), .init(member: .init(withProxy: RoomMemberProxyMock.mockModerator), verificationState: .verificationViolation), + .init(member: .init(withProxy: RoomMemberProxyMock.mockFrank), + verificationState: .notVerified, + isInCall: true), .init(member: .init(withProxy: RoomMemberProxyMock(with: .init(userID: "@nodisplayname:matrix.org", membership: .join))), verificationState: .notVerified), diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-en-GB.png new file mode 100644 index 000000000..03cd3978b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18395ebf158fcb6a95c2cb872dfe10c46524c1da11e1dde7606d641d3595dbec +size 168947 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-pseudo.png new file mode 100644 index 000000000..12e61ac17 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8baf2a6430be90309622852137282670f80acb65c134b954be2f15a68c26db72 +size 176958 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-en-GB.png new file mode 100644 index 000000000..baf3986b4 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf46ae15f2852960aed8d453219b03082bcc655c076946e66471e16c55b474fd +size 113640 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-pseudo.png new file mode 100644 index 000000000..e517faf75 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.Admin-In-Call-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a83fb0e73446c7f567cf320974677ca744b101bdd0d18854d963f4f970329bfd +size 124639 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-en-GB.png new file mode 100644 index 000000000..1e7dcb29e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a562486535b300d53c6a0ac094139f038bb0609895d632fde87386899ae8249 +size 173785 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-pseudo.png new file mode 100644 index 000000000..5b3ba6e2d --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5a6e0823d9f3a90987023ae393e54fc53fe7017d99f81d77630366e3ace0b9f +size 181515 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-en-GB.png new file mode 100644 index 000000000..c5a5807bd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f08d918e6586257f3cb9d59ed5d1977cec27542989453f8761da76c814432946 +size 119267 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-pseudo.png new file mode 100644 index 000000000..28d56a84a --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomMembersListScreen.In-Call-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d4f8cce7e0469dd7ccebddbc8cd1964f71d5e70afd73e06f81ff70b6cc10ec5 +size 129563 diff --git a/UnitTests/Sources/RoomMembersListScreenViewModelTests.swift b/UnitTests/Sources/RoomMembersListScreenViewModelTests.swift index 6b7c000de..5c854fadd 100644 --- a/UnitTests/Sources/RoomMembersListScreenViewModelTests.swift +++ b/UnitTests/Sources/RoomMembersListScreenViewModelTests.swift @@ -54,6 +54,30 @@ struct RoomMembersListScreenViewModelTests { #expect(viewModel.state.visibleJoinedMembers == sortedMembers) } + @Test + mutating func sortingCallParticipants() async throws { + setup(members: [.mockAdmin, .mockFrank, .mockAlice], + activeRoomCallParticipants: [RoomMemberProxyMock.mockFrank.userID]) + + let deferred = deferFulfillment(context.$viewState) { state in + state.visibleJoinedMembers.count == 3 + } + try await deferred.fulfill() + + // Frank is in the call, so he should be sorted to the top despite having the lowest power level. + let sortedMembers: [RoomMemberListScreenEntry] = [ + .init(member: .init(withProxy: RoomMemberProxyMock.mockFrank), + verificationState: .notVerified, + isInCall: true), + .init(member: .init(withProxy: RoomMemberProxyMock.mockAdmin), + verificationState: .notVerified), + .init(member: .init(withProxy: RoomMemberProxyMock.mockAlice), + verificationState: .notVerified) + ] + + #expect(viewModel.state.visibleJoinedMembers == sortedMembers) + } + @Test mutating func search() async throws { setup(members: [.mockAlice, .mockBob]) @@ -293,8 +317,8 @@ struct RoomMembersListScreenViewModelTests { // MARK: - Helpers - private mutating func setup(members: [RoomMemberProxyMock]) { - roomProxy = JoinedRoomProxyMock(.init(name: "test", members: members)) + private mutating func setup(members: [RoomMemberProxyMock], activeRoomCallParticipants: [String] = []) { + roomProxy = JoinedRoomProxyMock(.init(name: "test", activeRoomCallParticipants: activeRoomCallParticipants, members: members)) viewModel = RoomMembersListScreenViewModel(userSession: UserSessionMock(.init()), roomProxy: roomProxy, userIndicatorController: UserIndicatorControllerMock(),