Move one-on-one layout into CallViewModel (#3567)

* move ononone layout into CallViewModel

* move even more into the vm.

* tests
This commit is contained in:
Timo
2025-11-17 17:42:37 +01:00
committed by GitHub
parent 49ec4b4298
commit b51df36a06
7 changed files with 98 additions and 69 deletions

View File

@@ -248,7 +248,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
// const allLivekitRooms = useBehavior(vm.allLivekitRooms$);
const audioParticipants = useBehavior(vm.audioParticipants$); const audioParticipants = useBehavior(vm.audioParticipants$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
@@ -263,6 +262,7 @@ export const InCallView: FC<InCallViewProps> = ({
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$); const sharingScreen = useBehavior(vm.sharingScreen$);
const ringOverlay = useBehavior(vm.ringOverlay$);
const fatalCallError = useBehavior(vm.configError$); const fatalCallError = useBehavior(vm.configError$);
// Stop the rendering and throw for the error boundary // Stop the rendering and throw for the error boundary
if (fatalCallError) throw fatalCallError; if (fatalCallError) throw fatalCallError;
@@ -299,47 +299,26 @@ export const InCallView: FC<InCallViewProps> = ({
// Waiting UI overlay // Waiting UI overlay
const waitingOverlay: JSX.Element | null = useMemo(() => { const waitingOverlay: JSX.Element | null = useMemo(() => {
// No overlay if not in ringing state return ringOverlay ? (
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 (
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}> <div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
<div <div
className={classNames(overlayStyles.content, waitingStyles.content)} className={classNames(overlayStyles.content, waitingStyles.content)}
> >
<div className={waitingStyles.pulse}> <div className={waitingStyles.pulse}>
<Avatar <Avatar
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId} id={ringOverlay.idForAvatar}
name={isOneOnOne ? otherMember.name : matrixRoom.name} name={ringOverlay.name}
src={avatarMxc} src={ringOverlay.avatarMxc}
size={AvatarSize.XL} size={AvatarSize.XL}
/> />
</div> </div>
<Text size="md" className={waitingStyles.text}> <Text size="md" className={waitingStyles.text}>
{text} {ringOverlay.text}
</Text> </Text>
</div> </div>
</div> </div>
); ) : null;
}, [callPickupState, client, matrixRoom]); }, [ringOverlay]);
// Ideally we could detect taps by listening for click events and checking // 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 // that the pointerType of the event is "touch", but this isn't yet supported

View File

@@ -12,7 +12,7 @@ import {
type Room as LivekitRoom, type Room as LivekitRoom,
type RoomOptions, type RoomOptions,
} from "livekit-client"; } from "livekit-client";
import { type RoomMember, type Room as MatrixRoom } from "matrix-js-sdk"; import { type Room as MatrixRoom } from "matrix-js-sdk";
import { import {
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
@@ -115,6 +115,7 @@ import {
createSentCallNotification$, createSentCallNotification$,
} from "./CallNotificationLifecycle.ts"; } from "./CallNotificationLifecycle.ts";
import { import {
createDMMember$,
createMatrixMemberMetadata$, createMatrixMemberMetadata$,
createRoomMembers$, createRoomMembers$,
} from "./remoteMembers/MatrixMemberMetadata.ts"; } from "./remoteMembers/MatrixMemberMetadata.ts";
@@ -244,11 +245,14 @@ export class CallViewModel {
public handsRaised$: Behavior<Record<string, RaisedHandInfo>>; public handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
public reactions$: Behavior<Record<string, ReactionOption>>; public reactions$: Behavior<Record<string, ReactionOption>>;
public isOneOnOneWith$: Behavior<Pick<
RoomMember, public ringOverlay$: Behavior<null | {
"userId" | "getMxcAvatarUrl" | "rawDisplayName" name: string;
> | null>; /** roomId or userId for the avatar generation. */
public localUserIsAlone$: Behavior<boolean>; idForAvatar: string;
text: string;
avatarMxc?: string;
}>;
// sounds and events // sounds and events
public joinSoundEffect$: Observable<void>; public joinSoundEffect$: Observable<void>;
public leaveSoundEffect$: Observable<void>; public leaveSoundEffect$: Observable<void>;
@@ -483,7 +487,9 @@ export class CallViewModel {
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// callLifecycle // 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, scope: scope,
memberships$: memberships$, memberships$: memberships$,
sentCallNotification$: createSentCallNotification$( sentCallNotification$: createSentCallNotification$(
@@ -505,21 +511,8 @@ export class CallViewModel {
matrixRoomMembers$, matrixRoomMembers$,
); );
/** const dmMember$ = createDMMember$(scope, matrixRoomMembers$, matrixRoom);
* Returns the Member {userId, getMxcAvatarUrl, rawDisplayName} of the other user in the call, if it's a one-on-one call. const noUserToCallInRoom$ = scope.behavior(
*/
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(
matrixRoomMembers$.pipe( matrixRoomMembers$.pipe(
map( map(
(roomMembersMap) => (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? // CODESMELL?
// This is functionally the same Observable as leave$, except here it's // 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 // 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)), 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([ const leaveSoundEffect$ = combineLatest([
callLifecycle.callPickupState$, callPickupState$,
userMedia$, userMedia$,
]).pipe( ]).pipe(
// Until the call is successful, do not play a leave sound. // 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"> = const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
merge( merge(
callLifecycle.autoLeave$, autoLeave$,
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
).pipe( ).pipe(
scope.share, scope.share,
@@ -1430,8 +1442,9 @@ export class CallViewModel {
const join = localMembership.requestConnect; 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? 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.callPickupState$ = callPickupState$;
this.ringOverlay$ = ringOverlay$;
this.leave$ = leave$; this.leave$ = leave$;
this.hangup = (): void => userHangup$.next(); this.hangup = (): void => userHangup$.next();
this.join = join; this.join = join;
@@ -1446,8 +1459,6 @@ export class CallViewModel {
this.configError$ = localMembership.configError$; this.configError$ = localMembership.configError$;
this.participantCount$ = participantCount$; this.participantCount$ = participantCount$;
this.audioParticipants$ = audioParticipants$; this.audioParticipants$ = audioParticipants$;
this.isOneOnOneWith$ = isOneOnOneWith$;
this.localUserIsAlone$ = localUserIsAlone$;
this.handsRaised$ = handsRaised$; this.handsRaised$ = handsRaised$;
this.reactions$ = reactions$; this.reactions$ = reactions$;

View File

@@ -122,6 +122,7 @@ export function withCallViewModel(
} }
})() as Partial<MatrixClient> as MatrixClient, })() as Partial<MatrixClient> as MatrixClient,
getMembers: () => Array.from(roomMembers.values()), getMembers: () => Array.from(roomMembers.values()),
getMembersWithMembership: () => Array.from(roomMembers.values()),
}); });
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$); const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
const participantsSpy = vi const participantsSpy = vi

View File

@@ -58,6 +58,10 @@ describe("MatrixMemberMetadata", () => {
const members = Array.from(fakeMembersMap.values()); const members = Array.from(fakeMembersMap.values());
return members; return members;
}), }),
getMembersWithMembership: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
} as unknown as MatrixRoom; } as unknown as MatrixRoom;
}); });

