diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 1474fb81..1f410adb 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -248,7 +248,6 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - // const allLivekitRooms = useBehavior(vm.allLivekitRooms$); const audioParticipants = useBehavior(vm.audioParticipants$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); @@ -263,6 +262,7 @@ export const InCallView: FC = ({ const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const sharingScreen = useBehavior(vm.sharingScreen$); + const ringOverlay = useBehavior(vm.ringOverlay$); const fatalCallError = useBehavior(vm.configError$); // Stop the rendering and throw for the error boundary if (fatalCallError) throw fatalCallError; @@ -299,47 +299,26 @@ export const InCallView: FC = ({ // Waiting UI overlay const waitingOverlay: JSX.Element | null = useMemo(() => { - // No overlay if not in ringing state - if (callPickupState !== "ringing") return null; - - // Use room state for other participants data (the one that we likely want to reach) - // TODO: this screams it wants to be a behavior in the vm. - const roomOthers = [ - ...matrixRoom.getMembersWithMembership("join"), - ...matrixRoom.getMembersWithMembership("invite"), - ].filter((m) => m.userId !== client.getUserId()); - // Yield if there are not other members in the room. - if (roomOthers.length === 0) return null; - - const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined; - const isOneOnOne = roomOthers.length === 1 && otherMember; - const text = isOneOnOne - ? `Waiting for ${otherMember.name ?? otherMember.userId} to join…` - : "Waiting for other participants…"; - const avatarMxc = isOneOnOne - ? (otherMember.getMxcAvatarUrl?.() ?? undefined) - : (matrixRoom.getMxcAvatarUrl() ?? undefined); - - return ( + return ringOverlay ? (
- {text} + {ringOverlay.text}
- ); - }, [callPickupState, client, matrixRoom]); + ) : null; + }, [ringOverlay]); // Ideally we could detect taps by listening for click events and checking // that the pointerType of the event is "touch", but this isn't yet supported diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 063a953e..f1332286 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -12,7 +12,7 @@ import { type Room as LivekitRoom, type RoomOptions, } from "livekit-client"; -import { type RoomMember, type Room as MatrixRoom } from "matrix-js-sdk"; +import { type Room as MatrixRoom } from "matrix-js-sdk"; import { combineLatest, distinctUntilChanged, @@ -115,6 +115,7 @@ import { createSentCallNotification$, } from "./CallNotificationLifecycle.ts"; import { + createDMMember$, createMatrixMemberMetadata$, createRoomMembers$, } from "./remoteMembers/MatrixMemberMetadata.ts"; @@ -244,11 +245,14 @@ export class CallViewModel { public handsRaised$: Behavior>; /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ public reactions$: Behavior>; - public isOneOnOneWith$: Behavior | null>; - public localUserIsAlone$: Behavior; + + public ringOverlay$: Behavior; // sounds and events public joinSoundEffect$: Observable; public leaveSoundEffect$: Observable; @@ -483,7 +487,9 @@ export class CallViewModel { // ------------------------------------------------------------------------ // callLifecycle - const callLifecycle = createCallNotificationLifecycle$({ + // TODO if we are in "unknown" state we need a loading rendering (or empty screen) + // Otherwise it looks like we already connected and only than the ringing starts which is weird. + const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({ scope: scope, memberships$: memberships$, sentCallNotification$: createSentCallNotification$( @@ -505,21 +511,8 @@ export class CallViewModel { matrixRoomMembers$, ); - /** - * Returns the Member {userId, getMxcAvatarUrl, rawDisplayName} of the other user in the call, if it's a one-on-one call. - */ - const isOneOnOneWith$ = scope.behavior( - matrixRoomMembers$.pipe( - map((roomMembersMap) => { - const otherMembers = Array.from(roomMembersMap.values()).filter( - (member) => member.userId !== userId, - ); - return otherMembers.length === 1 ? otherMembers[0] : null; - }), - ), - ); - - const localUserIsAlone$ = scope.behavior( + const dmMember$ = createDMMember$(scope, matrixRoomMembers$, matrixRoom); + const noUserToCallInRoom$ = scope.behavior( matrixRoomMembers$.pipe( map( (roomMembersMap) => @@ -529,6 +522,30 @@ export class CallViewModel { ), ); + const ringOverlay$ = scope.behavior( + combineLatest([noUserToCallInRoom$, dmMember$, callPickupState$]).pipe( + map(([noUserToCallInRoom, dmMember, callPickupState]) => { + // No overlay if not in ringing state + if (callPickupState !== "ringing" || noUserToCallInRoom) return null; + + const name = dmMember ? dmMember.rawDisplayName : matrixRoom.name; + const id = dmMember ? dmMember.userId : matrixRoom.roomId; + const text = dmMember + ? `Waiting for ${name} to join…` + : "Waiting for other participants…"; + const avatarMxc = dmMember + ? (dmMember.getMxcAvatarUrl?.() ?? undefined) + : (matrixRoom.getMxcAvatarUrl() ?? undefined); + return { + name: name ?? id, + idForAvatar: id, + text, + avatarMxc, + }; + }), + ), + ); + // CODESMELL? // This is functionally the same Observable as leave$, except here it's // hoisted to the top of the class. This enables the cyclic dependency between @@ -763,13 +780,8 @@ export class CallViewModel { matrixLivekitMembers$.pipe(map((ms) => ms.value.length)), ); - // only public to expose to the view. - // TODO if we are in "unknown" state we need a loading rendering (or empty screen) - // Otherwise it looks like we already connected and only than the ringing starts which is weird. - const callPickupState$ = callLifecycle.callPickupState$; - const leaveSoundEffect$ = combineLatest([ - callLifecycle.callPickupState$, + callPickupState$, userMedia$, ]).pipe( // Until the call is successful, do not play a leave sound. @@ -804,7 +816,7 @@ export class CallViewModel { const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> = merge( - callLifecycle.autoLeave$, + autoLeave$, merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), ).pipe( scope.share, @@ -1430,8 +1442,9 @@ export class CallViewModel { const join = localMembership.requestConnect; join(); // TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked? - this.autoLeave$ = callLifecycle.autoLeave$; + this.autoLeave$ = autoLeave$; this.callPickupState$ = callPickupState$; + this.ringOverlay$ = ringOverlay$; this.leave$ = leave$; this.hangup = (): void => userHangup$.next(); this.join = join; @@ -1446,8 +1459,6 @@ export class CallViewModel { this.configError$ = localMembership.configError$; this.participantCount$ = participantCount$; this.audioParticipants$ = audioParticipants$; - this.isOneOnOneWith$ = isOneOnOneWith$; - this.localUserIsAlone$ = localUserIsAlone$; this.handsRaised$ = handsRaised$; this.reactions$ = reactions$; diff --git a/src/state/CallViewModel/CallViewModelTestUtils.ts b/src/state/CallViewModel/CallViewModelTestUtils.ts index 81671971..fefc57a0 100644 --- a/src/state/CallViewModel/CallViewModelTestUtils.ts +++ b/src/state/CallViewModel/CallViewModelTestUtils.ts @@ -122,6 +122,7 @@ export function withCallViewModel( } })() as Partial as MatrixClient, getMembers: () => Array.from(roomMembers.values()), + getMembersWithMembership: () => Array.from(roomMembers.values()), }); const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$); const participantsSpy = vi diff --git a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.test.ts b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.test.ts index bc4d329c..d27fe4c9 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.test.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.test.ts @@ -58,6 +58,10 @@ describe("MatrixMemberMetadata", () => { const members = Array.from(fakeMembersMap.values()); return members; }), + getMembersWithMembership: vi.fn().mockImplementation(() => { + const members = Array.from(fakeMembersMap.values()); + return members; + }), } as unknown as MatrixRoom; }); diff --git a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts index ad603708..91363f90 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts @@ -9,7 +9,10 @@ import { type RoomMember, RoomStateEvent } from "matrix-js-sdk"; import { combineLatest, fromEvent, map } from "rxjs"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; -import { type Room as MatrixRoom } from "matrix-js-sdk/lib/matrix"; +import { + KnownMembership, + type Room as MatrixRoom, +} from "matrix-js-sdk/lib/matrix"; // eslint-disable-next-line rxjs/no-internal import { type ObservableScope } from "../../ObservableScope"; @@ -26,7 +29,10 @@ export type RoomMemberMap = Map< Pick >; export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap { - return matrixRoom.getMembers().reduce((acc, member) => { + const members = matrixRoom + .getMembersWithMembership(KnownMembership.Join) + .concat(matrixRoom.getMembersWithMembership(KnownMembership.Invite)); + return members.reduce((acc, member) => { acc.set(member.userId, { userId: member.userId, getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member), @@ -47,6 +53,32 @@ export function createRoomMembers$( roomToMembersMap(matrixRoom), ); } + +/** + * creates the member that this DM is with in case it is a DM (two members) otherwise null + */ +export function createDMMember$( + scope: ObservableScope, + roomMembers$: Behavior, + matrixRoom: MatrixRoom, +): Behavior | null> { + // We cannot use the normal direct check from matrix since we do not have access to the account data. + // use primitive member count === 2 check instead. + return scope.behavior( + roomMembers$.pipe( + map((membersMap) => { + // primitive appraoch do to no access to account data. + const isDM = membersMap.size === 2; + if (!isDM) return null; + return matrixRoom.getMember(matrixRoom.guessDMUserId()); + }), + ), + ); +} + /** * Displayname for each member of the call. This will disambiguate * any displayname that clashes with another member. Only members diff --git a/src/utils/displayname.test.ts b/src/utils/displayname.test.ts index 442b928a..fa732f19 100644 --- a/src/utils/displayname.test.ts +++ b/src/utils/displayname.test.ts @@ -25,7 +25,7 @@ import { roomToMembersMap } from "../state/CallViewModel/remoteMembers/MatrixMem describe("shouldDisambiguate", () => { test("should not disambiguate a solo member", () => { const room = mockMatrixRoom({ - getMembers: () => [], + getMembersWithMembership: () => [], }); expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual( false, @@ -33,7 +33,7 @@ describe("shouldDisambiguate", () => { }); test("should not disambiguate a member with an empty displayname", () => { const room = mockMatrixRoom({ - getMembers: () => [alice, aliceDoppelganger], + getMembersWithMembership: () => [alice, aliceDoppelganger], }); expect( shouldDisambiguate( @@ -44,14 +44,14 @@ describe("shouldDisambiguate", () => { ).toEqual(false); }); test("should disambiguate a member with RTL characters", () => { - const room = mockMatrixRoom({ getMembers: () => [] }); + const room = mockMatrixRoom({ getMembersWithMembership: () => [] }); expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual( true, ); }); test("should disambiguate a member with a matching displayname", () => { const room = mockMatrixRoom({ - getMembers: () => [alice, aliceDoppelganger], + getMembersWithMembership: () => [alice, aliceDoppelganger], }); expect( shouldDisambiguate( @@ -70,7 +70,7 @@ describe("shouldDisambiguate", () => { }); test("should disambiguate a member with a matching displayname with hidden spaces", () => { const room = mockMatrixRoom({ - getMembers: () => [bob, bobZeroWidthSpace], + getMembersWithMembership: () => [bob, bobZeroWidthSpace], }); expect( shouldDisambiguate( @@ -91,7 +91,7 @@ describe("shouldDisambiguate", () => { "should disambiguate a member with a displayname containing a mxid-like string '%s'", (rawDisplayName) => { const room = mockMatrixRoom({ - getMembers: () => [alice, aliceDoppelganger], + getMembersWithMembership: () => [alice, aliceDoppelganger], }); expect( shouldDisambiguate( diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index 314b6aa9..990c43f8 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -83,6 +83,8 @@ export function getBasicRTCSession( } as Partial as MatrixClient, getMember: (userId) => matrixRoomMembers.get(userId) ?? null, getMembers: () => Array.from(matrixRoomMembers.values()), + getMembersWithMembership: () => Array.from(matrixRoomMembers.values()), + guessDMUserId: vitest.fn(), roomId: matrixRoomId, on: vitest .fn()