Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
9cdbb11
temp
toger5 Oct 28, 2025
cfe05f1
more temp
toger5 Oct 28, 2025
3de0bbc
temp
toger5 Oct 29, 2025
62ef49c
temp
toger5 Oct 29, 2025
633a0f9
connection manager
toger5 Oct 29, 2025
6b51353
lots of fixup in the new classes
toger5 Oct 29, 2025
c8ef8d6
start moving over/removing things from the CallViewModel
toger5 Oct 30, 2025
4f892e3
start onwMemberhsip.ts
toger5 Oct 30, 2025
a44171d
changes summary valere timo
toger5 Oct 30, 2025
4c5f06a
Refactoring to ease testing of ConnectionManager
BillCarsonFr Nov 3, 2025
a7d2a3b
es lint fixes
BillCarsonFr Nov 3, 2025
5961cb6
test with marbles
BillCarsonFr Nov 3, 2025
06734ae
quick refactor, use object instead of tupple
BillCarsonFr Nov 4, 2025
870b706
Connection & Livekit integ test WIP
BillCarsonFr Nov 4, 2025
57bf86f
finish up most of our helper classes. there are no lint issues in the
toger5 Nov 4, 2025
107ef16
Add MatrixRTCMode and refactor local membership
toger5 Nov 5, 2025
4d0de2f
Refactor Matrix/LiveKit session merging
toger5 Nov 5, 2025
c19e224
use createSomething$ mathods instead of classes
toger5 Nov 5, 2025
d8e2946
rename merger
toger5 Nov 5, 2025
6e1a582
fix tests compilation peer session timo - wip
BillCarsonFr Nov 6, 2025
a55ce19
cleanup
toger5 Nov 6, 2025
2e6b176
Fixup base integration test
BillCarsonFr Nov 6, 2025
7c41aef
Add `Epoch<T>` and use it in for the `memberships$` behavior and its
toger5 Nov 6, 2025
92fdce3
pull out all screen share related logic.
toger5 Nov 7, 2025
2804721
Almost running
toger5 Nov 7, 2025
e741285
Fix lints, move CallViewModel.test.ts. Fix audio renderer
toger5 Nov 7, 2025
cf5c35b
fix more runtime errors
toger5 Nov 7, 2025
b8635b5
Working (no local feed)
toger5 Nov 7, 2025
fc842d4
test: fixup ConnectionManager tests
BillCarsonFr Nov 8, 2025
1f386a1
test: fix displayname tests due to Epoch change
BillCarsonFr Nov 8, 2025
b4c17ed
Replace generateKeyed$ with a redesigned generateItems operator
robintown Nov 7, 2025
92ddc4c
Fix avatar reactivity, simplify display names tracking
robintown Nov 9, 2025
5c83e0d
test: fixup MatrixLivekitMembers tests
BillCarsonFr Nov 10, 2025
9365993
fixup: update integration tests
BillCarsonFr Nov 10, 2025
93c4dc5
make it run
toger5 Nov 10, 2025
85f659b
Introduce MatrixMemberMetadata and use it to simplify username and
toger5 Nov 11, 2025
8671d3f
Very bit test overhaul. All displayname tests are now done in the
toger5 Nov 11, 2025
9f4d954
The source of the local participant is the createLocalMembership$ and
toger5 Nov 12, 2025
8d42189
Fix formatting of doc comment
robintown Nov 12, 2025
27b76b4
Remove unused Async type
robintown Nov 12, 2025
a62d836
Fix and simplify screen sharing
robintown Nov 12, 2025
c7f50b5
Fix decryption errors
robintown Nov 12, 2025
0115242
tests first batch
toger5 Nov 13, 2025
fdce3ec
Fix a couple of CallViewModel tests.
toger5 Nov 14, 2025
f6d0d73
fix call lifecycle and add tests.
toger5 Nov 14, 2025
8ce8067
Adding more logs (#3563)
BillCarsonFr Nov 14, 2025
da85e74
Restore LocalMembership tests
robintown Nov 14, 2025
b731c2e
dont import withCallViewModel in Lifecycle test
toger5 Nov 14, 2025
6d8d2d2
refact: extract withCallViewModel in test utils file
BillCarsonFr Nov 14, 2025
b9ad83b
fix a couple of more tests.
toger5 Nov 14, 2025
494ed7c
Fix the MatrixRTC mode developer controls
robintown Nov 14, 2025
53aaa01
Remove stray comma
robintown Nov 14, 2025
bb746dc
Fix a resource leak in the track processor code
robintown Nov 14, 2025
cf8298c
Fix lints
robintown Nov 14, 2025
348f0db
CallViewModel structure change. Clear input and outputs.
toger5 Nov 17, 2025
7ab90d3
Delete unused files
toger5 Nov 17, 2025
e9f400e
remove ReturnType
toger5 Nov 17, 2025
4414fe3
remove all `/*PUBLIC*/`
toger5 Nov 17, 2025
f518111
add back docstrings
toger5 Nov 17, 2025
49ec4b4
Merge pull request #3568 from element-hq/toger5/createCallViewModel$
toger5 Nov 17, 2025
b51df36
Move one-on-one layout into CallViewModel (#3567)
toger5 Nov 17, 2025
16e1c59
playwright: Fix error boundary mgmt or openId errors (#3570)
BillCarsonFr Nov 17, 2025
2e2c799
convert CallViewModel into create function pattern. (with much more
toger5 Nov 17, 2025
2a19f06
reword docstring
toger5 Nov 17, 2025
222982c
Merge pull request #3571 from element-hq/toger5/createCallViewModelFu…
toger5 Nov 17, 2025
614762b
fixup headers
BillCarsonFr Nov 18, 2025
0076b54
minor comments/TODO cleaning
BillCarsonFr Nov 18, 2025
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
20 changes: 15 additions & 5 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,22 @@
"livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"multi_sfu": "Multi-SFU media transport",
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
"prefer_sticky_events": {
"description": "Improves reliability of calls (requires homeserver support)",
"label": "Prefer sticky events"
"matrixRTCMode": {
"Comptibility": {
"description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
"label": "Compatibility: state events & multi SFU"
},
"Legacy": {
"description": "Compatible with old versions of EC that do not support multi SFU",
"label": "Legacy: state events & oldest membership SFU"
},
"Matrix_2_0": {
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
"label": "Matrix 2.0: sticky events & multi SFU"
},
"title": "MatrixRTC mode"
},
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
"show_connection_stats": "Show connection statistics",
"url_params": "URL parameters"
},
Expand Down
2 changes: 1 addition & 1 deletion src/button/ReactionToggleButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { type ReactNode } from "react";

import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test";
Expand Down
2 changes: 1 addition & 1 deletion src/button/ReactionToggleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
ReactionsRowSize,
} from "../reactions";
import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";

interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
Expand Down
14 changes: 9 additions & 5 deletions src/livekit/TrackProcessorContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "../settings/settings";
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";

//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
Expand Down Expand Up @@ -63,13 +64,17 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
return state$;
}

/**
* Updates your video tracks to always use the given processor.
*/
export const trackProcessorSync = (
scope: ObservableScope,
videoTrack$: Behavior<LocalVideoTrack | null>,
processor$: Behavior<ProcessorState>,
): void => {
// TODO-MULTI-SFU: Bind to an ObservableScope to avoid leaking resources.
combineLatest([videoTrack$, processor$]).subscribe(
([videoTrack, processorState]) => {
combineLatest([videoTrack$, processor$])
.pipe(scope.bind())
.subscribe(([videoTrack, processorState]) => {
if (!processorState) return;
if (!videoTrack) return;
const { processor } = processorState;
Expand All @@ -79,8 +84,7 @@ export const trackProcessorSync = (
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
},
);
});
};

export const useTrackProcessorSync = (
Expand Down
4 changes: 2 additions & 2 deletions src/livekit/openIDSFU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type OpenIDClientParts = Pick<
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
serviceUrl: string,
livekitAlias: string,
matrixRoomId: string,
): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
Expand All @@ -43,7 +43,7 @@ export async function getSFUConfigWithOpenID(
const sfuConfig = await getLiveKitJWT(
client,
serviceUrl,
livekitAlias,
matrixRoomId,
openIdToken,
);
logger.info(`Got JWT from call's active focus URL.`);
Expand Down
2 changes: 1 addition & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Initializer.initBeforeReact()
.then(() => {
root.render(
<StrictMode>
<App vm={new AppViewModel(globalScope)} />,
<App vm={new AppViewModel(globalScope)} />
</StrictMode>,
);
})
Expand Down
1 change: 1 addition & 0 deletions src/reactions/ReactionsReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ export class ReactionsReader {
);
return;
}
// TODO refactor to use memer id `membershipEvent.membershipID` (needs to happen in combination with other memberId refactors)
const identifier = `${membershipEvent.userId}:${membershipEvent.deviceId}`;

if (!content.emoji) {
Expand Down
2 changes: 1 addition & 1 deletion src/reactions/useReactionsSender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";

interface ReactionsSenderContextType {
Expand Down
2 changes: 1 addition & 1 deletion src/room/CallEventAudioRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
local,
localRtcMember,
} from "../utils/test-fixtures";
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel/CallViewModel";

vitest.mock("livekit-client/e2ee-worker?worker");
vitest.mock("../useAudioContext");
Expand Down
2 changes: 1 addition & 1 deletion src/room/CallEventAudioRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.

import { type ReactNode, useEffect } from "react";

import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3";
import joinCallSoundOgg from "../sound/join_call.ogg";
import leftCallSoundMp3 from "../sound/left_call.mp3";
Expand Down
15 changes: 8 additions & 7 deletions src/room/GroupCallView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ const leaveRTCSession = vi.hoisted(() =>
),
);

vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
// TODO: leaveRTCSession no longer exists! Tests need adapting.
return { ...orig, enterRTCSession, leaveRTCSession };
});
// vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// // TODO: perhaps there is a more elegant way to manage the type import here?
// // eslint-disable-next-line @typescript-eslint/consistent-type-imports
// const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
// // TODO: leaveRTCSession no longer exists! Tests need adapting.
// return { ...orig, enterRTCSession, leaveRTCSession };
// });

let playSound: MockedFunction<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
Expand Down Expand Up @@ -266,6 +266,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {

test.skip("GroupCallView shows errors that occur during joining", async () => {
const user = userEvent.setup();
// This should not mock this error that deep. it should only mock the CallViewModel.
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
onTestFinished(() => {
enterRTCSession.mockReset();
Expand Down
7 changes: 3 additions & 4 deletions src/room/InCallView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,6 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";

// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
// });
vi.hoisted(
() =>
(global.ImageData = class MockImageData {
Expand Down Expand Up @@ -109,6 +106,7 @@ function createInCallView(): RenderResult & {
getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null),
getDomain: () => "example.com",
} as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({
relations: {
Expand All @@ -119,7 +117,8 @@ function createInCallView(): RenderResult & {
} as unknown as RelationsContainer,
client,
roomId,
getMember: (userId) => roomMembers.get(userId) ?? null,
// getMember: (userId) => roomMembers.get(userId) ?? null,
getMembers: () => Array.from(roomMembers.values()),
getMxcAvatarUrl: () => null,
hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
getCanonicalAlias: () => null,
Expand Down
66 changes: 24 additions & 42 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ import { type MuteStates } from "../state/MuteStates";
import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import { CallViewModel, type GridMode } from "../state/CallViewModel";
import {
type CallViewModel,
createCallViewModel$,
type GridMode,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
Expand Down Expand Up @@ -115,17 +119,17 @@ export interface ActiveCallProps
}

export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const [vm, setVm] = useState<CallViewModel | null>(null);

const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
useUrlParams();

const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => {
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const vm = new CallViewModel(
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const vm = createCallViewModel$(
scope,
props.rtcSession,
props.matrixRoom,
Expand All @@ -138,7 +142,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
trackProcessorState$,
scope.behavior(trackProcessorState$),
);
setVm(vm);

Expand All @@ -149,13 +153,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}, [
props.rtcSession,
props.matrixRoom,
mediaDevices,
props.muteStates,
props.e2eeSystem,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
]);

Expand Down Expand Up @@ -247,7 +249,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 @@ -262,6 +263,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 @@ -298,47 +300,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 Expand Up @@ -819,7 +800,7 @@ export const InCallView: FC<InCallViewProps> = ({
key={url}
url={url}
livekitRoom={livekitRoom}
validIdentities={participants.map((p) => p.identity)}
validIdentities={participants}
muted={muteAllAudio}
/>
))}
Expand All @@ -841,7 +822,8 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={closeSettings}
tab={settingsTab}
onTabChange={setSettingsTab}
livekitRooms={allLivekitRooms}
// TODO expose correct data to setttings modal
livekitRooms={[]}
/>
</>
)}
Expand Down
Loading