View File

@@ -9,7 +9,10 @@ import { type RoomMember, RoomStateEvent } from "matrix-js-sdk";
import { combineLatest, fromEvent, map } from "rxjs"; import { combineLatest, fromEvent, map } from "rxjs";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; 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 // eslint-disable-next-line rxjs/no-internal
import { type ObservableScope } from "../../ObservableScope"; import { type ObservableScope } from "../../ObservableScope";
@@ -26,7 +29,10 @@ export type RoomMemberMap = Map<
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName"> Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
>; >;
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap { 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, { acc.set(member.userId, {
userId: member.userId, userId: member.userId,
getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member), getMxcAvatarUrl: member.getMxcAvatarUrl.bind(member),
@@ -47,6 +53,32 @@ export function createRoomMembers$(
roomToMembersMap(matrixRoom), 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<RoomMemberMap>,
matrixRoom: MatrixRoom,
): Behavior<Pick<
RoomMember,
"userId" | "getMxcAvatarUrl" | "rawDisplayName"
> | 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 * Displayname for each member of the call. This will disambiguate
* any displayname that clashes with another member. Only members * any displayname that clashes with another member. Only members

View File

@@ -25,7 +25,7 @@ import { roomToMembersMap } from "../state/CallViewModel/remoteMembers/MatrixMem
describe("shouldDisambiguate", () => { describe("shouldDisambiguate", () => {
test("should not disambiguate a solo member", () => { test("should not disambiguate a solo member", () => {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
getMembers: () => [], getMembersWithMembership: () => [],
}); });
expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual( expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual(
false, false,
@@ -33,7 +33,7 @@ describe("shouldDisambiguate", () => {
}); });
test("should not disambiguate a member with an empty displayname", () => { test("should not disambiguate a member with an empty displayname", () => {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
getMembers: () => [alice, aliceDoppelganger], getMembersWithMembership: () => [alice, aliceDoppelganger],
}); });
expect( expect(
shouldDisambiguate( shouldDisambiguate(
@@ -44,14 +44,14 @@ describe("shouldDisambiguate", () => {
).toEqual(false); ).toEqual(false);
}); });
test("should disambiguate a member with RTL characters", () => { test("should disambiguate a member with RTL characters", () => {
const room = mockMatrixRoom({ getMembers: () => [] }); const room = mockMatrixRoom({ getMembersWithMembership: () => [] });
expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual( expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual(
true, true,
); );
}); });
test("should disambiguate a member with a matching displayname", () => { test("should disambiguate a member with a matching displayname", () => {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
getMembers: () => [alice, aliceDoppelganger], getMembersWithMembership: () => [alice, aliceDoppelganger],
}); });
expect( expect(
shouldDisambiguate( shouldDisambiguate(
@@ -70,7 +70,7 @@ describe("shouldDisambiguate", () => {
}); });
test("should disambiguate a member with a matching displayname with hidden spaces", () => { test("should disambiguate a member with a matching displayname with hidden spaces", () => {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
getMembers: () => [bob, bobZeroWidthSpace], getMembersWithMembership: () => [bob, bobZeroWidthSpace],
}); });
expect( expect(
shouldDisambiguate( shouldDisambiguate(
@@ -91,7 +91,7 @@ describe("shouldDisambiguate", () => {
"should disambiguate a member with a displayname containing a mxid-like string '%s'", "should disambiguate a member with a displayname containing a mxid-like string '%s'",
(rawDisplayName) => { (rawDisplayName) => {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
getMembers: () => [alice, aliceDoppelganger], getMembersWithMembership: () => [alice, aliceDoppelganger],
}); });
expect( expect(
shouldDisambiguate( shouldDisambiguate(

View File

@@ -83,6 +83,8 @@ export function getBasicRTCSession(
} as Partial<MatrixClient> as MatrixClient, } as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => matrixRoomMembers.get(userId) ?? null, getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
getMembers: () => Array.from(matrixRoomMembers.values()), getMembers: () => Array.from(matrixRoomMembers.values()),
getMembersWithMembership: () => Array.from(matrixRoomMembers.values()),
guessDMUserId: vitest.fn(),
roomId: matrixRoomId, roomId: matrixRoomId,
on: vitest on: vitest
.fn() .fn()