Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 8 additions & 29 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);

// const allLivekitRooms = useBehavior(vm.allLivekitRooms$);
const audioParticipants = useBehavior(vm.audioParticipants$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
Expand All @@ -263,6 +262,7 @@ export const InCallView: FC<InCallViewProps> = ({
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;
Expand Down Expand Up @@ -299,47 +299,26 @@ export const InCallView: FC<InCallViewProps> = ({

// 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 ? (
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
<div
className={classNames(overlayStyles.content, waitingStyles.content)}
>
<div className={waitingStyles.pulse}>
<Avatar
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId}
name={isOneOnOne ? otherMember.name : matrixRoom.name}
src={avatarMxc}
id={ringOverlay.idForAvatar}
name={ringOverlay.name}
src={ringOverlay.avatarMxc}
size={AvatarSize.XL}
/>
</div>
<Text size="md" className={waitingStyles.text}>
{text}
{ringOverlay.text}
</Text>
</div>
</div>
);
}, [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
Expand Down
75 changes: 43 additions & 32 deletions src/state/CallViewModel/CallViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -115,6 +115,7 @@ import {
createSentCallNotification$,
} from "./CallNotificationLifecycle.ts";
import {
createDMMember$,
createMatrixMemberMetadata$,
createRoomMembers$,
} from "./remoteMembers/MatrixMemberMetadata.ts";
Expand Down Expand Up @@ -244,11 +245,14 @@ export class CallViewModel {
public handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
public reactions$: Behavior<Record<string, ReactionOption>>;
public isOneOnOneWith$: Behavior<Pick<
RoomMember,
"userId" | "getMxcAvatarUrl" | "rawDisplayName"
> | null>;
public localUserIsAlone$: Behavior<boolean>;

public ringOverlay$: Behavior<null | {
name: string;
/** roomId or userId for the avatar generation. */
idForAvatar: string;
text: string;
avatarMxc?: string;
}>;
// sounds and events
public joinSoundEffect$: Observable<void>;
public leaveSoundEffect$: Observable<void>;
Expand Down Expand Up @@ -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$(
Expand All @@ -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) =>
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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$;
Expand Down
1 change: 1 addition & 0 deletions src/state/CallViewModel/CallViewModelTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function withCallViewModel(
}
})() as Partial<MatrixClient> as MatrixClient,
getMembers: () => Array.from(roomMembers.values()),
getMembersWithMembership: () => Array.from(roomMembers.values()),
});
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
const participantsSpy = vi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down
36 changes: 34 additions & 2 deletions src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,7 +29,10 @@ export type RoomMemberMap = Map<
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
>;
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),
Expand All @@ -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<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
* any displayname that clashes with another member. Only members
Expand Down
12 changes: 6 additions & 6 deletions src/utils/displayname.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ 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,
);
});
test("should not disambiguate a member with an empty displayname", () => {
const room = mockMatrixRoom({
getMembers: () => [alice, aliceDoppelganger],
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/utils/test-viewmodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export function getBasicRTCSession(
} as Partial<MatrixClient> 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()
Expand Down
Loading