Merge branch 'livekit' into valere/fix_blank_widget_auto_leave

This commit is contained in:
Valere
2025-11-20 10:41:31 +01:00
73 changed files with 8101 additions and 5103 deletions

View File

@@ -85,7 +85,7 @@ jobs:
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload - name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
with: with:
files: | files: |
${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}.tar.gz
@@ -100,7 +100,7 @@ jobs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }} ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions: permissions:
contents: read contents: read
id-token: write # required for the provenance flag on npm publish id-token: write # Allow npm to authenticate as a trusted publisher
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
@@ -126,8 +126,6 @@ jobs:
npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version
echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV" echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV"
npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }} npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
- id: artifact_version - id: artifact_version
name: Output artifact version name: Output artifact version
@@ -264,7 +262,7 @@ jobs:
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}" echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
- name: Add release notes - name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
with: with:
append_body: true append_body: true
body: | body: |

View File

@@ -42,7 +42,7 @@ jobs:
- name: Create Checksum - name: Create Checksum
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload - name: Upload
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
with: with:
files: | files: |
${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}.tar.gz
@@ -68,7 +68,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Add release note - name: Add release note
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2 uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
with: with:
append_body: true append_body: true
body: | body: |

View File

@@ -1 +1 @@
22 24

View File

@@ -18,7 +18,7 @@
"api_host": "https://posthog-element-call.element.io" "api_host": "https://posthog-element-call.element.io"
}, },
"rageshake": { "rageshake": {
"submit_url": "https://element.io/bugreports/submit" "submit_url": "https://rageshakes.element.io/api/submit"
}, },
"sentry": { "sentry": {
"environment": "netlify-pr-preview", "environment": "netlify-pr-preview",

View File

@@ -136,8 +136,8 @@ handle @jwt_service {
reverse_proxy http://[::1]:8080 { reverse_proxy http://[::1]:8080 {
header_up Host {host} header_up Host {host}
header_up X-Forwarded-Server {host} header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr} header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_addr} header_up X-Forwarded-For {remote_host}
} }
} }
@@ -146,8 +146,8 @@ handle {
reverse_proxy http://localhost:7880 { reverse_proxy http://localhost:7880 {
header_up Host {host} header_up Host {host}
header_up X-Forwarded-Server {host} header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr} header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_addr} header_up X-Forwarded-For {remote_host}
} }
} }
``` ```

View File

@@ -96,6 +96,6 @@ These parameters are only supported in the [embedded](./embedded-standalone.md)
| -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `posthogApiHost` | Posthog server URL | No | e.g. `https://posthog-element-call.element.io`. Only supported in embedded package. In full package the value from config is used. | | `posthogApiHost` | Posthog server URL | No | e.g. `https://posthog-element-call.element.io`. Only supported in embedded package. In full package the value from config is used. |
| `posthogApiKey` | Posthog project API key | No | Only supported in embedded package. In full package the value from config is used. | | `posthogApiKey` | Posthog project API key | No | Only supported in embedded package. In full package the value from config is used. |
| `rageshakeSubmitUrl` | Rageshake server URL endpoint | No | e.g. `https://element.io/bugreports/submit`. In full package the value from config is used. | | `rageshakeSubmitUrl` | Rageshake server URL endpoint | No | e.g. `https://rageshakes.element.io/api/submit`. In full package the value from config is used. |
| `sentryDsn` | Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) | No | In full package the value from config is used. | | `sentryDsn` | Sentry [DSN](https://docs.sentry.io/concepts/key-terms/dsn-explainer/) | No | In full package the value from config is used. |
| `sentryEnvironment` | Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/) | No | In full package the value from config is used. | | `sentryEnvironment` | Sentry [environment](https://docs.sentry.io/concepts/key-terms/key-terms/) | No | In full package the value from config is used. |

View File

@@ -2,7 +2,7 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions] [versions]
android_gradle_plugin = "8.11.1" android_gradle_plugin = "8.13.0"
[libraries] [libraries]
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }

View File

@@ -72,12 +72,22 @@
"livekit_server_info": "LiveKit Server Info", "livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}", "livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}", "matrix_id": "Matrix ID: {{id}}",
"multi_sfu": "Multi-SFU media transport", "matrixRTCMode": {
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)", "Comptibility": {
"prefer_sticky_events": { "description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
"description": "Improves reliability of calls (requires homeserver support)", "label": "Compatibility: state events & multi SFU"
"label": "Prefer sticky events" },
"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", "show_connection_stats": "Show connection statistics",
"url_params": "URL parameters" "url_params": "URL parameters"
}, },

View File

@@ -71,7 +71,7 @@
"@types/grecaptcha": "^3.0.9", "@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.0.0", "@types/node": "^24.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",

View File

@@ -13,7 +13,7 @@ import { type ReactNode } from "react";
import { ReactionToggleButton } from "./ReactionToggleButton"; import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions"; import { ElementCallReactionEventType } from "../reactions";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures"; import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test"; import { type MockRTCSession } from "../utils/test";

View File

@@ -33,7 +33,7 @@ import {
ReactionsRowSize, ReactionsRowSize,
} from "../reactions"; } from "../reactions";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {

View File

@@ -28,6 +28,7 @@ import {
} from "../settings/settings"; } from "../settings/settings";
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer"; import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
//TODO-MULTI-SFU: This is not yet fully there. //TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks. // it is a combination of exposing observable and react hooks.
@@ -63,13 +64,17 @@ export function useTrackProcessorObservable$(): Observable<ProcessorState> {
return state$; return state$;
} }
/**
* Updates your video tracks to always use the given processor.
*/
export const trackProcessorSync = ( export const trackProcessorSync = (
scope: ObservableScope,
videoTrack$: Behavior<LocalVideoTrack | null>, videoTrack$: Behavior<LocalVideoTrack | null>,
processor$: Behavior<ProcessorState>, processor$: Behavior<ProcessorState>,
): void => { ): void => {
// TODO-MULTI-SFU: Bind to an ObservableScope to avoid leaking resources. combineLatest([videoTrack$, processor$])
combineLatest([videoTrack$, processor$]).subscribe( .pipe(scope.bind())
([videoTrack, processorState]) => { .subscribe(([videoTrack, processorState]) => {
if (!processorState) return; if (!processorState) return;
if (!videoTrack) return; if (!videoTrack) return;
const { processor } = processorState; const { processor } = processorState;
@@ -79,8 +84,7 @@ export const trackProcessorSync = (
if (!processor && videoTrack.getProcessor()) { if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor(); void videoTrack.stopProcessor();
} }
}, });
);
}; };
export const useTrackProcessorSync = ( export const useTrackProcessorSync = (

View File

@@ -25,7 +25,7 @@ export type OpenIDClientParts = Pick<
export async function getSFUConfigWithOpenID( export async function getSFUConfigWithOpenID(
client: OpenIDClientParts, client: OpenIDClientParts,
serviceUrl: string, serviceUrl: string,
livekitAlias: string, matrixRoomId: string,
): Promise<SFUConfig> { ): Promise<SFUConfig> {
let openIdToken: IOpenIDToken; let openIdToken: IOpenIDToken;
try { try {
@@ -43,7 +43,7 @@ export async function getSFUConfigWithOpenID(
const sfuConfig = await getLiveKitJWT( const sfuConfig = await getLiveKitJWT(
client, client,
serviceUrl, serviceUrl,
livekitAlias, matrixRoomId,
openIdToken, openIdToken,
); );
logger.info(`Got JWT from call's active focus URL.`); logger.info(`Got JWT from call's active focus URL.`);

View File

@@ -62,7 +62,7 @@ Initializer.initBeforeReact()
.then(() => { .then(() => {
root.render( root.render(
<StrictMode> <StrictMode>
<App vm={new AppViewModel(globalScope)} />, <App vm={new AppViewModel(globalScope)} />
</StrictMode>, </StrictMode>,
); );
}) })

View File

@@ -266,6 +266,7 @@ export class ReactionsReader {
); );
return; return;
} }
// TODO refactor to use memer id `membershipEvent.membershipID` (needs to happen in combination with other memberId refactors)
const identifier = `${membershipEvent.userId}:${membershipEvent.deviceId}`; const identifier = `${membershipEvent.userId}:${membershipEvent.deviceId}`;
if (!content.emoji) { if (!content.emoji) {

View File

@@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext"; import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from "."; import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
interface ReactionsSenderContextType { interface ReactionsSenderContextType {

View File

@@ -38,7 +38,7 @@ import {
local, local,
localRtcMember, localRtcMember,
} from "../utils/test-fixtures"; } 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("livekit-client/e2ee-worker?worker");
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode, useEffect } from "react"; 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 joinCallSoundMp3 from "../sound/join_call.mp3";
import joinCallSoundOgg from "../sound/join_call.ogg"; import joinCallSoundOgg from "../sound/join_call.ogg";
import leftCallSoundMp3 from "../sound/left_call.mp3"; import leftCallSoundMp3 from "../sound/left_call.mp3";

View File

@@ -78,13 +78,13 @@ const leaveRTCSession = vi.hoisted(() =>
), ),
); );
vi.mock("../rtcSessionHelpers", async (importOriginal) => { // vi.mock("../rtcSessionHelpers", async (importOriginal) => {
// TODO: perhaps there is a more elegant way to manage the type import here? // // TODO: perhaps there is a more elegant way to manage the type import here?
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // // eslint-disable-next-line @typescript-eslint/consistent-type-imports
const orig = await importOriginal<typeof import("../rtcSessionHelpers")>(); // const orig = await importOriginal<typeof import("../rtcSessionHelpers")>();
// TODO: leaveRTCSession no longer exists! Tests need adapting. // // TODO: leaveRTCSession no longer exists! Tests need adapting.
return { ...orig, enterRTCSession, leaveRTCSession }; // return { ...orig, enterRTCSession, leaveRTCSession };
}); // });
let playSound: MockedFunction< let playSound: MockedFunction<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"] NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
@@ -346,6 +346,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
test.skip("GroupCallView shows errors that occur during joining", async () => { test.skip("GroupCallView shows errors that occur during joining", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
// This should not mock this error that deep. it should only mock the CallViewModel.
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError("")); enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
onTestFinished(() => { onTestFinished(() => {
enterRTCSession.mockReset(); enterRTCSession.mockReset();

View File

@@ -43,9 +43,6 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
// });
vi.hoisted( vi.hoisted(
() => () =>
(global.ImageData = class MockImageData { (global.ImageData = class MockImageData {
@@ -109,6 +106,7 @@ function createInCallView(): RenderResult & {
getUserId: () => localRtcMember.userId, getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId, getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null), getRoom: (rId) => (rId === roomId ? room : null),
getDomain: () => "example.com",
} as Partial<MatrixClient> as MatrixClient; } as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({ const room = mockMatrixRoom({
relations: { relations: {
@@ -119,7 +117,8 @@ function createInCallView(): RenderResult & {
} as unknown as RelationsContainer, } as unknown as RelationsContainer,
client, client,
roomId, roomId,
getMember: (userId) => roomMembers.get(userId) ?? null, // getMember: (userId) => roomMembers.get(userId) ?? null,
getMembers: () => Array.from(roomMembers.values()),
getMxcAvatarUrl: () => null, getMxcAvatarUrl: () => null,
hasEncryptionStateEvent: vi.fn().mockReturnValue(true), hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
getCanonicalAlias: () => null, getCanonicalAlias: () => null,

View File

@@ -58,7 +58,11 @@ import { type MuteStates } from "../state/MuteStates";
import { type MatrixInfo } from "./VideoPreview"; import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton"; import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle"; 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 { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile"; import { SpotlightTile } from "../tile/SpotlightTile";
@@ -117,17 +121,17 @@ export interface ActiveCallProps
} }
export const ActiveCall: FC<ActiveCallProps> = (props) => { export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const [vm, setVm] = useState<CallViewModel | null>(null); const [vm, setVm] = useState<CallViewModel | null>(null);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = const urlParams = useUrlParams();
useUrlParams(); const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$(); const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => { useEffect(() => {
const scope = new ObservableScope(); const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession); const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const vm = new CallViewModel( const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const vm = createCallViewModel$(
scope, scope,
props.rtcSession, props.rtcSession,
props.matrixRoom, props.matrixRoom,
@@ -140,7 +144,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}, },
reactionsReader.raisedHands$, reactionsReader.raisedHands$,
reactionsReader.reactions$, reactionsReader.reactions$,
trackProcessorState$, scope.behavior(trackProcessorState$),
); );
setVm(vm); setVm(vm);
@@ -151,13 +155,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}, [ }, [
props.rtcSession, props.rtcSession,
props.matrixRoom, props.matrixRoom,
mediaDevices,
props.muteStates, props.muteStates,
props.e2eeSystem, props.e2eeSystem,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
props.onLeft, props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$, trackProcessorState$,
]); ]);
@@ -249,7 +251,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$);
@@ -264,6 +265,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;
@@ -300,47 +302,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
@@ -821,7 +802,7 @@ export const InCallView: FC<InCallViewProps> = ({
key={url} key={url}
url={url} url={url}
livekitRoom={livekitRoom} livekitRoom={livekitRoom}
validIdentities={participants.map((p) => p.identity)} validIdentities={participants}
muted={muteAllAudio} muted={muteAllAudio}
/> />
))} ))}
@@ -843,7 +824,8 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={closeSettings} onDismiss={closeSettings}
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
livekitRooms={allLivekitRooms} // TODO expose correct data to setttings modal
livekitRooms={[]}
/> />
</> </>
)} )}

View File

@@ -10,8 +10,9 @@ import {
afterAll, afterAll,
afterEach, afterEach,
beforeEach, beforeEach,
describe,
expect, expect,
test, it,
vitest, vitest,
type MockedFunction, type MockedFunction,
type Mock, type Mock,
@@ -27,7 +28,7 @@ import {
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { GenericReaction, ReactionSet } from "../reactions"; import { GenericReaction, ReactionSet } from "../reactions";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { import {
alice, alice,
@@ -49,122 +50,125 @@ vitest.mock("livekit-client/e2ee-worker?worker");
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils"); vitest.mock("../soundUtils");
afterEach(() => {
vitest.resetAllMocks();
playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
});
afterAll(() => {
vitest.restoreAllMocks();
});
let playSound: Mock< let playSound: Mock<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"] NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
>; >;
beforeEach(() => { describe("ReactionAudioRenderer", () => {
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({ afterEach(() => {
sound: new ArrayBuffer(0), playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
}); });
playSound = vitest.fn(); beforeEach(() => {
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue(
playSound, {
playSoundLooping: vitest.fn(), sound: new ArrayBuffer(0),
soundDuration: {}, },
});
});
test("preloads all audio elements", () => {
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
expect(prefetchSounds).toHaveBeenCalledOnce();
});
test("will play an audio sound when there is a reaction", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !!r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
); );
} playSound = vitest.fn();
act(() => { (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue(
reactionsSubject$.next({ {
[aliceRtcMember.deviceId]: { playSound,
reactionOption: chosenReaction, playSoundLooping: vitest.fn(),
expireAfter: new Date(0), soundDuration: {},
}, },
});
});
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
});
test("will play the generic audio sound when there is soundless reaction", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
); );
}
act(() => {
reactionsSubject$.next({
[aliceRtcMember.deviceId]: {
reactionOption: chosenReaction,
expireAfter: new Date(0),
},
});
}); });
expect(playSound).toHaveBeenCalledWith(GenericReaction.name); afterAll(() => {
}); vitest.restoreAllMocks();
});
test("will play multiple audio sounds when there are multiple different reactions", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([ it("preloads all audio elements", () => {
local, const { vm } = getBasicCallViewModelEnvironment([local, alice]);
alice, playReactionsSoundSetting.setValue(true);
]); render(<TestComponent vm={vm} />);
playReactionsSoundSetting.setValue(true); expect(prefetchSounds).toHaveBeenCalledOnce();
render(<TestComponent vm={vm} />); });
// Find the first reaction with a sound effect it("will play an audio sound when there is a reaction", () => {
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound); const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
if (!reaction1 || !reaction2) { local,
throw Error( alice,
"No reactions have sounds configured, this test cannot succeed", ]);
); playReactionsSoundSetting.setValue(true);
} render(<TestComponent vm={vm} />);
act(() => {
reactionsSubject$.next({ // Find the first reaction with a sound effect
[aliceRtcMember.deviceId]: { const chosenReaction = ReactionSet.find((r) => !!r.sound);
reactionOption: reaction1, if (!chosenReaction) {
expireAfter: new Date(0), throw Error(
}, "No reactions have sounds configured, this test cannot succeed",
[bobRtcMember.deviceId]: { );
reactionOption: reaction2, }
expireAfter: new Date(0), act(() => {
}, reactionsSubject$.next({
[localRtcMember.deviceId]: { [aliceRtcMember.deviceId]: {
reactionOption: reaction1, reactionOption: chosenReaction,
expireAfter: new Date(0), expireAfter: new Date(0),
}, },
}); });
});
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
});
it("will play the generic audio sound when there is soundless reaction", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
reactionsSubject$.next({
[aliceRtcMember.deviceId]: {
reactionOption: chosenReaction,
expireAfter: new Date(0),
},
});
});
expect(playSound).toHaveBeenCalledWith(GenericReaction.name);
});
it("will play multiple audio sounds when there are multiple different reactions", () => {
const { vm, reactionsSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound);
if (!reaction1 || !reaction2) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
reactionsSubject$.next({
[aliceRtcMember.deviceId]: {
reactionOption: reaction1,
expireAfter: new Date(0),
},
[bobRtcMember.deviceId]: {
reactionOption: reaction2,
expireAfter: new Date(0),
},
[localRtcMember.deviceId]: {
reactionOption: reaction1,
expireAfter: new Date(0),
},
});
});
expect(playSound).toHaveBeenCalledWith(reaction1.name);
expect(playSound).toHaveBeenCalledWith(reaction2.name);
}); });
expect(playSound).toHaveBeenCalledWith(reaction1.name);
expect(playSound).toHaveBeenCalledWith(reaction2.name);
}); });

View File

@@ -12,7 +12,7 @@ import { GenericReaction, ReactionSet } from "../reactions";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
const soundMap = Object.fromEntries([ const soundMap = Object.fromEntries([
...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [ ...ReactionSet.filter((v) => v.sound !== undefined).map((v) => [

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import styles from "./ReactionsOverlay.module.css"; import styles from "./ReactionsOverlay.module.css";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {

View File

@@ -83,9 +83,6 @@ exports[`InCallView > rendering > renders 1`] = `
class="nav rightNav" class="nav rightNav"
/> />
</header> </header>
<div>
mocked: MatrixAudioRenderer
</div>
<div <div
class="scrollingGrid grid" class="scrollingGrid grid"
> >

View File

@@ -1,161 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type MatrixRTCSession,
isLivekitTransportConfig,
type LivekitTransportConfig,
type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { Config } from "./config/Config";
import { ElementWidgetActions, widget } from "./widget";
import { MatrixRTCTransportMissingError } from "./utils/errors";
import { getUrlParams } from "./UrlParams";
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
export function getLivekitAlias(rtcSession: MatrixRTCSession): string {
// For now we assume everything is a room-scoped call
return rtcSession.room.roomId;
}
async function makeTransportInternal(
rtcSession: MatrixRTCSession,
): Promise<LivekitTransport> {
logger.log("Searching for a preferred transport");
const livekitAlias = getLivekitAlias(rtcSession);
// TODO-MULTI-SFU: Either remove this dev tool or make it more official
const urlFromStorage =
localStorage.getItem("robin-matrixrtc-auth") ??
localStorage.getItem("timo-focus-url");
if (urlFromStorage !== null) {
const transportFromStorage: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromStorage,
livekit_alias: livekitAlias,
};
logger.log(
"Using LiveKit transport from local storage: ",
transportFromStorage,
);
return transportFromStorage;
}
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
const domain = rtcSession.room.client.getDomain();
if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
FOCI_WK_KEY
];
if (Array.isArray(wellKnownFoci)) {
const transport: LivekitTransportConfig | undefined = wellKnownFoci.find(
(f) => f && isLivekitTransportConfig(f),
);
if (transport !== undefined) {
logger.log("Using LiveKit transport from .well-known: ", transport);
return { ...transport, livekit_alias: livekitAlias };
}
}
}
const urlFromConf = Config.get().livekit?.livekit_service_url;
if (urlFromConf) {
const transportFromConf: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
logger.log("Using LiveKit transport from config: ", transportFromConf);
return transportFromConf;
}
throw new MatrixRTCTransportMissingError(domain ?? "");
}
export async function makeTransport(
rtcSession: MatrixRTCSession,
): Promise<LivekitTransport> {
const transport = await makeTransportInternal(rtcSession);
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
await getSFUConfigWithOpenID(
rtcSession.room.client,
transport.livekit_service_url,
transport.livekit_alias,
);
return transport;
}
export interface EnterRTCSessionOptions {
encryptMedia: boolean;
/** EXPERIMENTAL: If true, will use the multi-sfu codepath where each member connects to its SFU instead of everyone connecting to an elected on. */
useMultiSfu: boolean;
preferStickyEvents: boolean;
}
/**
* TODO! document this function properly
* @param rtcSession
* @param transport
* @param options
*/
export async function enterRTCSession(
rtcSession: MatrixRTCSession,
transport: LivekitTransport,
{ encryptMedia, useMultiSfu, preferStickyEvents }: EnterRTCSessionOptions,
): Promise<void> {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
// groupCallOTelMembership?.onJoinCall();
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
rtcSession.joinRoomSession(
useMultiSfu ? [] : [transport],
useMultiSfu ? transport : undefined,
{
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport: true,
unstableSendStickyEvents: preferStickyEvents,
},
);
if (widget) {
try {
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
} catch (e) {
logger.error("Failed to send join action", e);
}
}
}

View File

@@ -12,6 +12,7 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
useId,
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -19,6 +20,14 @@ import {
type MatrixClient, type MatrixClient,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import {
Root as Form,
Heading,
HelpMessage,
InlineField,
Label,
RadioControl,
} from "@vector-im/compound-web";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { import {
@@ -26,10 +35,10 @@ import {
duplicateTiles as duplicateTilesSetting, duplicateTiles as duplicateTilesSetting,
debugTileLayout as debugTileLayoutSetting, debugTileLayout as debugTileLayoutSetting,
showConnectionStats as showConnectionStatsSetting, showConnectionStats as showConnectionStatsSetting,
multiSfu as multiSfuSetting,
muteAllAudio as muteAllAudioSetting, muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
preferStickyEvents as preferStickyEventsSetting, matrixRTCMode as matrixRTCModeSetting,
MatrixRTCMode,
} from "./settings"; } from "./settings";
import type { Room as LivekitRoom } from "livekit-client"; import type { Room as LivekitRoom } from "livekit-client";
import styles from "./DeveloperSettingsTab.module.css"; import styles from "./DeveloperSettingsTab.module.css";
@@ -59,8 +68,13 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
}); });
}, [client]); }, [client]);
const [preferStickyEvents, setPreferStickyEvents] = useSetting( const [matrixRTCMode, setMatrixRTCMode] = useSetting(matrixRTCModeSetting);
preferStickyEventsSetting, const matrixRTCModeRadioGroup = useId();
const onMatrixRTCModeChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setMatrixRTCMode(e.target.value as MatrixRTCMode);
},
[setMatrixRTCMode],
); );
const [showConnectionStats, setShowConnectionStats] = useSetting( const [showConnectionStats, setShowConnectionStats] = useSetting(
@@ -71,8 +85,6 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
alwaysShowIphoneEarpieceSetting, alwaysShowIphoneEarpieceSetting,
); );
const [multiSfu, setMultiSfu] = useSetting(multiSfuSetting);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting); const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
const urlParams = useUrlParams(); const urlParams = useUrlParams();
@@ -89,7 +101,7 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
}, [livekitRooms]); }, [livekitRooms]);
return ( return (
<> <Form>
<p> <p>
{t("developer_mode.hostname", { {t("developer_mode.hostname", {
hostname: window.location.hostname || "unknown", hostname: window.location.hostname || "unknown",
@@ -146,22 +158,6 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
} }
/> />
</FieldRow> </FieldRow>
<FieldRow>
<InputField
id="preferStickyEvents"
type="checkbox"
label={t("developer_mode.prefer_sticky_events.label")}
disabled={!stickyEventsSupported}
description={t("developer_mode.prefer_sticky_events.description")}
checked={!!preferStickyEvents}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setPreferStickyEvents(event.target.checked);
},
[setPreferStickyEvents],
)}
/>
</FieldRow>
<FieldRow> <FieldRow>
<InputField <InputField
id="showConnectionStats" id="showConnectionStats"
@@ -176,22 +172,6 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
)} )}
/> />
</FieldRow> </FieldRow>
<FieldRow>
<InputField
id="multiSfu"
type="checkbox"
label={t("developer_mode.multi_sfu")}
// If using sticky events we implicitly prefer use multi-sfu
checked={multiSfu || preferStickyEvents}
disabled={preferStickyEvents}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setMultiSfu(event.target.checked);
},
[setMultiSfu],
)}
/>
</FieldRow>
<FieldRow> <FieldRow>
<InputField <InputField
id="muteAllAudio" id="muteAllAudio"
@@ -220,6 +200,55 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
)} )}
/>{" "} />{" "}
</FieldRow> </FieldRow>
<Heading as="h3" type="body" weight="semibold" size="lg">
{t("developer_mode.matrixRTCMode.title")}
</Heading>
<InlineField
name={matrixRTCModeRadioGroup}
control={
<RadioControl
checked={matrixRTCMode === MatrixRTCMode.Legacy}
value={MatrixRTCMode.Legacy}
onChange={onMatrixRTCModeChange}
/>
}
>
<Label>{t("developer_mode.matrixRTCMode.Legacy.label")}</Label>
<HelpMessage>
{t("developer_mode.matrixRTCMode.Legacy.description")}
</HelpMessage>
</InlineField>
<InlineField
name={matrixRTCModeRadioGroup}
control={
<RadioControl
checked={matrixRTCMode === MatrixRTCMode.Compatibil}
value={MatrixRTCMode.Compatibil}
onChange={onMatrixRTCModeChange}
/>
}
>
<Label>{t("developer_mode.matrixRTCMode.Comptibility.label")}</Label>
<HelpMessage>
{t("developer_mode.matrixRTCMode.Comptibility.description")}
</HelpMessage>
</InlineField>
<InlineField
name={matrixRTCModeRadioGroup}
control={
<RadioControl
checked={matrixRTCMode === MatrixRTCMode.Matrix_2_0}
value={MatrixRTCMode.Matrix_2_0}
disabled={!stickyEventsSupported}
onChange={onMatrixRTCModeChange}
/>
}
>
<Label>{t("developer_mode.matrixRTCMode.Matrix_2_0.label")}</Label>
<HelpMessage>
{t("developer_mode.matrixRTCMode.Matrix_2_0.description")}
</HelpMessage>
</InlineField>
{livekitRooms?.map((livekitRoom) => ( {livekitRooms?.map((livekitRoom) => (
<> <>
<h3> <h3>
@@ -244,6 +273,6 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRooms }) => {
<pre>{JSON.stringify(import.meta.env, null, 2)}</pre> <pre>{JSON.stringify(import.meta.env, null, 2)}</pre>
<p>{t("developer_mode.url_params")}</p> <p>{t("developer_mode.url_params")}</p>
<pre>{JSON.stringify(urlParams, null, 2)}</pre> <pre>{JSON.stringify(urlParams, null, 2)}</pre>
</> </Form>
); );
}; };

View File

@@ -83,11 +83,6 @@ export const showConnectionStats = new Setting<boolean>(
false, false,
); );
export const preferStickyEvents = new Setting<boolean>(
"prefer-sticky-events",
false,
);
export const audioInput = new Setting<string | undefined>( export const audioInput = new Setting<string | undefined>(
"audio-input", "audio-input",
undefined, undefined,
@@ -120,8 +115,6 @@ export const soundEffectVolume = new Setting<number>(
0.5, 0.5,
); );
export const multiSfu = new Setting<boolean>("multi-sfu", false);
export const muteAllAudio = new Setting<boolean>("mute-all-audio", false); export const muteAllAudio = new Setting<boolean>("mute-all-audio", false);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true); export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
@@ -130,3 +123,14 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
"always-show-iphone-earpiece", "always-show-iphone-earpiece",
false, false,
); );
export enum MatrixRTCMode {
Legacy = "legacy",
Compatibil = "compatibil",
Matrix_2_0 = "matrix_2_0",
}
export const matrixRTCMode = new Setting<MatrixRTCMode>(
"matrix-rtc-mode",
MatrixRTCMode.Legacy,
);

View File

@@ -1,53 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { catchError, from, map, type Observable, of, startWith } from "rxjs";
/**
* Data that may need to be loaded asynchronously.
*
* This type is for when you need to represent the current state of an operation
* involving Promises as **immutable data**. See the async$ function below.
*/
export type Async<A> =
| { state: "loading" }
| { state: "error"; value: Error }
| { state: "ready"; value: A };
export const loading: Async<never> = { state: "loading" };
export function error(value: Error): Async<never> {
return { state: "error", value };
}
export function ready<A>(value: A): Async<A> {
return { state: "ready", value };
}
/**
* Turn a Promise into an Observable async value. The Observable will have the
* value "loading" while the Promise is pending, "ready" when the Promise
* resolves, and "error" when the Promise rejects.
*/
export function async$<A>(promise: Promise<A>): Observable<Async<A>> {
return from(promise).pipe(
map(ready),
startWith(loading),
catchError((e: unknown) =>
of(error((e as Error) ?? new Error("Unknown error"))),
),
);
}
/**
* If the async value is ready, apply the given function to the inner value.
*/
export function mapAsync<A, B>(
async: Async<A>,
project: (value: A) => B,
): Async<B> {
return async.state === "ready" ? ready(project(async.value)) : async;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type ICallNotifyContent,
type IRTCNotificationContent,
} from "matrix-js-sdk/lib/matrixrtc";
import { describe, it } from "vitest";
import {
EventType,
type IEvent,
type IRoomTimelineData,
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import { withTestScheduler } from "../../utils/test";
import {
aliceRtcMember,
local,
localRtcMember,
} from "../../utils/test-fixtures";
import {
createCallNotificationLifecycle$,
type Props as CallNotificationLifecycleProps,
} from "./CallNotificationLifecycle";
import { trackEpoch } from "../ObservableScope";
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
function mockRingEvent(
eventId: string,
lifetimeMs: number | undefined,
sender = local.userId,
): { event_id: string } & IRTCNotificationContent {
return {
event_id: eventId,
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
notification_type: "ring",
sender,
} as unknown as { event_id: string } & IRTCNotificationContent;
}
describe("waitForCallPickup$", () => {
it("unknown -> ringing -> timeout when notified and nobody joins", () => {
withTestScheduler(({ scope, expectObservable, behavior, hot }) => {
// No one ever joins (only local user)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", { a: [] }).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: [mockRingEvent("$notif1", 30), mockLegacyRingEvent],
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
});
});
it("ringing -> success if someone joins before timeout is reached", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a 19ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("5ms a", {
a: [mockRingEvent("$notif2", 100), mockLegacyRingEvent],
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", {
a: "unknown",
b: "ringing",
c: "success",
});
});
});
it("success when someone joins before we notify", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a 9ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("20ms a", {
a: [mockRingEvent("$notif2", 50), mockLegacyRingEvent],
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
a: "unknown",
b: "success",
});
});
});
it("notify without lifetime -> immediate timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: [mockRingEvent("$notif2", undefined), mockLegacyRingEvent],
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
a: "unknown",
b: "timeout",
});
});
});
it("stays null when waitForCallPickup=false", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const validProps: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a--b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: [mockRingEvent("$notif5", 30), mockLegacyRingEvent],
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const propsDeactivated = {
...validProps,
options: {
...validProps.options,
waitForCallPickup: false,
},
};
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
expectObservable(lifecycle.callPickupState$).toBe("n", {
n: null,
});
const lifecycleReference = createCallNotificationLifecycle$(validProps);
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", {
u: "unknown",
s: "success",
});
});
});
it("decline before timeout window ends -> decline", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: [mockRingEvent("$decl1", 50), mockLegacyRingEvent],
}),
receivedDecline$: hot("40ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
a: "unknown",
b: "ringing",
e: "decline",
});
});
});
it("decline after timeout window ends -> stays timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: [mockRingEvent("$decl", 20), mockLegacyRingEvent],
}),
receivedDecline$: hot("40ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
"a 9ms b 19ms e",
{
a: "unknown",
b: "ringing",
e: "timeout",
},
);
});
});
//
function testStaysRinging(
declineEvent: Partial<IEvent>,
expectDecline: boolean,
): void {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: [mockRingEvent("$right", 50), mockLegacyRingEvent],
}),
receivedDecline$: hot("20ms d", {
d: [
new MatrixEvent(declineEvent),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b";
expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, {
a: "unknown",
b: "ringing",
d: "decline",
});
});
}
const reference = (refId?: string, sender?: string): Partial<IEvent> => ({
event_id: "$decline",
type: EventType.RTCDecline,
sender: sender ?? "@other:example.org",
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: refId ?? "$right",
},
},
});
it("decline reference works", () => {
testStaysRinging(reference(), true);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference("$wrong"), false);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference(undefined, local.userId), false);
});
});

View File

@@ -0,0 +1,211 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type CallMembership,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
concat,
endWith,
filter,
fromEvent,
ignoreElements,
map,
merge,
NEVER,
type Observable,
of,
pairwise,
startWith,
switchMap,
takeUntil,
timer,
} from "rxjs";
import {
type EventTimelineSetHandlerMap,
EventType,
type Room as MatrixRoom,
RoomEvent,
} from "matrix-js-sdk";
import { type Behavior } from "../Behavior";
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
export type CallPickupState =
| "unknown"
| "ringing"
| "timeout"
| "decline"
| "success"
| null;
export type CallNotificationWrapper = Parameters<
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
>;
export function createSentCallNotification$(
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<CallNotificationWrapper | null> {
const sentCallNotification$ = scope.behavior(
fromEvent(matrixRTCSession, MatrixRTCSessionEvent.DidSendCallNotification),
null,
) as Behavior<CallNotificationWrapper | null>;
return sentCallNotification$;
}
export function createReceivedDecline$(
matrixRoom: MatrixRoom,
): Observable<Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>> {
return (
fromEvent(matrixRoom, RoomEvent.Timeline) as Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(filter(([event]) => event.getType() === EventType.RTCDecline));
}
export interface Props {
scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>;
sentCallNotification$: Observable<CallNotificationWrapper | null>;
receivedDecline$: Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>;
options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean };
localUser: { deviceId: string; userId: string };
}
/**
* @returns {callPickupState$, autoLeave$}
* `callPickupState$` The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* This may also be set if we are disconnected.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*
* `autoLeave$` An observable that emits (null) when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*
*/
export function createCallNotificationLifecycle$({
scope,
memberships$,
sentCallNotification$,
receivedDecline$,
options,
localUser,
}: Props): {
callPickupState$: Behavior<CallPickupState>;
autoLeave$: Observable<AutoLeaveReason>;
} {
const allOthersLeft$ = memberships$.pipe(
pairwise(),
filter(
([{ value: prev }, { value: current }]) =>
current.every((m) => m.userId === localUser.userId) &&
prev.some((m) => m.userId !== localUser.userId),
),
map(() => {}),
);
/**
* Whether some Matrix user other than ourself is joined to the call.
*/
const someoneElseJoined$ = memberships$.pipe(
mapEpoch((ms) => ms.some((m) => m.userId !== localUser.userId)),
) as Behavior<Epoch<boolean>>;
/**
* Whenever the RTC session tells us that it intends to ring the remote
* participant's devices, this emits an Observable tracking the current state of
* that ringing process.
*/
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
// A behavior will emit the latest observable with the running timer to new subscribers.
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
// `ring$` would not be a behavior.
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
scope.behavior(
sentCallNotification$.pipe(
filter(
(newAndLegacyEvents) =>
// only care about new events (legacy do not have decline pattern)
newAndLegacyEvents?.[0].notification_type === "ring",
),
map((e) => e as CallNotificationWrapper),
switchMap(([notificationEvent]) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
of(null)
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
// This makes sure we will not drop into the `endWith("decline" as const)` state
NEVER,
).pipe(
takeUntil(
receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id ===
notificationEvent.event_id &&
event.getSender() !== localUser.userId &&
callPickupState$.value !== "timeout",
),
),
),
endWith("decline" as const),
);
}),
),
null,
);
const callPickupState$ = scope.behavior(
options.waitForCallPickup === true
? combineLatest(
[someoneElseJoined$, remoteRingState$],
(someoneElseJoined, ring) => {
if (someoneElseJoined.value === true) {
return "success" as const;
}
// Show the ringing state of the most recent ringing attempt.
// as long as we have not yet sent an RTC notification event or noone else joined,
// ring will be null -> callPickupState$ = unknown.
return ring ?? ("unknown" as const);
},
)
: NEVER,
null,
);
const autoLeave$ = merge(
options.autoLeaveWhenOthersLeft === true
? allOthersLeft$.pipe(map(() => "allOthersLeft" as const))
: NEVER,
callPickupState$.pipe(
filter((state) => state === "timeout" || state === "decline"),
),
);
return { autoLeave$, callPickupState$ };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
/*
Copyright 2025 Element Corp.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ConnectionState,
type LocalParticipant,
type Participant,
ParticipantEvent,
type RemoteParticipant,
type Room as LivekitRoom,
} from "livekit-client";
import { SyncState } from "matrix-js-sdk/lib/sync";
import { BehaviorSubject, type Observable, map, of } from "rxjs";
import { onTestFinished, vi } from "vitest";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import EventEmitter from "events";
import * as ComponentsCore from "@livekit/components-core";
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { E2eeType } from "../../e2ee/e2eeType";
import { type RaisedHandInfo, type ReactionInfo } from "../../reactions";
import {
type CallViewModel,
createCallViewModel$,
type CallViewModelOptions,
} from "./CallViewModel";
import {
mockConfig,
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices,
mockMuteStates,
MockRTCSession,
testScope,
} from "../../utils/test";
import {
alice,
aliceDoppelganger,
bob,
bobZeroWidthSpace,
daveRTL,
daveRTLRtcMember,
local,
localRtcMember,
} from "../../utils/test-fixtures";
import { type Behavior, constant } from "../Behavior";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type MediaDevices } from "../MediaDevices";
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
const carol = local;
const dave = mockMatrixRoomMember(daveRTLRtcMember, { rawDisplayName: "Dave" });
const roomMembers = new Map(
[alice, aliceDoppelganger, bob, bobZeroWidthSpace, carol, dave, daveRTL].map(
(p) => [p.userId, p],
),
);
export interface CallViewModelInputs {
remoteParticipants$: Behavior<RemoteParticipant[]>;
rtcMembers$: Behavior<Partial<CallMembership>[]>;
livekitConnectionState$: Behavior<ConnectionState>;
speaking: Map<Participant, Observable<boolean>>;
mediaDevices: MediaDevices;
initialSyncState: SyncState;
}
const localParticipant = mockLocalParticipant({ identity: "" });
export function withCallViewModel(
{
remoteParticipants$ = constant([]),
rtcMembers$ = constant([localRtcMember]),
livekitConnectionState$: connectionState$ = constant(
ConnectionState.Connected,
),
speaking = new Map(),
mediaDevices = mockMediaDevices({}),
initialSyncState = SyncState.Syncing,
}: Partial<CallViewModelInputs> = {},
continuation: (
vm: CallViewModel,
rtcSession: MockRTCSession,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
setSyncState: (value: SyncState) => void,
) => void,
options: CallViewModelOptions = {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false,
},
): void {
let syncState = initialSyncState;
const setSyncState = (value: SyncState): void => {
const prev = syncState;
syncState = value;
room.client.emit(ClientEvent.Sync, value, prev);
};
const room = mockMatrixRoom({
client: new (class extends EventEmitter {
public getUserId(): string | undefined {
return localRtcMember.userId;
}
public getDeviceId(): string {
return localRtcMember.deviceId;
}
public getDomain(): string {
return "example.com";
}
public getSyncState(): SyncState {
return syncState;
}
})() 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
.spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants$);
const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) =>
of({ participant: p } as Partial<
ComponentsCore.ParticipantMedia<LocalParticipant>
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
);
const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p, ...eventTypes) => {
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
return (speaking.get(p) ?? of(false)).pipe(
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
);
} else {
return of(p);
}
});
const roomEventSelectorSpy = vi
.spyOn(ComponentsCore, "roomEventSelector")
.mockImplementation((_room, _eventType) => of());
const muteStates = mockMuteStates();
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
room,
mediaDevices,
muteStates,
{
...options,
livekitRoomFactory: (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
}),
connectionState$,
},
raisedHands$,
reactions$,
new BehaviorSubject<ProcessorState>({
processor: undefined,
supported: undefined,
}),
);
onTestFinished(() => {
participantsSpy.mockRestore();
mediaSpy.mockRestore();
eventsSpy.mockRestore();
roomEventSelectorSpy.mockRestore();
});
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
}

View File

@@ -1,4 +1,5 @@
/* /*
Copyright 2025 Element Creations Ltd.
Copyright 2024 New Vector Ltd. Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -10,16 +11,16 @@ import { expect, test, vi } from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import EventEmitter from "events"; import EventEmitter from "events";
import { enterRTCSession } from "../src/rtcSessionHelpers"; import { MatrixRTCMode } from "../../../settings/settings";
import { mockConfig } from "./utils/test"; import { mockConfig } from "../../../utils/test";
import { enterRTCSession } from "./LocalMembership";
const USE_MUTI_SFU = false; const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("./UrlParams", () => ({ getUrlParams })); vi.mock("../../../UrlParams", () => ({ getUrlParams }));
const actualWidget = await vi.hoisted(async () => vi.importActual("./widget")); vi.mock("../../../widget", async (importOriginal) => ({
vi.mock("./widget", () => ({ ...(await importOriginal()),
...actualWidget,
widget: { widget: {
api: { api: {
setAlwaysOnScreen: (): void => {}, setAlwaysOnScreen: (): void => {},
@@ -94,8 +95,7 @@ test("It joins the correct Session", async () => {
}, },
{ {
encryptMedia: true, encryptMedia: true,
useMultiSfu: USE_MUTI_SFU, matrixRTCMode: MATRIX_RTC_MODE,
preferStickyEvents: false,
}, },
); );
@@ -153,8 +153,7 @@ test("It should not fail with configuration error if homeserver config has livek
}, },
{ {
encryptMedia: true, encryptMedia: true,
useMultiSfu: USE_MUTI_SFU, matrixRTCMode: MATRIX_RTC_MODE,
preferStickyEvents: false,
}, },
); );
}); });

View File

@@ -0,0 +1,633 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalTrack,
type Participant,
ParticipantEvent,
type LocalParticipant,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import {
type LivekitTransport,
type MatrixRTCSession,
MembershipManagerEvent,
Status,
} from "matrix-js-sdk/lib/matrixrtc";
import { ClientEvent, SyncState, type Room as MatrixRoom } from "matrix-js-sdk";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
fromEvent,
map,
type Observable,
of,
scan,
startWith,
switchMap,
tap,
} from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
import { ObservableScope } from "../../ObservableScope";
import { Publisher } from "./Publisher";
import { type MuteStates } from "../../MuteStates";
import { type ProcessorState } from "../../../livekit/TrackProcessorContext";
import { type MediaDevices } from "../../MediaDevices";
import { and$ } from "../../../utils/observable";
import { ElementCallError, UnknownCallError } from "../../../utils/errors";
import {
ElementWidgetActions,
widget,
type WidgetHelpers,
} from "../../../widget";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts";
import { Config } from "../../../config/Config.ts";
import {
type Connection,
type ConnectionState,
} from "../remoteMembers/Connection.ts";
export enum LivekitState {
Uninitialized = "uninitialized",
Connecting = "connecting",
Connected = "connected",
Error = "error",
Disconnected = "disconnected",
Disconnecting = "disconnecting",
}
type LocalMemberLivekitState =
| { state: LivekitState.Error; error: string }
| { state: LivekitState.Connected }
| { state: LivekitState.Connecting }
| { state: LivekitState.Uninitialized }
| { state: LivekitState.Disconnected }
| { state: LivekitState.Disconnecting };
export enum MatrixState {
Connected = "connected",
Disconnected = "disconnected",
Connecting = "connecting",
}
type LocalMemberMatrixState =
| { state: MatrixState.Connected }
| { state: MatrixState.Connecting }
| { state: MatrixState.Disconnected };
export interface LocalMemberConnectionState {
livekit$: Behavior<LocalMemberLivekitState>;
matrix$: Behavior<LocalMemberMatrixState>;
}
/*
* - get well known
* - get oldest membership
* - get transport to use
* - get openId + jwt token
* - wait for createTrack() call
* - create tracks
* - wait for join() call
* - Publisher.publishTracks()
* - send join state/sticky event
*/
interface Props {
options: Behavior<EnterRTCSessionOptions>;
scope: ObservableScope;
mediaDevices: MediaDevices;
muteStates: MuteStates;
connectionManager: IConnectionManager;
matrixRTCSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
localTransport$: Behavior<LivekitTransport | null>;
trackProcessorState$: Behavior<ProcessorState>;
widget: WidgetHelpers | null;
logger: Logger;
}
/**
* This class is responsible for managing the own membership in a room.
* We want
* - a publisher
* -
* @param param0
* @returns
* - publisher: The handle to create tracks and publish them to the room.
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
* - transport$: the transport object the ownMembership$ ended up using.
* - connectionState: the current connection state. Including matrix server and livekit server connection.
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
*/
export const createLocalMembership$ = ({
scope,
options,
muteStates,
mediaDevices,
connectionManager,
matrixRTCSession,
localTransport$,
matrixRoom,
trackProcessorState$,
widget,
logger: parentLogger,
}: Props): {
// publisher: Publisher
requestConnect: () => LocalMemberConnectionState;
startTracks: () => Behavior<LocalTrack[]>;
requestDisconnect: () => Observable<LocalMemberLivekitState> | null;
connectionState: LocalMemberConnectionState;
sharingScreen$: Behavior<boolean>;
/**
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
toggleScreenSharing: (() => void) | null;
participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>;
// deprecated fields
/** @deprecated use state instead*/
homeserverConnected$: Behavior<boolean>;
/** @deprecated use state instead*/
connected$: Behavior<boolean>;
// this needs to be discussed
/** @deprecated use state instead*/
reconnecting$: Behavior<boolean>;
// also needs to be disccues
/** @deprecated use state instead*/
configError$: Behavior<ElementCallError | null>;
} => {
const logger = parentLogger.getChild("[LocalMembership]");
logger.debug(`Creating local membership..`);
const state = {
livekit$: new BehaviorSubject<LocalMemberLivekitState>({
state: LivekitState.Uninitialized,
}),
matrix$: new BehaviorSubject<LocalMemberMatrixState>({
state: MatrixState.Disconnected,
}),
};
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const trackStartRequested$ = new BehaviorSubject(false);
// This should be used in a combineLatest with publisher$ to connect.
// to make it possible to call startTracks before the preferredTransport$ has resolved.
const connectRequested$ = new BehaviorSubject(false);
// This should be used in a combineLatest with publisher$ to connect.
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
// Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior(
combineLatest([connectionManager.connections$, localTransport$]).pipe(
map(([connections, localTransport]) => {
if (localTransport === null) {
return null;
}
return (
connections.value.find((connection) =>
areLivekitTransportsEqual(connection.transport, localTransport),
) ?? null
);
}),
tap((connection) => {
logger.info(
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
);
/**
* Whether we are connected to the MatrixRTC session.
*/
const homeserverConnected$ = scope.behavior(
// To consider ourselves connected to MatrixRTC, we check the following:
and$(
// The client is connected to the sync loop
(
fromEvent(matrixRoom.client, ClientEvent.Sync) as Observable<
[SyncState]
>
).pipe(
startWith([matrixRoom.client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
),
// Room state observed by session says we're connected
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
startWith(null),
map(() => matrixRTCSession.membershipStatus === Status.Connected),
),
// Also watch out for warnings that we've likely hit a timeout and our
// delayed leave event is being sent (this condition is here because it
// provides an earlier warning than the sync loop timeout, and we wouldn't
// see the actual leave event until we reconnect to the sync loop)
fromEvent(matrixRTCSession, MembershipManagerEvent.ProbablyLeft).pipe(
startWith(null),
map(() => matrixRTCSession.probablyLeft !== true),
),
).pipe(
tap((connected) => {
logger.info(`Homeserver connected update: ${connected}`);
}),
),
);
// /**
// * Whether we are "fully" connected to the call. Accounts for both the
// * connection to the MatrixRTC session and the LiveKit publish connection.
// */
// // TODO use this in combination with the MemberState.
const connected$ = scope.behavior(
and$(
homeserverConnected$,
localConnection$.pipe(
switchMap((c) =>
c
? c.state$.pipe(map((state) => state.state === "ConnectedToLkRoom"))
: of(false),
),
),
),
);
const publisher$ = new BehaviorSubject<Publisher | null>(null);
localConnection$.pipe(scope.bind()).subscribe((connection) => {
if (connection !== null && publisher$.value === null) {
// TODO looks strange to not change publisher if connection changes.
publisher$.next(
new Publisher(
scope,
connection,
mediaDevices,
muteStates,
trackProcessorState$,
),
);
}
});
combineLatest([publisher$, trackStartRequested$]).subscribe(
([publisher, shouldStartTracks]) => {
if (publisher && shouldStartTracks) {
publisher
.createAndSetupTracks()
.then((tracks) => {
tracks$.next(tracks);
})
.catch((error) => {
logger.error("Error creating tracks:", error);
});
}
},
);
// MATRIX RELATED
// /**
// * Whether we should tell the user that we're reconnecting to the call.
// */
// DISCUSSION is there a better way to do this?
// sth that is more deriectly implied from the membership manager of the js sdk. (fromEvent(matrixRTCSession, Reconnecting)) ??? or similar
const reconnecting$ = scope.behavior(
connected$.pipe(
// We are reconnecting if we previously had some successful initial
// connection but are now disconnected
scan(
({ connectedPreviously }, connectedNow) => ({
connectedPreviously: connectedPreviously || connectedNow,
reconnecting: connectedPreviously && !connectedNow,
}),
{ connectedPreviously: false, reconnecting: false },
),
map(({ reconnecting }) => reconnecting),
),
);
const startTracks = (): Behavior<LocalTrack[]> => {
trackStartRequested$.next(true);
return tracks$;
};
combineLatest([publisher$, tracks$]).subscribe(([publisher, tracks]) => {
if (
tracks.length === 0 ||
// change this to !== Publishing
state.livekit$.value.state !== LivekitState.Uninitialized
) {
return;
}
state.livekit$.next({ state: LivekitState.Connecting });
publisher
?.startPublishing()
.then(() => {
state.livekit$.next({ state: LivekitState.Connected });
})
.catch((error) => {
state.livekit$.next({ state: LivekitState.Error, error });
});
});
combineLatest([localTransport$, connectRequested$]).subscribe(
// TODO reconnect when transport changes => create test.
([transport, connectRequested]) => {
if (
transport === null ||
!connectRequested ||
state.matrix$.value.state !== MatrixState.Disconnected
) {
logger.info(
"Not yet connecting because: ",
"transport === null:",
transport === null,
"!connectRequested:",
!connectRequested,
"state.matrix$.value.state !== MatrixState.Disconnected:",
state.matrix$.value.state !== MatrixState.Disconnected,
);
return;
}
state.matrix$.next({ state: MatrixState.Connecting });
logger.info("Matrix State connecting");
enterRTCSession(matrixRTCSession, transport, options.value).catch(
(error) => {
logger.error(error);
},
);
},
);
const requestConnect = (): LocalMemberConnectionState => {
trackStartRequested$.next(true);
connectRequested$.next(true);
return state;
};
const requestDisconnect = (): Behavior<LocalMemberLivekitState> | null => {
if (state.livekit$.value.state !== LivekitState.Connected) return null;
state.livekit$.next({ state: LivekitState.Disconnecting });
combineLatest([publisher$, tracks$], (publisher, tracks) => {
publisher
?.stopPublishing()
.then(() => {
tracks.forEach((track) => track.stop());
state.livekit$.next({ state: LivekitState.Disconnected });
})
.catch((error) => {
state.livekit$.next({ state: LivekitState.Error, error });
});
});
return state.livekit$;
};
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
combineLatest([localConnection$, homeserverConnected$])
.pipe(scope.bind())
.subscribe(([connection, connected]) => {
if (connection?.state$.value.state !== "ConnectedToLkRoom") return;
const publications =
connection.livekitRoom.localParticipant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.info(
`Resuming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.info(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
const configError$ = new BehaviorSubject<ElementCallError | null>(null);
// TODO I do not fully understand what this does.
// Is it needed?
// Is this at the right place?
// Can this be simplified?
// Start and stop session membership as needed
scope.reconcile(localTransport$, async (advertised) => {
if (advertised !== null && advertised !== undefined) {
try {
await enterRTCSession(matrixRTCSession, advertised, options.value);
configError$.next(null);
} catch (e) {
logger.error("Error entering RTC session", e);
}
// Update our member event when our mute state changes.
const intentScope = new ObservableScope();
intentScope.reconcile(muteStates.video.enabled$, async (videoEnabled) =>
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
);
return async (): Promise<void> => {
intentScope.end();
// Only sends Matrix leave event. The LiveKit session will disconnect
// as soon as either the stopConnection$ handler above gets to it or
// the view model is destroyed.
try {
await matrixRTCSession.leaveRoomSession();
} catch (e) {
logger.error("Error leaving RTC session", e);
}
try {
await widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
} catch (e) {
logger.error("Failed to send hangup action", e);
}
};
}
});
localConnection$
.pipe(
distinctUntilChanged(),
switchMap((c) =>
c === null ? of({ state: "Initialized" } as ConnectionState) : c.state$,
),
map((s) => {
logger.trace(`Local connection state update: ${s.state}`);
if (s.state == "FailedToStart") {
return s.error instanceof ElementCallError
? s.error
: new UnknownCallError(s.error);
} else {
return null;
}
}),
scope.bind(),
)
.subscribe((fatalError) => {
configError$.next(fatalError);
});
/**
* Whether the user is currently sharing their screen.
*/
const sharingScreen$ = scope.behavior(
localConnection$.pipe(
switchMap((c) =>
c === null
? of(false)
: observeSharingScreen$(c.livekitRoom.localParticipant),
),
),
);
const toggleScreenSharing =
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!getUrlParams().hideScreensharing
? (): void =>
// If a connection is ready, toggle screen sharing.
// We deliberately do nothing in the case of a null connection because
// it looks nice for the call control buttons to all become available
// at once upon joining the call, rather than introducing a disabled
// state. The user can just click again.
// We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
void localConnection$.value?.livekitRoom.localParticipant
.setScreenShareEnabled(!sharingScreen$.value, {
audio: true,
selfBrowserSurface: "include",
surfaceSwitching: "include",
systemAudio: "include",
})
.catch(logger.error)
: null;
const participant$ = scope.behavior(
localConnection$.pipe(map((c) => c?.livekitRoom.localParticipant ?? null)),
);
return {
startTracks,
requestConnect,
requestDisconnect,
connectionState: state,
homeserverConnected$,
connected$,
reconnecting$,
configError$,
sharingScreen$,
toggleScreenSharing,
participant$,
connection$: localConnection$,
};
};
export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
}
interface EnterRTCSessionOptions {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
}
/**
* Does the necessary steps to enter the RTC session on the matrix side:
* - Preparing the membership info (FOCUS to use, options)
* - Sends the matrix event to join the call, and starts the membership manager:
* - Delay events management
* - Handles retries (fails only after several attempts)
*
* @param rtcSession
* @param transport
* @param options
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/
// Exported for unit testing
export async function enterRTCSession(
rtcSession: MatrixRTCSession,
transport: LivekitTransport,
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
): Promise<void> {
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
// This must be called before we start trying to join the call, as we need to
// have started tracking by the time calls start getting created.
// groupCallOTelMembership?.onJoinCall();
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
rtcSession.joinRoomSession(
multiSFU ? [] : [transport],
multiSFU ? transport : undefined,
{
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport: true,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
},
);
if (widget) {
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
}
}

View File

@@ -0,0 +1,179 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type CallMembership,
isLivekitTransport,
type LivekitTransportConfig,
type LivekitTransport,
isLivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { combineLatest, distinctUntilChanged, first, from, map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { type Behavior } from "../../Behavior.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { Config } from "../../../config/Config.ts";
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
/*
* - get well known
* - get oldest membership
* - get transport to use
* - get openId + jwt token
* - wait for createTrack() call
* - create tracks
* - wait for join() call
* - Publisher.publishTracks()
* - send join state/sticky event
*/
interface Props {
scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>;
client: MatrixClient;
roomId: string;
useOldestMember$: Behavior<boolean>;
}
/**
* This class is responsible for managing the local transport.
* "Which transport is the local member going to use"
*
* @prop useOldestMember Whether to use the same transport as the oldest member.
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
*/
export const createLocalTransport$ = ({
scope,
memberships$,
client,
roomId,
useOldestMember$,
}: Props): Behavior<LivekitTransport | null> => {
/**
* The transport over which we should be actively publishing our media.
* undefined when not joined.
*/
const oldestMemberTransport$ = scope.behavior(
memberships$.pipe(
map(
(memberships) =>
memberships.value[0]?.getTransport(memberships.value[0]) ?? null,
),
first((t) => t != null && isLivekitTransport(t)),
),
null,
);
/**
* The transport that we would personally prefer to publish on (if not for the
* transport preferences of others, perhaps).
*/
const preferredTransport$: Behavior<LivekitTransport | null> = scope.behavior(
from(makeTransport(client, roomId)),
null,
);
/**
* The transport we should advertise in our MatrixRTC membership.
*/
const advertisedTransport$ = scope.behavior(
combineLatest([
useOldestMember$,
oldestMemberTransport$,
preferredTransport$,
]).pipe(
map(([useOldestMember, oldestMemberTransport, preferredTransport]) =>
useOldestMember
? (oldestMemberTransport ?? preferredTransport)
: preferredTransport,
),
distinctUntilChanged(areLivekitTransportsEqual),
),
);
return advertisedTransport$;
};
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
async function makeTransportInternal(
client: MatrixClient,
roomId: string,
): Promise<LivekitTransport> {
logger.log("Searching for a preferred transport");
//TODO refactor this to use the jwt service returned alias.
const livekitAlias = roomId;
// TODO-MULTI-SFU: Either remove this dev tool or make it more official
const urlFromStorage =
localStorage.getItem("robin-matrixrtc-auth") ??
localStorage.getItem("timo-focus-url");
if (urlFromStorage !== null) {
const transportFromStorage: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromStorage,
livekit_alias: livekitAlias,
};
logger.log(
"Using LiveKit transport from local storage: ",
transportFromStorage,
);
return transportFromStorage;
}
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
const domain = client.getDomain();
if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
FOCI_WK_KEY
];
if (Array.isArray(wellKnownFoci)) {
const transport: LivekitTransportConfig | undefined = wellKnownFoci.find(
(f) => f && isLivekitTransportConfig(f),
);
if (transport !== undefined) {
logger.log("Using LiveKit transport from .well-known: ", transport);
return { ...transport, livekit_alias: livekitAlias };
}
}
}
const urlFromConf = Config.get().livekit?.livekit_service_url;
if (urlFromConf) {
const transportFromConf: LivekitTransport = {
type: "livekit",
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
logger.log("Using LiveKit transport from config: ", transportFromConf);
return transportFromConf;
}
throw new MatrixRTCTransportMissingError(domain ?? "");
}
async function makeTransport(
client: MatrixClient,
roomId: string,
): Promise<LivekitTransport> {
const transport = await makeTransportInternal(client, roomId);
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
try {
await getSFUConfigWithOpenID(
client,
transport.livekit_service_url,
transport.livekit_alias,
);
} catch (e) {
logger.warn(`Failed to get SFU config for transport: ${e}`);
}
return transport;
}

View File

@@ -1,16 +1,17 @@
/* /*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd. Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import {
ConnectionState,
type E2EEOptions,
LocalVideoTrack, LocalVideoTrack,
Room as LivekitRoom, type Room as LivekitRoom,
type RoomOptions,
Track, Track,
type LocalTrack,
type LocalTrackPublication,
ConnectionState as LivekitConnectionState,
} from "livekit-client"; } from "livekit-client";
import { import {
map, map,
@@ -19,65 +20,52 @@ import {
type Subscription, type Subscription,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger"; import { type Logger } from "matrix-js-sdk/lib/logger";
import type { Behavior } from "./Behavior.ts"; import type { Behavior } from "../../Behavior.ts";
import type { MediaDevices, SelectedDevice } from "./MediaDevices.ts"; import type { MediaDevices, SelectedDevice } from "../../MediaDevices.ts";
import type { MuteStates } from "./MuteStates.ts"; import type { MuteStates } from "../../MuteStates.ts";
import { import {
type ProcessorState, type ProcessorState,
trackProcessorSync, trackProcessorSync,
} from "../livekit/TrackProcessorContext.tsx"; } from "../../../livekit/TrackProcessorContext.tsx";
import { getUrlParams } from "../UrlParams.ts"; import { getUrlParams } from "../../../UrlParams.ts";
import { defaultLiveKitOptions } from "../livekit/options.ts"; import { observeTrackReference$ } from "../../MediaViewModel.ts";
import { getValue } from "../utils/observable.ts"; import { type Connection } from "../remoteMembers/Connection.ts";
import { observeTrackReference$ } from "./MediaViewModel.ts"; import { type ObservableScope } from "../../ObservableScope.ts";
import { Connection, type ConnectionOpts } from "./Connection.ts";
import { type ObservableScope } from "./ObservableScope.ts";
/** /**
* A connection to the local LiveKit room, the one the user is publishing to. * A wrapper for a Connection object.
* This connection will publish the local user's audio and video tracks. * This wrapper will manage the connection used to publish to the LiveKit room.
* The Publisher is also responsible for creating the media tracks.
*/ */
export class PublishConnection extends Connection { export class Publisher {
private readonly scope: ObservableScope; public tracks: LocalTrack<Track.Kind>[] = [];
/** /**
* Creates a new PublishConnection. * Creates a new Publisher.
* @param args - The connection options. {@link ConnectionOpts} * @param scope - The observable scope to use for managing the publisher.
* @param connection - The connection to use for publishing.
* @param devices - The media devices to use for audio and video input. * @param devices - The media devices to use for audio and video input.
* @param muteStates - The mute states for audio and video. * @param muteStates - The mute states for audio and video.
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit room. Use to share the same key provider across connections!. * @param e2eeLivekitOptions - The E2EE options to use for the LiveKit room. Use to share the same key provider across connections!.
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur). * @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
*/ */
public constructor( public constructor(
args: ConnectionOpts, private scope: ObservableScope,
private connection: Connection,
devices: MediaDevices, devices: MediaDevices,
private readonly muteStates: MuteStates, private readonly muteStates: MuteStates,
e2eeLivekitOptions: E2EEOptions | undefined,
trackerProcessorState$: Behavior<ProcessorState>, trackerProcessorState$: Behavior<ProcessorState>,
private logger?: Logger,
) { ) {
const { scope } = args; this.logger?.info("[PublishConnection] Create LiveKit room");
logger.info("[PublishConnection] Create LiveKit room");
const { controlledAudioDevices } = getUrlParams(); const { controlledAudioDevices } = getUrlParams();
const factory = const room = connection.livekitRoom;
args.livekitRoomFactory ??
((options: RoomOptions): LivekitRoom => new LivekitRoom(options));
const room = factory(
generateRoomOption(
devices,
trackerProcessorState$.value,
controlledAudioDevices,
e2eeLivekitOptions,
),
);
room.setE2EEEnabled(e2eeLivekitOptions !== undefined)?.catch((e) => {
logger.error("Failed to set E2EE enabled on room", e);
});
super(room, args); room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
this.scope = scope; this.logger?.error("Failed to set E2EE enabled on room", e);
});
// Setup track processor syncing (blur) // Setup track processor syncing (blur)
this.observeTrackProcessors(scope, room, trackerProcessorState$); this.observeTrackProcessors(scope, room, trackerProcessorState$);
@@ -85,61 +73,118 @@ export class PublishConnection extends Connection {
this.observeMediaDevices(scope, devices, controlledAudioDevices); this.observeMediaDevices(scope, devices, controlledAudioDevices);
this.workaroundRestartAudioInputTrackChrome(devices, scope); this.workaroundRestartAudioInputTrackChrome(devices, scope);
this.scope.onEnd(() => {
this.logger?.info(
"[PublishConnection] Scope ended -> stop publishing all tracks",
);
void this.stopPublishing();
});
} }
/** /**
* Start the connection to LiveKit and publish local tracks. * Start the connection to LiveKit and publish local tracks.
* *
* This will: * This will:
* 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.) * wait for the connection to be ready.
* 2. Use this token to request the SFU config to the MatrixRtc authentication service. // * 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.)
* 3. Connect to the configured LiveKit room. // * 2. Use this token to request the SFU config to the MatrixRtc authentication service.
* 4. Create local audio and video tracks based on the current mute states and publish them to the room. // * 3. Connect to the configured LiveKit room.
// * 4. Create local audio and video tracks based on the current mute states and publish them to the room.
* *
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection. * @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created. * @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/ */
public async start(): Promise<void> { public async createAndSetupTracks(): Promise<LocalTrack[]> {
this.stopped = false; const lkRoom = this.connection.livekitRoom;
// Observe mute state changes and update LiveKit microphone/camera states accordingly // Observe mute state changes and update LiveKit microphone/camera states accordingly
this.observeMuteStates(this.scope); this.observeMuteStates(this.scope);
// TODO: This should be an autostarted connection no need to start here. just check the connection state.
// TODO: This will fetch the JWT token. Perhaps we could keep it preloaded // TODO: This will fetch the JWT token. Perhaps we could keep it preloaded
// instead? This optimization would only be safe for a publish connection, // instead? This optimization would only be safe for a publish connection,
// because we don't want to leak the user's intent to perhaps join a call to // because we don't want to leak the user's intent to perhaps join a call to
// remote servers before they actually commit to it. // remote servers before they actually commit to it.
await super.start(); // const { promise, resolve, reject } = Promise.withResolvers<void>();
// const sub = this.connection.state$.subscribe((s) => {
if (this.stopped) return; // if (s.state === "FailedToStart") {
// reject(new Error("Disconnected from LiveKit server"));
// } else if (s.state === "ConnectedToLkRoom") {
// resolve();
// }
// });
// try {
// await promise;
// } catch (e) {
// throw e;
// } finally {
// sub.unsubscribe();
// }
// TODO-MULTI-SFU: Prepublish a microphone track // TODO-MULTI-SFU: Prepublish a microphone track
const audio = this.muteStates.audio.enabled$.value; const audio = this.muteStates.audio.enabled$.value;
const video = this.muteStates.video.enabled$.value; const video = this.muteStates.video.enabled$.value;
// createTracks throws if called with audio=false and video=false // createTracks throws if called with audio=false and video=false
if (audio || video) { if (audio || video) {
// TODO this can still throw errors? It will also prompt for permissions if not already granted // TODO this can still throw errors? It will also prompt for permissions if not already granted
const tracks = await this.livekitRoom.localParticipant.createTracks({ this.tracks =
audio, (await lkRoom.localParticipant
video, .createTracks({
}); audio,
if (this.stopped) return; video,
for (const track of tracks) { })
// TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally .catch((error) => {
// with a timeout. this.logger?.error("Failed to create tracks", error);
await this.livekitRoom.localParticipant.publishTrack(track); })) ?? [];
if (this.stopped) return;
// TODO: check if the connection is still active? and break the loop if not?
}
} }
return this.tracks;
} }
public async stop(): Promise<void> { public async startPublishing(): Promise<LocalTrack[]> {
const lkRoom = this.connection.livekitRoom;
const { promise, resolve, reject } = Promise.withResolvers<void>();
const sub = this.connection.state$.subscribe((s) => {
switch (s.state) {
case "ConnectedToLkRoom":
resolve();
break;
case "FailedToStart":
reject(new Error("Failed to connect to LiveKit server"));
break;
default:
this.logger?.info("waiting for connection: ", s.state);
}
});
try {
await promise;
} catch (e) {
throw e;
} finally {
sub.unsubscribe();
}
for (const track of this.tracks) {
// TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally
// with a timeout.
await lkRoom.localParticipant.publishTrack(track).catch((error) => {
this.logger?.error("Failed to publish track", error);
});
// TODO: check if the connection is still active? and break the loop if not?
}
return this.tracks;
}
public async stopPublishing(): Promise<void> {
// TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope // TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope
// actually has the right lifetime // actually has the right lifetime
this.muteStates.audio.unsetHandler(); this.muteStates.audio.unsetHandler();
this.muteStates.video.unsetHandler(); this.muteStates.video.unsetHandler();
await super.stop();
const localParticipant = this.connection.livekitRoom.localParticipant;
const tracks: LocalTrack[] = [];
const addToTracksIfDefined = (p: LocalTrackPublication): void => {
if (p.track !== undefined) tracks.push(p.track);
};
localParticipant.trackPublications.forEach(addToTracksIfDefined);
await localParticipant.unpublishTracks(tracks);
} }
/// Private methods /// Private methods
@@ -156,15 +201,16 @@ export class PublishConnection extends Connection {
devices: MediaDevices, devices: MediaDevices,
scope: ObservableScope, scope: ObservableScope,
): void { ): void {
const lkRoom = this.connection.livekitRoom;
devices.audioInput.selected$ devices.audioInput.selected$
.pipe( .pipe(
switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER), switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER),
scope.bind(), scope.bind(),
) )
.subscribe(() => { .subscribe(() => {
if (this.livekitRoom.state != ConnectionState.Connected) return; if (lkRoom.state != LivekitConnectionState.Connected) return;
const activeMicTrack = Array.from( const activeMicTrack = Array.from(
this.livekitRoom.localParticipant.audioTrackPublications.values(), lkRoom.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track; ).find((d) => d.source === Track.Source.Microphone)?.track;
if ( if (
@@ -179,11 +225,11 @@ export class PublishConnection extends Connection {
// getUserMedia() call with deviceId: default to get the *new* default device. // getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because // Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default). // the deviceId hasn't changed (was & still is default).
this.livekitRoom.localParticipant lkRoom.localParticipant
.getTrackPublication(Track.Source.Microphone) .getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack() ?.audioTrack?.restartTrack()
.catch((e) => { .catch((e) => {
logger.error(`Failed to restart audio device track`, e); this.logger?.error(`Failed to restart audio device track`, e);
}); });
} }
}); });
@@ -195,27 +241,31 @@ export class PublishConnection extends Connection {
devices: MediaDevices, devices: MediaDevices,
controlledAudioDevices: boolean, controlledAudioDevices: boolean,
): void { ): void {
const lkRoom = this.connection.livekitRoom;
const syncDevice = ( const syncDevice = (
kind: MediaDeviceKind, kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>, selected$: Observable<SelectedDevice | undefined>,
): Subscription => ): Subscription =>
selected$.pipe(scope.bind()).subscribe((device) => { selected$.pipe(scope.bind()).subscribe((device) => {
if (this.livekitRoom.state != ConnectionState.Connected) return; if (lkRoom.state != LivekitConnectionState.Connected) return;
// if (this.connectionState$.value !== ConnectionState.Connected) return; // if (this.connectionState$.value !== ConnectionState.Connected) return;
logger.info( this.logger?.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :", "[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
this.livekitRoom.getActiveDevice(kind), lkRoom.getActiveDevice(kind),
" !== ", " !== ",
device?.id, device?.id,
); );
if ( if (
device !== undefined && device !== undefined &&
this.livekitRoom.getActiveDevice(kind) !== device.id lkRoom.getActiveDevice(kind) !== device.id
) { ) {
this.livekitRoom lkRoom
.switchActiveDevice(kind, device.id) .switchActiveDevice(kind, device.id)
.catch((e) => .catch((e: Error) =>
logger.error(`Failed to sync ${kind} device with LiveKit`, e), this.logger?.error(
`Failed to sync ${kind} device with LiveKit`,
e,
),
); );
} }
}); });
@@ -232,21 +282,28 @@ export class PublishConnection extends Connection {
* @private * @private
*/ */
private observeMuteStates(scope: ObservableScope): void { private observeMuteStates(scope: ObservableScope): void {
const lkRoom = this.connection.livekitRoom;
this.muteStates.audio.setHandler(async (desired) => { this.muteStates.audio.setHandler(async (desired) => {
try { try {
await this.livekitRoom.localParticipant.setMicrophoneEnabled(desired); await lkRoom.localParticipant.setMicrophoneEnabled(desired);
} catch (e) { } catch (e) {
logger.error("Failed to update LiveKit audio input mute state", e); this.logger?.error(
"Failed to update LiveKit audio input mute state",
e,
);
} }
return this.livekitRoom.localParticipant.isMicrophoneEnabled; return lkRoom.localParticipant.isMicrophoneEnabled;
}); });
this.muteStates.video.setHandler(async (desired) => { this.muteStates.video.setHandler(async (desired) => {
try { try {
await this.livekitRoom.localParticipant.setCameraEnabled(desired); await lkRoom.localParticipant.setCameraEnabled(desired);
} catch (e) { } catch (e) {
logger.error("Failed to update LiveKit video input mute state", e); this.logger?.error(
"Failed to update LiveKit video input mute state",
e,
);
} }
return this.livekitRoom.localParticipant.isCameraEnabled; return lkRoom.localParticipant.isCameraEnabled;
}); });
} }
@@ -262,37 +319,8 @@ export class PublishConnection extends Connection {
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
}), }),
), ),
null,
); );
trackProcessorSync(track$, trackerProcessorState$); trackProcessorSync(scope, track$, trackerProcessorState$);
} }
} }
// Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
function generateRoomOption(
devices: MediaDevices,
processorState: ProcessorState,
controlledAudioDevices: boolean,
e2eeLivekitOptions: E2EEOptions | undefined,
): RoomOptions {
return {
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id,
},
audioOutput: {
// When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: getValue(devices.audioOutput.selected$)?.id,
},
e2ee: e2eeLivekitOptions,
};
}

View File

@@ -1,4 +1,5 @@
/* /*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd. Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -10,41 +11,32 @@ import {
describe, describe,
expect, expect,
it, it,
type Mock,
type MockedObject, type MockedObject,
onTestFinished, onTestFinished,
vi, vi,
} from "vitest"; } from "vitest";
import { BehaviorSubject, of } from "rxjs";
import { import {
ConnectionState,
type LocalParticipant, type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
type Room as LivekitRoom, type Room as LivekitRoom,
RoomEvent, RoomEvent,
type RoomOptions, ConnectionState as LivekitConnectionState,
} from "livekit-client"; } from "livekit-client";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
import EventEmitter from "events"; import EventEmitter from "events";
import { type IOpenIDToken } from "matrix-js-sdk"; import { type IOpenIDToken } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import type { import type { LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
CallMembership,
LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { import {
Connection,
type ConnectionOpts, type ConnectionOpts,
type TransportState, type ConnectionState,
type PublishingParticipant, type PublishingParticipant,
RemoteConnection,
} from "./Connection.ts"; } from "./Connection.ts";
import { ObservableScope } from "./ObservableScope.ts"; import { ObservableScope } from "../../ObservableScope.ts";
import { type OpenIDClientParts } from "../livekit/openIDSFU.ts"; import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import { FailToGetOpenIdToken } from "../utils/errors.ts"; import { FailToGetOpenIdToken } from "../../../utils/errors.ts";
import { PublishConnection } from "./PublishConnection.ts";
import { mockMediaDevices, mockMuteStates } from "../utils/test.ts";
import type { ProcessorState } from "../livekit/TrackProcessorContext.tsx";
import { type MuteStates } from "./MuteStates.ts";
let testScope: ObservableScope; let testScope: ObservableScope;
@@ -56,9 +48,9 @@ let localParticipantEventEmiter: EventEmitter;
let fakeLocalParticipant: MockedObject<LocalParticipant>; let fakeLocalParticipant: MockedObject<LocalParticipant>;
let fakeRoomEventEmiter: EventEmitter; let fakeRoomEventEmiter: EventEmitter;
let fakeMembershipsFocusMap$: BehaviorSubject< // let fakeMembershipsFocusMap$: BehaviorSubject<
{ membership: CallMembership; transport: LivekitTransport }[] // { membership: CallMembership; transport: LivekitTransport }[]
>; // >;
const livekitFocus: LivekitTransport = { const livekitFocus: LivekitTransport = {
livekit_alias: "!roomID:example.org", livekit_alias: "!roomID:example.org",
@@ -77,9 +69,6 @@ function setupTest(): void {
}), }),
getDeviceId: vi.fn().mockReturnValue("ABCDEF"), getDeviceId: vi.fn().mockReturnValue("ABCDEF"),
} as unknown as OpenIDClientParts); } as unknown as OpenIDClientParts);
fakeMembershipsFocusMap$ = new BehaviorSubject<
{ membership: CallMembership; transport: LivekitTransport }[]
>([]);
localParticipantEventEmiter = new EventEmitter(); localParticipantEventEmiter = new EventEmitter();
@@ -106,7 +95,7 @@ function setupTest(): void {
disconnect: vi.fn(), disconnect: vi.fn(),
remoteParticipants: new Map(), remoteParticipants: new Map(),
localParticipant: fakeLocalParticipant, localParticipant: fakeLocalParticipant,
state: ConnectionState.Disconnected, state: LivekitConnectionState.Disconnected,
on: fakeRoomEventEmiter.on.bind(fakeRoomEventEmiter), on: fakeRoomEventEmiter.on.bind(fakeRoomEventEmiter),
off: fakeRoomEventEmiter.off.bind(fakeRoomEventEmiter), off: fakeRoomEventEmiter.off.bind(fakeRoomEventEmiter),
addListener: fakeRoomEventEmiter.addListener.bind(fakeRoomEventEmiter), addListener: fakeRoomEventEmiter.addListener.bind(fakeRoomEventEmiter),
@@ -118,11 +107,10 @@ function setupTest(): void {
} as unknown as LivekitRoom); } as unknown as LivekitRoom);
} }
function setupRemoteConnection(): RemoteConnection { function setupRemoteConnection(): Connection {
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
remoteTransports$: fakeMembershipsFocusMap$,
scope: testScope, scope: testScope,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
@@ -139,7 +127,7 @@ function setupRemoteConnection(): RemoteConnection {
fakeLivekitRoom.connect.mockResolvedValue(undefined); fakeLivekitRoom.connect.mockResolvedValue(undefined);
return new RemoteConnection(opts, undefined); return new Connection(opts, logger);
} }
afterEach(() => { afterEach(() => {
@@ -155,13 +143,12 @@ describe("Start connection states", () => {
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
remoteTransports$: fakeMembershipsFocusMap$,
scope: testScope, scope: testScope,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
const connection = new RemoteConnection(opts, undefined); const connection = new Connection(opts, logger);
expect(connection.transportState$.getValue().state).toEqual("Initialized"); expect(connection.state$.getValue().state).toEqual("Initialized");
}); });
it("fail to getOpenId token then error state", async () => { it("fail to getOpenId token then error state", async () => {
@@ -171,15 +158,14 @@ describe("Start connection states", () => {
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
remoteTransports$: fakeMembershipsFocusMap$,
scope: testScope, scope: testScope,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
const connection = new RemoteConnection(opts, undefined); const connection = new Connection(opts, logger);
const capturedStates: TransportState[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.transportState$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
@@ -224,15 +210,14 @@ describe("Start connection states", () => {
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
remoteTransports$: fakeMembershipsFocusMap$,
scope: testScope, scope: testScope,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
const connection = new RemoteConnection(opts, undefined); const connection = new Connection(opts, logger);
const capturedStates: TransportState[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.transportState$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
@@ -281,15 +266,14 @@ describe("Start connection states", () => {
const opts: ConnectionOpts = { const opts: ConnectionOpts = {
client: client, client: client,
transport: livekitFocus, transport: livekitFocus,
remoteTransports$: fakeMembershipsFocusMap$,
scope: testScope, scope: testScope,
livekitRoomFactory: () => fakeLivekitRoom, livekitRoomFactory: () => fakeLivekitRoom,
}; };
const connection = new RemoteConnection(opts, undefined); const connection = new Connection(opts, logger);
const capturedStates: TransportState[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.transportState$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
@@ -345,8 +329,8 @@ describe("Start connection states", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
const capturedStates: TransportState[] = []; const capturedStates: ConnectionState[] = [];
const s = connection.transportState$.subscribe((value) => { const s = connection.state$.subscribe((value) => {
capturedStates.push(value); capturedStates.push(value);
}); });
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
@@ -379,21 +363,18 @@ describe("Start connection states", () => {
}); });
}); });
function fakeRemoteLivekitParticipant(id: string): RemoteParticipant { function fakeRemoteLivekitParticipant(
id: string,
publications: number = 1,
): RemoteParticipant {
return { return {
identity: id, identity: id,
getTrackPublications: () => Array(publications),
} as unknown as RemoteParticipant; } as unknown as RemoteParticipant;
} }
function fakeRtcMemberShip(userId: string, deviceId: string): CallMembership {
return {
userId,
deviceId,
} as unknown as CallMembership;
}
describe("Publishing participants observations", () => { describe("Publishing participants observations", () => {
it("should emit the list of publishing participants", async () => { it("should emit the list of publishing participants", () => {
setupTest(); setupTest();
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
@@ -401,135 +382,53 @@ describe("Publishing participants observations", () => {
const bobIsAPublisher = Promise.withResolvers<void>(); const bobIsAPublisher = Promise.withResolvers<void>();
const danIsAPublisher = Promise.withResolvers<void>(); const danIsAPublisher = Promise.withResolvers<void>();
const observedPublishers: PublishingParticipant[][] = []; const observedPublishers: PublishingParticipant[][] = [];
const s = connection.publishingParticipants$.subscribe((publishers) => { const s = connection.remoteParticipantsWithTracks$.subscribe(
observedPublishers.push(publishers); (publishers) => {
if ( observedPublishers.push(publishers);
publishers.some( if (publishers.some((p) => p.identity === "@bob:example.org:DEV111")) {
(p) => p.participant?.identity === "@bob:example.org:DEV111", bobIsAPublisher.resolve();
) }
) { if (publishers.some((p) => p.identity === "@dan:example.org:DEV333")) {
bobIsAPublisher.resolve(); danIsAPublisher.resolve();
} }
if ( },
publishers.some( );
(p) => p.participant?.identity === "@dan:example.org:DEV333",
)
) {
danIsAPublisher.resolve();
}
});
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
// The publishingParticipants$ observable is derived from the current members of the // The publishingParticipants$ observable is derived from the current members of the
// livekitRoom and the rtc membership in order to publish the members that are publishing // livekitRoom and the rtc membership in order to publish the members that are publishing
// on this connection. // on this connection.
let participants: RemoteParticipant[] = [ let participants: RemoteParticipant[] = [
fakeRemoteLivekitParticipant("@alice:example.org:DEV000"), fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0),
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"), fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
fakeRemoteLivekitParticipant("@carol:example.org:DEV222"), fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0),
fakeRemoteLivekitParticipant("@dan:example.org:DEV333"), fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0),
]; ];
// Let's simulate 3 members on the livekitRoom // Let's simulate 3 members on the livekitRoom
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockReturnValue( vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
new Map(participants.map((p) => [p.identity, p])), () => new Map(participants.map((p) => [p.identity, p])),
); );
for (const participant of participants) { participants.forEach((p) =>
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant); fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, p),
} );
// At this point there should be no publishers // At this point there should be no publishers
expect(observedPublishers.pop()!.length).toEqual(0); expect(observedPublishers.pop()!.length).toEqual(0);
const otherFocus: LivekitTransport = { participants = [
livekit_alias: "!roomID:example.org", fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 1),
livekit_service_url: "https://other-matrix-rtc.example.org/livekit/jwt", fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1),
type: "livekit", fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 1),
}; fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 2),
const rtcMemberships = [
// Say bob is on the same focus
{
membership: fakeRtcMemberShip("@bob:example.org", "DEV111"),
transport: livekitFocus,
},
// Alice and carol is on a different focus
{
membership: fakeRtcMemberShip("@alice:example.org", "DEV000"),
transport: otherFocus,
},
{
membership: fakeRtcMemberShip("@carol:example.org", "DEV222"),
transport: otherFocus,
},
// NO DAVE YET
]; ];
// signal this change in rtc memberships participants.forEach((p) =>
fakeMembershipsFocusMap$.next(rtcMemberships); fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, p),
// We should have bob has a publisher now
await bobIsAPublisher.promise;
const publishers = observedPublishers.pop();
expect(publishers?.length).toEqual(1);
expect(publishers?.[0].participant?.identity).toEqual(
"@bob:example.org:DEV111",
); );
// Now let's make dan join the rtc memberships // At this point there should be no publishers
rtcMemberships.push({ expect(observedPublishers.pop()!.length).toEqual(4);
membership: fakeRtcMemberShip("@dan:example.org", "DEV333"),
transport: livekitFocus,
});
fakeMembershipsFocusMap$.next(rtcMemberships);
// We should have bob and dan has publishers now
await danIsAPublisher.promise;
const twoPublishers = observedPublishers.pop();
expect(twoPublishers?.length).toEqual(2);
expect(
twoPublishers?.some(
(p) => p.participant?.identity === "@bob:example.org:DEV111",
),
).toBeTruthy();
expect(
twoPublishers?.some(
(p) => p.participant?.identity === "@dan:example.org:DEV333",
),
).toBeTruthy();
// Now let's make bob leave the livekit room
participants = participants.filter(
(p) => p.identity !== "@bob:example.org:DEV111",
);
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockReturnValue(
new Map(participants.map((p) => [p.identity, p])),
);
fakeRoomEventEmiter.emit(
RoomEvent.ParticipantDisconnected,
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
);
const updatedPublishers = observedPublishers.pop();
// Bob is not connected to the room but he is still in the rtc memberships declaring that
// he is using that focus to publish, so he should still appear as a publisher
expect(updatedPublishers?.length).toEqual(2);
const pp = updatedPublishers?.find(
(p) => p.membership.userId == "@bob:example.org",
);
expect(pp).toBeDefined();
expect(pp!.participant).not.toBeDefined();
expect(
updatedPublishers?.some(
(p) => p.participant?.identity === "@dan:example.org:DEV333",
),
).toBeTruthy();
// Now if bob is not in the rtc memberships, he should disappear
const noBob = rtcMemberships.filter(
({ membership }) => membership.userId !== "@bob:example.org",
);
fakeMembershipsFocusMap$.next(noBob);
expect(observedPublishers.pop()?.length).toEqual(1);
}); });
it("should be scoped to parent scope", (): void => { it("should be scoped to parent scope", (): void => {
@@ -538,18 +437,20 @@ describe("Publishing participants observations", () => {
const connection = setupRemoteConnection(); const connection = setupRemoteConnection();
let observedPublishers: PublishingParticipant[][] = []; let observedPublishers: PublishingParticipant[][] = [];
const s = connection.publishingParticipants$.subscribe((publishers) => { const s = connection.remoteParticipantsWithTracks$.subscribe(
observedPublishers.push(publishers); (publishers) => {
}); observedPublishers.push(publishers);
},
);
onTestFinished(() => s.unsubscribe()); onTestFinished(() => s.unsubscribe());
let participants: RemoteParticipant[] = [ let participants: RemoteParticipant[] = [
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"), fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
]; ];
// Let's simulate 3 members on the livekitRoom // Let's simulate 3 members on the livekitRoom
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockReturnValue( vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockImplementation(
new Map(participants.map((p) => [p.identity, p])), () => new Map(participants.map((p) => [p.identity, p])),
); );
for (const participant of participants) { for (const participant of participants) {
@@ -559,22 +460,16 @@ describe("Publishing participants observations", () => {
// At this point there should be no publishers // At this point there should be no publishers
expect(observedPublishers.pop()!.length).toEqual(0); expect(observedPublishers.pop()!.length).toEqual(0);
const rtcMemberships = [ participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)];
// Say bob is on the same focus
{ for (const participant of participants) {
membership: fakeRtcMemberShip("@bob:example.org", "DEV111"), fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant);
transport: livekitFocus, }
},
];
// signal this change in rtc memberships
fakeMembershipsFocusMap$.next(rtcMemberships);
// We should have bob has a publisher now // We should have bob has a publisher now
const publishers = observedPublishers.pop(); const publishers = observedPublishers.pop();
expect(publishers?.length).toEqual(1); expect(publishers?.length).toEqual(1);
expect(publishers?.[0].participant?.identity).toEqual( expect(publishers?.[0]?.identity).toEqual("@bob:example.org:DEV111");
"@bob:example.org:DEV111",
);
// end the parent scope // end the parent scope
testScope.end(); testScope.end();
@@ -584,9 +479,7 @@ describe("Publishing participants observations", () => {
participants = participants.filter( participants = participants.filter(
(p) => p.identity !== "@bob:example.org:DEV111", (p) => p.identity !== "@bob:example.org:DEV111",
); );
vi.spyOn(fakeLivekitRoom, "remoteParticipants", "get").mockReturnValue(
new Map(participants.map((p) => [p.identity, p])),
);
fakeRoomEventEmiter.emit( fakeRoomEventEmiter.emit(
RoomEvent.ParticipantDisconnected, RoomEvent.ParticipantDisconnected,
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"), fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
@@ -596,108 +489,112 @@ describe("Publishing participants observations", () => {
}); });
}); });
describe("PublishConnection", () => { //
// let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>; // NOT USED ANYMORE ?
let roomFactoryMock: Mock<() => LivekitRoom>; //
let muteStates: MockedObject<MuteStates>; // This setup look like sth for the Publisher. Not a connection.
function setUpPublishConnection(): void { // describe("PublishConnection", () => {
setupTest(); // // let fakeBlurProcessor: ProcessorWrapper<BackgroundOptions>;
// let roomFactoryMock: Mock<() => LivekitRoom>;
// let muteStates: MockedObject<MuteStates>;
roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom); // function setUpPublishConnection(): void {
// setupTest();
muteStates = mockMuteStates(); // roomFactoryMock = vi.fn().mockReturnValue(fakeLivekitRoom);
// fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({ // muteStates = mockMuteStates();
// name: "BackgroundBlur",
// restart: vi.fn().mockResolvedValue(undefined),
// setOptions: vi.fn().mockResolvedValue(undefined),
// getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
// isRunning: vi.fn().mockReturnValue(false)
// });
}
describe("Livekit room creation", () => { // // fakeBlurProcessor = vi.mocked<ProcessorWrapper<BackgroundOptions>>({
function createSetup(): void { // // name: "BackgroundBlur",
setUpPublishConnection(); // // restart: vi.fn().mockResolvedValue(undefined),
// // setOptions: vi.fn().mockResolvedValue(undefined),
// // getOptions: vi.fn().mockReturnValue({ strength: 0.5 }),
// // isRunning: vi.fn().mockReturnValue(false)
// // });
// }
const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({ // describe("Livekit room creation", () => {
supported: true, // function createSetup(): void {
processor: undefined, // setUpPublishConnection();
});
const opts: ConnectionOpts = { // const fakeTrackProcessorSubject$ = new BehaviorSubject<ProcessorState>({
client: client, // supported: true,
transport: livekitFocus, // processor: undefined,
remoteTransports$: fakeMembershipsFocusMap$, // });
scope: testScope,
livekitRoomFactory: roomFactoryMock,
};
const audioInput = { // const opts: ConnectionOpts = {
available$: of(new Map([["mic1", { id: "mic1" }]])), // client: client,
selected$: new BehaviorSubject({ id: "mic1" }), // transport: livekitFocus,
select(): void {}, // scope: testScope,
}; // livekitRoomFactory: roomFactoryMock,
// };
const videoInput = { // const audioInput = {
available$: of(new Map([["cam1", { id: "cam1" }]])), // available$: of(new Map([["mic1", { id: "mic1" }]])),
selected$: new BehaviorSubject({ id: "cam1" }), // selected$: new BehaviorSubject({ id: "mic1" }),
select(): void {}, // select(): void {},
}; // };
const audioOutput = { // const videoInput = {
available$: of(new Map([["speaker", { id: "speaker" }]])), // available$: of(new Map([["cam1", { id: "cam1" }]])),
selected$: new BehaviorSubject({ id: "speaker" }), // selected$: new BehaviorSubject({ id: "cam1" }),
select(): void {}, // select(): void {},
}; // };
// TODO understand what is wrong with our mocking that requires ts-expect-error // const audioOutput = {
const fakeDevices = mockMediaDevices({ // available$: of(new Map([["speaker", { id: "speaker" }]])),
// @ts-expect-error Mocking only // selected$: new BehaviorSubject({ id: "speaker" }),
audioInput, // select(): void {},
// @ts-expect-error Mocking only // };
videoInput,
// @ts-expect-error Mocking only
audioOutput,
});
new PublishConnection( // // TODO understand what is wrong with our mocking that requires ts-expect-error
opts, // const fakeDevices = mockMediaDevices({
fakeDevices, // // @ts-expect-error Mocking only
muteStates, // audioInput,
undefined, // // @ts-expect-error Mocking only
fakeTrackProcessorSubject$, // videoInput,
); // // @ts-expect-error Mocking only
} // audioOutput,
// });
it("should create room with proper initial audio and video settings", () => { // new Connection(
createSetup(); // opts,
// fakeDevices,
// muteStates,
// undefined,
// fakeTrackProcessorSubject$,
// );
// }
expect(roomFactoryMock).toHaveBeenCalled(); // it("should create room with proper initial audio and video settings", () => {
// createSetup();
const lastCallArgs = // expect(roomFactoryMock).toHaveBeenCalled();
roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
const roomOptions = lastCallArgs.pop() as unknown as RoomOptions; // const lastCallArgs =
expect(roomOptions).toBeDefined(); // roomFactoryMock.mock.calls[roomFactoryMock.mock.calls.length - 1];
expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1"); // const roomOptions = lastCallArgs.pop() as unknown as RoomOptions;
expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1"); // expect(roomOptions).toBeDefined();
expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
});
it("respect controlledAudioDevices", () => { // expect(roomOptions!.videoCaptureDefaults?.deviceId).toEqual("cam1");
// TODO: Refactor the code to make it testable. // expect(roomOptions!.audioCaptureDefaults?.deviceId).toEqual("mic1");
// The UrlParams module is a singleton has a cache and is very hard to test. // expect(roomOptions!.audioOutput?.deviceId).toEqual("speaker");
// This breaks other tests as well if not handled properly. // });
// vi.mock(import("./../UrlParams"), () => {
// return { // it("respect controlledAudioDevices", () => {
// getUrlParams: vi.fn().mockReturnValue({ // // TODO: Refactor the code to make it testable.
// controlledAudioDevices: true // // The UrlParams module is a singleton has a cache and is very hard to test.
// }) // // This breaks other tests as well if not handled properly.
// }; // // vi.mock(import("./../UrlParams"), () => {
// }); // // return {
}); // // getUrlParams: vi.fn().mockReturnValue({
}); // // controlledAudioDevices: true
}); // // })
// // };
// // });
// });
// });
// });

View File

@@ -1,4 +1,5 @@
/* /*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd. Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -11,31 +12,29 @@ import {
} from "@livekit/components-core"; } from "@livekit/components-core";
import { import {
ConnectionError, ConnectionError,
type ConnectionState, type ConnectionState as LivekitConenctionState,
type E2EEOptions, type Room as LivekitRoom,
type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
Room as LivekitRoom, RoomEvent,
type RoomOptions,
} from "livekit-client"; } from "livekit-client";
import { import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
type CallMembership, import { BehaviorSubject, map, type Observable } from "rxjs";
type LivekitTransport, import { type Logger } from "matrix-js-sdk/lib/logger";
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, combineLatest, type Observable } from "rxjs";
import { import {
getSFUConfigWithOpenID, getSFUConfigWithOpenID,
type OpenIDClientParts, type OpenIDClientParts,
type SFUConfig, type SFUConfig,
} from "../livekit/openIDSFU"; } from "../../../livekit/openIDSFU.ts";
import { type Behavior } from "./Behavior"; import { type Behavior } from "../../Behavior.ts";
import { type ObservableScope } from "./ObservableScope"; import { type ObservableScope } from "../../ObservableScope.ts";
import { defaultLiveKitOptions } from "../livekit/options";
import { import {
InsufficientCapacityError, InsufficientCapacityError,
SFURoomCreationRestrictedError, SFURoomCreationRestrictedError,
} from "../utils/errors.ts"; } from "../../../utils/errors.ts";
export type PublishingParticipant = LocalParticipant | RemoteParticipant;
export interface ConnectionOpts { export interface ConnectionOpts {
/** The media transport to connect to. */ /** The media transport to connect to. */
@@ -44,16 +43,12 @@ export interface ConnectionOpts {
client: OpenIDClientParts; client: OpenIDClientParts;
/** The observable scope to use for this connection. */ /** The observable scope to use for this connection. */
scope: ObservableScope; scope: ObservableScope;
/** An observable of the current RTC call memberships and their associated transports. */
remoteTransports$: Behavior<
{ membership: CallMembership; transport: LivekitTransport }[]
>;
/** Optional factory to create the LiveKit room, mainly for testing purposes. */ /** Optional factory to create the LiveKit room, mainly for testing purposes. */
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom; livekitRoomFactory: () => LivekitRoom;
} }
export type TransportState = export type ConnectionState =
| { state: "Initialized" } | { state: "Initialized" }
| { state: "FetchingConfig"; transport: LivekitTransport } | { state: "FetchingConfig"; transport: LivekitTransport }
| { state: "ConnectingToLkRoom"; transport: LivekitTransport } | { state: "ConnectingToLkRoom"; transport: LivekitTransport }
@@ -61,26 +56,11 @@ export type TransportState =
| { state: "FailedToStart"; error: Error; transport: LivekitTransport } | { state: "FailedToStart"; error: Error; transport: LivekitTransport }
| { | {
state: "ConnectedToLkRoom"; state: "ConnectedToLkRoom";
connectionState$: Observable<ConnectionState>; livekitConnectionState$: Observable<LivekitConenctionState>;
transport: LivekitTransport; transport: LivekitTransport;
} }
| { state: "Stopped"; transport: LivekitTransport }; | { state: "Stopped"; transport: LivekitTransport };
/**
* Represents participant publishing or expected to publish on the connection.
* It is paired with its associated rtc membership.
*/
export type PublishingParticipant = {
/**
* The LiveKit participant publishing on this connection, or undefined if the participant is not currently (yet) connected to the livekit room.
*/
participant: RemoteParticipant | undefined;
/**
* The rtc call membership associated with this participant.
*/
membership: CallMembership;
};
/** /**
* A connection to a Matrix RTC LiveKit backend. * A connection to a Matrix RTC LiveKit backend.
* *
@@ -88,15 +68,14 @@ export type PublishingParticipant = {
*/ */
export class Connection { export class Connection {
// Private Behavior // Private Behavior
private readonly _transportState$ = new BehaviorSubject<TransportState>({ private readonly _state$ = new BehaviorSubject<ConnectionState>({
state: "Initialized", state: "Initialized",
}); });
/** /**
* The current state of the connection to the media transport. * The current state of the connection to the media transport.
*/ */
public readonly transportState$: Behavior<TransportState> = public readonly state$: Behavior<ConnectionState> = this._state$;
this._transportState$;
/** /**
* Whether the connection has been stopped. * Whether the connection has been stopped.
@@ -112,13 +91,18 @@ export class Connection {
* 2. Use this token to request the SFU config to the MatrixRtc authentication service. * 2. Use this token to request the SFU config to the MatrixRtc authentication service.
* 3. Connect to the configured LiveKit room. * 3. Connect to the configured LiveKit room.
* *
* The errors are also represented as a state in the `state$` observable.
* It is safe to ignore those errors and handle them accordingly via the `state$` observable.
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection. * @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created. * @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
*/ */
// TODO dont make this throw and instead store a connection error state in this class?
// TODO consider an autostart pattern...
public async start(): Promise<void> { public async start(): Promise<void> {
this.logger.debug("Starting Connection");
this.stopped = false; this.stopped = false;
try { try {
this._transportState$.next({ this._state$.next({
state: "FetchingConfig", state: "FetchingConfig",
transport: this.transport, transport: this.transport,
}); });
@@ -126,7 +110,7 @@ export class Connection {
// If we were stopped while fetching the config, don't proceed to connect // If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return; if (this.stopped) return;
this._transportState$.next({ this._state$.next({
state: "ConnectingToLkRoom", state: "ConnectingToLkRoom",
transport: this.transport, transport: this.transport,
}); });
@@ -157,13 +141,14 @@ export class Connection {
// If we were stopped while connecting, don't proceed to update state. // If we were stopped while connecting, don't proceed to update state.
if (this.stopped) return; if (this.stopped) return;
this._transportState$.next({ this._state$.next({
state: "ConnectedToLkRoom", state: "ConnectedToLkRoom",
transport: this.transport, transport: this.transport,
connectionState$: connectionStateObserver(this.livekitRoom), livekitConnectionState$: connectionStateObserver(this.livekitRoom),
}); });
} catch (error) { } catch (error) {
this._transportState$.next({ this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
this._state$.next({
state: "FailedToStart", state: "FailedToStart",
error: error instanceof Error ? error : new Error(`${error}`), error: error instanceof Error ? error : new Error(`${error}`),
transport: this.transport, transport: this.transport,
@@ -179,6 +164,7 @@ export class Connection {
this.transport.livekit_alias, this.transport.livekit_alias,
); );
} }
/** /**
* Stops the connection. * Stops the connection.
* *
@@ -186,9 +172,12 @@ export class Connection {
* If the connection is already stopped, this is a no-op. * If the connection is already stopped, this is a no-op.
*/ */
public async stop(): Promise<void> { public async stop(): Promise<void> {
this.logger.debug(
`Stopping connection to ${this.transport.livekit_service_url}`,
);
if (this.stopped) return; if (this.stopped) return;
await this.livekitRoom.disconnect(); await this.livekitRoom.disconnect();
this._transportState$.next({ this._state$.next({
state: "Stopped", state: "Stopped",
transport: this.transport, transport: this.transport,
}); });
@@ -196,11 +185,13 @@ export class Connection {
} }
/** /**
* An observable of the participants that are publishing on this connection. * An observable of the participants that are publishing on this connection. (Excluding our local participant)
* This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`. * This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`.
* It filters the participants to only those that are associated with a membership that claims to publish on this connection. * It filters the participants to only those that are associated with a membership that claims to publish on this connection.
*/ */
public readonly publishingParticipants$: Behavior<PublishingParticipant[]>; public readonly remoteParticipantsWithTracks$: Behavior<
PublishingParticipant[]
>;
/** /**
* The media transport to connect to. * The media transport to connect to.
@@ -208,79 +199,50 @@ export class Connection {
public readonly transport: LivekitTransport; public readonly transport: LivekitTransport;
private readonly client: OpenIDClientParts; private readonly client: OpenIDClientParts;
public readonly livekitRoom: LivekitRoom;
private readonly logger: Logger;
/** /**
* Creates a new connection to a matrix RTC LiveKit backend. * Creates a new connection to a matrix RTC LiveKit backend.
* *
* @param livekitRoom - LiveKit room instance to use.
* @param opts - Connection options {@link ConnectionOpts}. * @param opts - Connection options {@link ConnectionOpts}.
* *
* @param logger
*/ */
protected constructor( public constructor(opts: ConnectionOpts, logger: Logger) {
public readonly livekitRoom: LivekitRoom, this.logger = logger.getChild("[Connection]");
opts: ConnectionOpts, this.logger.info(
) {
logger.log(
`[Connection] Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`, `[Connection] Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,
); );
const { transport, client, scope, remoteTransports$ } = opts; const { transport, client, scope } = opts;
this.livekitRoom = opts.livekitRoomFactory();
this.transport = transport; this.transport = transport;
this.client = client; this.client = client;
const participantsIncludingSubscribers$ = scope.behavior( // REMOTE participants with track!!!
connectedParticipantsObserver(this.livekitRoom), // this.remoteParticipantsWithTracks$
[], this.remoteParticipantsWithTracks$ = scope.behavior(
); // only tracks remote participants
connectedParticipantsObserver(this.livekitRoom, {
this.publishingParticipants$ = scope.behavior( additionalRoomEvents: [
combineLatest( RoomEvent.TrackPublished,
[participantsIncludingSubscribers$, remoteTransports$], RoomEvent.TrackUnpublished,
(participants, remoteTransports) => ],
remoteTransports }).pipe(
// Find all members that claim to publish on this connection map((participants) => {
.flatMap(({ membership, transport }) => return participants.filter(
transport.livekit_service_url === (participant) => participant.getTrackPublications().length > 0,
this.transport.livekit_service_url );
? [membership] }),
: [],
)
// Pair with their associated LiveKit participant (if any)
.map((membership) => {
const id = `${membership.userId}:${membership.deviceId}`;
const participant = participants.find((p) => p.identity === id);
return { participant, membership };
}),
), ),
[], [],
); );
scope.onEnd(() => void this.stop()); scope.onEnd(() => {
} this.logger.info(`Connection scope ended, stopping connection`);
} void this.stop();
/**
* A remote connection to the Matrix RTC LiveKit backend.
*
* This connection is used for subscribing to remote participants.
* It does not publish any local tracks.
*/
export class RemoteConnection extends Connection {
/**
* Creates a new remote connection to a matrix RTC LiveKit backend.
* @param opts
* @param sharedE2eeOption - The shared E2EE options to use for the connection.
*/
public constructor(
opts: ConnectionOpts,
sharedE2eeOption: E2EEOptions | undefined,
) {
const factory =
opts.livekitRoomFactory ??
((options: RoomOptions): LivekitRoom => new LivekitRoom(options));
const livekitRoom = factory({
...defaultLiveKitOptions,
e2ee: sharedE2eeOption,
}); });
super(livekitRoom, opts);
} }
} }

View File

@@ -0,0 +1,121 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import {
type E2EEOptions,
Room as LivekitRoom,
type RoomOptions,
type BaseKeyProvider,
} from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { type ObservableScope } from "../../ObservableScope.ts";
import { Connection } from "./Connection.ts";
import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import type { MediaDevices } from "../../MediaDevices.ts";
import type { Behavior } from "../../Behavior.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { defaultLiveKitOptions } from "../../../livekit/options.ts";
export interface ConnectionFactory {
createConnection(
transport: LivekitTransport,
scope: ObservableScope,
logger: Logger,
): Connection;
}
export class ECConnectionFactory implements ConnectionFactory {
private readonly livekitRoomFactory: () => LivekitRoom;
/**
* Creates a ConnectionFactory for LiveKit connections.
*
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
* @param devices - Used for video/audio out/in capture options.
* @param processorState$ - Effects like background blur (only for publishing connection?)
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit Room.
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
*/
public constructor(
private client: OpenIDClientParts,
private devices: MediaDevices,
private processorState$: Behavior<ProcessorState>,
livekitKeyProvider: BaseKeyProvider | undefined,
private controlledAudioDevices: boolean,
livekitRoomFactory?: () => LivekitRoom,
) {
const defaultFactory = (): LivekitRoom =>
new LivekitRoom(
generateRoomOption(
this.devices,
this.processorState$.value,
livekitKeyProvider && {
keyProvider: livekitKeyProvider,
// It's important that every room use a separate E2EE worker.
// They get confused if given streams from multiple rooms.
worker: new E2EEWorker(),
},
this.controlledAudioDevices,
),
);
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
}
public createConnection(
transport: LivekitTransport,
scope: ObservableScope,
logger: Logger,
): Connection {
return new Connection(
{
transport,
client: this.client,
scope: scope,
livekitRoomFactory: this.livekitRoomFactory,
},
logger,
);
}
}
/**
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
*/
function generateRoomOption(
devices: MediaDevices,
processorState: ProcessorState,
e2eeLivekitOptions: E2EEOptions | undefined,
controlledAudioDevices: boolean,
): RoomOptions {
return {
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id,
},
audioOutput: {
// When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: devices.audioOutput.selected$.value?.id,
},
e2ee: e2eeLivekitOptions,
// TODO test and consider this:
// webAudioMix: true,
};
}

View File

@@ -0,0 +1,332 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type Participant as LivekitParticipant } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, ObservableScope } from "../../ObservableScope.ts";
import {
createConnectionManager$,
type ConnectionManagerData,
} from "./ConnectionManager.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts";
import { withTestScheduler } from "../../../utils/test.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type Behavior } from "../../Behavior.ts";
// Some test constants
const TRANSPORT_1: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
livekit_alias: "!alias:example.org",
};
const TRANSPORT_2: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.sample.com",
livekit_alias: "!alias:sample.com",
};
let fakeConnectionFactory: ConnectionFactory;
let testScope: ObservableScope;
// Can be useful to track all created connections in tests, even the disposed ones
let allCreatedConnections: Connection[];
beforeEach(() => {
testScope = new ObservableScope();
allCreatedConnections = [];
fakeConnectionFactory = {} as unknown as ConnectionFactory;
vi.mocked(fakeConnectionFactory).createConnection = vi
.fn()
.mockImplementation(
(transport: LivekitTransport, scope: ObservableScope) => {
const mockConnection = {
transport,
remoteParticipantsWithTracks$: new BehaviorSubject([]),
} as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn();
// Tie the connection's lifecycle to the scope to test scope lifecycle management
scope.onEnd(() => {
void mockConnection.stop();
});
allCreatedConnections.push(mockConnection);
return mockConnection;
},
);
});
afterEach(() => {
testScope.end();
});
describe("connections$ stream", () => {
test("Should create and start new connections for each transports", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { connections$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
logger: logger,
});
expectObservable(connections$).toBe("a", {
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(2);
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2);
const conn1 = connections.find((c) =>
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalled();
const conn2 = connections.find((c) =>
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined();
expect(conn2!.start).toHaveBeenCalled();
return true;
}),
});
});
});
test("Should start connection only once", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { connections$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("abcdef", {
a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1], 1),
c: new Epoch([TRANSPORT_1], 2),
d: new Epoch([TRANSPORT_1], 3),
e: new Epoch([TRANSPORT_1], 4),
f: new Epoch([TRANSPORT_1, TRANSPORT_2], 5),
}),
logger: logger,
});
expectObservable(connections$).toBe("xxxxxa", {
x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(2);
expect(
vi.mocked(fakeConnectionFactory).createConnection,
).toHaveBeenCalledTimes(2);
const conn2 = connections.find((c) =>
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(conn2).toBeDefined();
const conn1 = connections.find((c) =>
areLivekitTransportsEqual(c.transport, TRANSPORT_1),
);
expect(conn1).toBeDefined();
expect(conn1!.start).toHaveBeenCalledOnce();
return true;
}),
});
});
});
test("Should cleanup connections when not needed anymore", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { connections$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("abc", {
a: new Epoch([TRANSPORT_1], 0),
b: new Epoch([TRANSPORT_1, TRANSPORT_2], 1),
c: new Epoch([TRANSPORT_1], 2),
}),
logger: logger,
});
expectObservable(connections$).toBe("xab", {
x: expect.anything(),
a: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(2);
return true;
}),
b: expect.toSatisfy((e: Epoch<Connection[]>) => {
const connections = e.value;
expect(connections.length).toBe(1);
// The second connection should have been stopped has it is no longer needed.
const connection2 = allCreatedConnections.find((c) =>
areLivekitTransportsEqual(c.transport, TRANSPORT_2),
);
expect(connection2).toBeDefined();
expect(connection2!.stop).toHaveBeenCalled();
// The first connection should still be active
const conn1 = connections[0];
expect(conn1.stop).not.toHaveBeenCalledOnce();
return true;
}),
});
});
});
});
describe("connectionManagerData$ stream", () => {
// Used in test to control fake connections' remoteParticipantsWithTracks$ streams
let fakePublishingParticipantsStreams: Map<
string,
Behavior<LivekitParticipant[]>
>;
function keyForTransport(transport: LivekitTransport): string {
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
}
beforeEach(() => {
fakePublishingParticipantsStreams = new Map();
function getPublishingParticipantsFor(
transport: LivekitTransport,
): Behavior<LivekitParticipant[]> {
return (
fakePublishingParticipantsStreams.get(keyForTransport(transport)) ??
new BehaviorSubject([])
);
}
// need a more advanced fake connection factory
vi.mocked(fakeConnectionFactory).createConnection = vi
.fn()
.mockImplementation(
(transport: LivekitTransport, scope: ObservableScope) => {
const fakePublishingParticipants$ = new BehaviorSubject<
LivekitParticipant[]
>([]);
const mockConnection = {
transport,
remoteParticipantsWithTracks$:
getPublishingParticipantsFor(transport),
} as unknown as Connection;
vi.mocked(mockConnection).start = vi.fn();
vi.mocked(mockConnection).stop = vi.fn();
// Tie the connection's lifecycle to the scope to test scope lifecycle management
scope.onEnd(() => {
void mockConnection.stop();
});
fakePublishingParticipantsStreams.set(
keyForTransport(transport),
fakePublishingParticipants$,
);
return mockConnection;
},
);
});
test("Should report connections with the publishing participants", () => {
withTestScheduler(({ expectObservable, schedule, behavior }) => {
// Setup the fake participants streams behavior
// ==============================
fakePublishingParticipantsStreams.set(
keyForTransport(TRANSPORT_1),
behavior("oa-b", {
o: [],
a: [{ identity: "user1A" } as LivekitParticipant],
b: [
{ identity: "user1A" } as LivekitParticipant,
{ identity: "user1B" } as LivekitParticipant,
],
}),
);
fakePublishingParticipantsStreams.set(
keyForTransport(TRANSPORT_2),
behavior("o-a", {
o: [],
a: [{ identity: "user2A" } as LivekitParticipant],
}),
);
// ==============================
const { connectionManagerData$ } = createConnectionManager$({
scope: testScope,
connectionFactory: fakeConnectionFactory,
inputTransports$: behavior("a", {
a: new Epoch([TRANSPORT_1, TRANSPORT_2], 0),
}),
logger,
});
expectObservable(connectionManagerData$).toBe("abcd", {
a: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(0);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0);
return true;
}),
b: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0);
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe(
"user1A",
);
return true;
}),
c: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe(
"user1A",
);
expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe(
"user2A",
);
return true;
}),
d: expect.toSatisfy((e) => {
const data: ConnectionManagerData = e.value;
expect(data.getConnections().length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(2);
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1);
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe(
"user1A",
);
expect(data.getParticipantForTransport(TRANSPORT_1)[1].identity).toBe(
"user1B",
);
expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe(
"user2A",
);
return true;
}),
});
});
});
});

View File

@@ -0,0 +1,231 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LivekitTransport,
type ParticipantId,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, combineLatest, map, of, switchMap, tap } from "rxjs";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
import { type Behavior } from "../../Behavior.ts";
import { type Connection } from "./Connection.ts";
import { Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { generateItemsWithEpoch } from "../../../utils/observable.ts";
import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
export class ConnectionManagerData {
private readonly store: Map<
string,
[Connection, (LocalParticipant | RemoteParticipant)[]]
> = new Map();
public constructor() {}
public add(
connection: Connection,
participants: (LocalParticipant | RemoteParticipant)[],
): void {
const key = this.getKey(connection.transport);
const existing = this.store.get(key);
if (!existing) {
this.store.set(key, [connection, participants]);
} else {
existing[1].push(...participants);
}
}
private getKey(transport: LivekitTransport): string {
return transport.livekit_service_url + "|" + transport.livekit_alias;
}
public getConnections(): Connection[] {
return Array.from(this.store.values()).map(([connection]) => connection);
}
public getConnectionForTransport(
transport: LivekitTransport,
): Connection | null {
return this.store.get(this.getKey(transport))?.[0] ?? null;
}
public getParticipantForTransport(
transport: LivekitTransport,
): (LocalParticipant | RemoteParticipant)[] {
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
const existing = this.store.get(key);
if (existing) {
return existing[1];
}
return [];
}
/**
* Get all connections where the given participant is publishing.
* In theory, there could be several connections where the same participant is publishing but with
* only well behaving clients a participant should only be publishing on a single connection.
* @param participantId
*/
public getConnectionsForParticipant(
participantId: ParticipantId,
): Connection[] {
const connections: Connection[] = [];
for (const [connection, participants] of this.store.values()) {
if (participants.some((p) => p.identity === participantId)) {
connections.push(connection);
}
}
return connections;
}
}
interface Props {
scope: ObservableScope;
connectionFactory: ConnectionFactory;
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
logger: Logger;
}
// TODO - write test for scopes (do we really need to bind scope)
export interface IConnectionManager {
transports$: Behavior<Epoch<LivekitTransport[]>>;
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
connections$: Behavior<Epoch<Connection[]>>;
}
/**
* Crete a `ConnectionManager`
* @param scope the observable scope used by this object.
* @param connectionFactory used to create new connections.
* @param _transportsSubscriptions$ A list of Behaviors each containing a LIST of LivekitTransport.
* Each of these behaviors can be interpreted as subscribed list of transports.
*
* Using `registerTransports` independent external modules can control what connections
* are created by the ConnectionManager.
*
* The connection manager will remove all duplicate transports in each subscibed list.
*
* See `unregisterAllTransports` and `unregisterTransport` for details on how to unsubscribe.
*/
export function createConnectionManager$({
scope,
connectionFactory,
inputTransports$,
logger: parentLogger,
}: Props): IConnectionManager {
const logger = parentLogger.getChild("[ConnectionManager]");
const running$ = new BehaviorSubject(true);
scope.onEnd(() => running$.next(false));
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
/**
* All transports currently managed by the ConnectionManager.
*
* This list does not include duplicate transports.
*
* It is build based on the list of subscribed transports (`transportsSubscriptions$`).
* externally this is modified via `registerTransports()`.
*/
const transports$ = scope.behavior(
combineLatest([running$, inputTransports$]).pipe(
map(([running, transports]) =>
transports.mapInner((transport) => (running ? transport : [])),
),
map((transports) => transports.mapInner(removeDuplicateTransports)),
tap(({ value: transports }) => {
logger.trace(
`Managing transports: ${transports.map((t) => t.livekit_service_url).join(", ")}`,
);
}),
),
);
/**
* Connections for each transport in use by one or more session members.
*/
const connections$ = scope.behavior(
transports$.pipe(
generateItemsWithEpoch(
function* (transports) {
for (const transport of transports)
yield {
keys: [transport.livekit_service_url, transport.livekit_alias],
data: undefined,
};
},
(scope, _data$, serviceUrl, alias) => {
logger.debug(`Creating connection to ${serviceUrl} (${alias})`);
const connection = connectionFactory.createConnection(
{
type: "livekit",
livekit_service_url: serviceUrl,
livekit_alias: alias,
},
scope,
logger,
);
// Start the connection immediately
// Use connection state to track connection progress
void connection.start();
// TODO subscribe to connection state to retry or log issues?
return connection;
},
),
),
);
const connectionManagerData$ = scope.behavior(
connections$.pipe(
switchMap((connections) => {
const epoch = connections.epoch;
// Map the connections to list of {connection, participants}[]
const listOfConnectionsWithPublishingParticipants =
connections.value.map((connection) => {
return connection.remoteParticipantsWithTracks$.pipe(
map((participants) => ({
connection,
participants,
})),
);
});
// probably not required
if (listOfConnectionsWithPublishingParticipants.length === 0) {
return of(new Epoch(new ConnectionManagerData(), epoch));
}
// combineLatest the several streams into a single stream with the ConnectionManagerData
return combineLatest(listOfConnectionsWithPublishingParticipants).pipe(
map(
(lists) =>
new Epoch(
lists.reduce((data, { connection, participants }) => {
data.add(connection, participants);
return data;
}, new ConnectionManagerData()),
epoch,
),
),
);
}),
),
new Epoch(new ConnectionManagerData()),
);
return { transports$, connectionManagerData$, connections$ };
}
function removeDuplicateTransports(
transports: LivekitTransport[],
): LivekitTransport[] {
return transports.reduce((acc, transport) => {
if (!acc.some((t) => areLivekitTransportsEqual(t, transport)))
acc.push(transport);
return acc;
}, [] as LivekitTransport[]);
}

View File

@@ -0,0 +1,384 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
type CallMembership,
type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { getParticipantId } from "matrix-js-sdk/lib/matrixrtc/utils";
import { combineLatest, map, type Observable } from "rxjs";
import { type IConnectionManager } from "./ConnectionManager.ts";
import {
type MatrixLivekitMember,
createMatrixLivekitMembers$,
} from "./MatrixLivekitMembers.ts";
import {
Epoch,
mapEpoch,
ObservableScope,
trackEpoch,
} from "../../ObservableScope.ts";
import { ConnectionManagerData } from "./ConnectionManager.ts";
import {
mockCallMembership,
mockRemoteParticipant,
withTestScheduler,
} from "../../../utils/test.ts";
import { type Connection } from "./Connection.ts";
let testScope: ObservableScope;
const transportA: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
livekit_alias: "!alias:example.org",
};
const transportB: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.sample.com",
livekit_alias: "!alias:sample.com",
};
const bobMembership = mockCallMembership(
"@bob:example.org",
"DEV000",
transportA,
);
const carlMembership = mockCallMembership(
"@carl:sample.com",
"DEV111",
transportB,
);
beforeEach(() => {
testScope = new ObservableScope();
});
afterEach(() => {
testScope.end();
});
function epochMeWith$<T, U>(
source$: Observable<Epoch<U>>,
me$: Observable<T>,
): Observable<Epoch<T>> {
return combineLatest([source$, me$]).pipe(
map(([ep, cd]) => {
return new Epoch(cd, ep.epoch);
}),
);
}
test("should signal participant not yet connected to livekit", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership],
}),
);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: new ConnectionManagerData(),
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant$).toBe("a", {
a: null,
});
expectObservable(data[0].connection$).toBe("a", {
a: null,
});
return true;
}),
});
});
});
// Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable.
function fromMemberships$(m$: Observable<CallMembership[]>): {
memberships$: Observable<Epoch<CallMembership[]>>;
membershipsWithTransport$: Observable<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
>;
} {
const memberships$ = m$.pipe(trackEpoch());
const membershipsWithTransport$ = memberships$.pipe(
mapEpoch((members) => {
return members.map((m) => {
const tr = m.getTransport(m);
return {
membership: m,
transport:
tr?.type === "livekit" ? (tr as LivekitTransport) : undefined,
};
});
}),
);
return {
memberships$,
membershipsWithTransport$,
};
}
test("should signal participant on a connection that is publishing", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const bobParticipantId = getParticipantId(
bobMembership.userId,
bobMembership.deviceId,
);
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership],
}),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: dataWithPublisher,
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant$).toBe("a", {
a: expect.toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
}),
});
expectObservable(data[0].connection$).toBe("a", {
a: connection,
});
return true;
}),
});
});
});
test("should signal participant on a connection that is not publishing", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership],
}),
);
const connection = {
transport: bobMembership.getTransport(bobMembership),
} as unknown as Connection;
const dataWithPublisher = new ConnectionManagerData();
dataWithPublisher.add(connection, []);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: dataWithPublisher,
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(1);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].participant$).toBe("a", {
a: null,
});
expectObservable(data[0].connection$).toBe("a", {
a: connection,
});
return true;
}),
});
});
});
describe("Publication edge case", () => {
test("bob is publishing in several connections", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership, carlMembership],
}),
);
const connectionWithPublisher = new ConnectionManagerData();
const bobParticipantId = getParticipantId(
bobMembership.userId,
bobMembership.deviceId,
);
const connectionA = {
transport: transportA,
} as unknown as Connection;
const connectionB = {
transport: transportB,
} as unknown as Connection;
connectionWithPublisher.add(connectionA, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: connectionWithPublisher,
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$,
),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe(
"a",
{
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].connection$).toBe("a", {
// The real connection should be from transportA as per the membership
a: connectionA,
});
expectObservable(data[0].participant$).toBe("a", {
a: expect.toSatisfy((participant) => {
expect(participant).toBeDefined();
expect(participant!.identity).toEqual(bobParticipantId);
return true;
}),
});
return true;
}),
},
);
});
});
test("bob is publishing in the wrong connection", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const { memberships$, membershipsWithTransport$ } = fromMemberships$(
behavior("a", {
a: [bobMembership, carlMembership],
}),
);
const connectionWithPublisher = new ConnectionManagerData();
const bobParticipantId = getParticipantId(
bobMembership.userId,
bobMembership.deviceId,
);
const connectionA = { transport: transportA } as unknown as Connection;
const connectionB = { transport: transportB } as unknown as Connection;
// Bob is not publishing on A
connectionWithPublisher.add(connectionA, []);
// Bob is publishing on B but his membership says A
connectionWithPublisher.add(connectionB, [
mockRemoteParticipant({ identity: bobParticipantId }),
]);
const connectionManagerData$ = epochMeWith$(
memberships$,
behavior("a", {
a: connectionWithPublisher,
}),
);
const matrixLivekitMember$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$: testScope.behavior(
membershipsWithTransport$,
),
connectionManager: {
connectionManagerData$: connectionManagerData$,
} as unknown as IConnectionManager,
});
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe(
"a",
{
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
expect(data.length).toEqual(2);
expectObservable(data[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(data[0].connection$).toBe("a", {
// The real connection should be from transportA as per the membership
a: connectionA,
});
expectObservable(data[0].participant$).toBe("a", {
// No participant as Bob is not publishing on his membership transport
a: null,
});
return true;
}),
},
);
});
});
});

View File

@@ -0,0 +1,138 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalParticipant as LocalLivekitParticipant,
type RemoteParticipant as RemoteLivekitParticipant,
} from "livekit-client";
import {
type LivekitTransport,
type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, filter, map } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager";
import { Epoch, type ObservableScope } from "../../ObservableScope";
import { type Connection } from "./Connection";
import { generateItemsWithEpoch } from "../../../utils/observable";
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
/**
* Represents a Matrix call member and their associated LiveKit participation.
* `livekitParticipant` can be undefined if the member is not yet connected to the livekit room
* or if it has no livekit transport at all.
*/
export interface MatrixLivekitMember {
membership$: Behavior<CallMembership>;
participant$: Behavior<
LocalLivekitParticipant | RemoteLivekitParticipant | null
>;
connection$: Behavior<Connection | null>;
// participantId: string; We do not want a participantId here since it will be generated by the jwt
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
userId: string;
}
interface Props {
scope: ObservableScope;
membershipsWithTransport$: Behavior<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
>;
connectionManager: IConnectionManager;
}
/**
* Combines MatrixRTC and Livekit worlds.
*
* It has a small public interface:
* - in (via constructor):
* - an observable of CallMembership[] to track the call members (The matrix side)
* - a `ConnectionManager` for the lk rooms (The livekit side)
* - out (via public Observable):
* - `remoteMatrixLivekitMember` an observable of MatrixLivekitMember[] to track the remote members and associated livekit data.
*/
export function createMatrixLivekitMembers$({
scope,
membershipsWithTransport$,
connectionManager,
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> {
/**
* Stream of all the call members and their associated livekit data (if available).
*/
return scope.behavior(
combineLatest([
membershipsWithTransport$,
connectionManager.connectionManagerData$,
]).pipe(
filter((values) =>
values.every((value) => value.epoch === values[0].epoch),
),
map(
([
{ value: membershipsWithTransports, epoch },
{ value: managerData },
]) =>
new Epoch([membershipsWithTransports, managerData] as const, epoch),
),
generateItemsWithEpoch(
// Generator function.
// creates an array of `{key, data}[]`
// Each change in the keys (new key, missing key) will result in a call to the factory function.
function* ([membershipsWithTransports, managerData]) {
for (const { membership, transport } of membershipsWithTransports) {
// TODO! cannot use membership.membershipID yet, Currently its hardcoded by the jwt service to
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
const participants = transport
? managerData.getParticipantForTransport(transport)
: [];
const participant =
participants.find((p) => p.identity == participantId) ?? null;
const connection = transport
? managerData.getConnectionForTransport(transport)
: null;
yield {
keys: [participantId, membership.userId],
data: { membership, participant, connection },
};
}
},
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
(scope, data$, participantId, userId) => {
logger.debug(
`Updating data$ for participantId: ${participantId}, userId: ${userId}`,
);
// will only get called once per `participantId, userId` pair.
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
return {
participantId,
userId,
...scope.splitBehavior(data$),
};
},
),
),
);
}
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
// TODO add this to the JS-SDK
export function areLivekitTransportsEqual(
t1: LivekitTransport | null,
t2: LivekitTransport | null,
): boolean {
if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url;
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service)
// It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation)
if (!t1 && !t2) return true;
return false;
}

View File

@@ -0,0 +1,613 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, describe, vi } from "vitest";
import {
type MatrixEvent,
type RoomMember,
type RoomState,
RoomStateEvent,
} from "matrix-js-sdk";
import EventEmitter from "events";
import { it } from "vitest";
import { ObservableScope } from "../../ObservableScope.ts";
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
import {
mockCallMembership,
mockMatrixRoomMember,
withTestScheduler,
} from "../../../utils/test.ts";
import {
createMatrixMemberMetadata$,
createRoomMembers$,
} from "./MatrixMemberMetadata.ts";
let testScope: ObservableScope;
let mockMatrixRoom: MatrixRoom;
describe("MatrixMemberMetadata", () => {
/*
* To be populated in the test setup.
* Maps userId to a partial/mock RoomMember object.
*/
let fakeMembersMap: Map<string, Partial<RoomMember>>;
beforeEach(() => {
testScope = new ObservableScope();
fakeMembersMap = new Map<string, Partial<RoomMember>>();
const roomEmitter = new EventEmitter();
mockMatrixRoom = {
on: roomEmitter.on.bind(roomEmitter),
off: roomEmitter.off.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
// addListener: roomEmitter.addListener.bind(roomEmitter),
// removeListener: roomEmitter.removeListener.bind(roomEmitter),
getMember: vi.fn().mockImplementation((userId: string) => {
const member = fakeMembersMap.get(userId);
if (member) {
return member as RoomMember;
}
return null;
}),
getMembers: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
getMembersWithMembership: vi.fn().mockImplementation(() => {
const members = Array.from(fakeMembersMap.values());
return members;
}),
} as unknown as MatrixRoom;
});
function fakeMemberWith(data: Partial<RoomMember>): void {
const userId = data.userId || "@alice:example.com";
const member: Partial<RoomMember> = {
userId: userId,
rawDisplayName: data.rawDisplayName ?? userId,
getMxcAvatarUrl:
data.getMxcAvatarUrl ||
vi.fn().mockImplementation(() => {
return `mxc://example.com/${userId}`;
}),
...data,
} as unknown as RoomMember;
fakeMembersMap.set(userId, member);
}
afterEach(() => {
fakeMembersMap.clear();
});
describe("displayname", () => {
function updateDisplayName(
userId: `@${string}:${string}`,
newDisplayName: string,
): void {
const member = fakeMembersMap.get(userId);
if (member) {
member.rawDisplayName = newDisplayName;
// Emit the event to notify listeners
mockMatrixRoom.emit(
RoomStateEvent.Members,
{} as unknown as MatrixEvent,
{} as unknown as RoomState,
member as RoomMember,
);
} else {
throw new Error(`No member found with userId: ${userId}`);
}
}
it("should show our own user if present in rtc session and room", () => {
withTestScheduler(({ behavior, expectObservable }) => {
fakeMemberWith({
userId: "@local:example.com",
rawDisplayName: "it's a me",
});
const memberships$ = behavior("a", {
a: [mockCallMembership("@local:example.com", "DEVICE1")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const dn$ =
metadataStore.createDisplayNameBehavior$("@local:example.com");
expectObservable(dn$).toBe("a", {
a: "it's a me",
});
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([["@local:example.com", "it's a me"]]),
});
});
});
function setUpBasicRoom(): void {
fakeMemberWith({
userId: "@local:example.com",
rawDisplayName: "it's a me",
});
fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
fakeMemberWith({ userId: "@no-name:foo.bar" });
}
it("should get displayName for users", () => {
setUpBasicRoom();
withTestScheduler(({ behavior, expectObservable }) => {
const memberships$ = behavior("a", {
a: [
mockCallMembership("@alice:example.com", "DEVICE1"),
mockCallMembership("@bob:example.com", "DEVICE1"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const aliceDispName$ =
metadataStore.createDisplayNameBehavior$("@alice:example.com");
expectObservable(aliceDispName$).toBe("a", {
a: "Alice",
});
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
["@alice:example.com", "Alice"],
["@bob:example.com", "Bob"],
]),
});
});
});
it("should use userId if no display name", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [mockCallMembership("@no-name:foo.bar", "D000")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
["@no-name:foo.bar", "@no-name:foo.bar"],
]),
});
});
});
it("should disambiguate users with same display name", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [
mockCallMembership("@bob:example.com", "DEVICE1"),
mockCallMembership("@bob:example.com", "DEVICE2"),
mockCallMembership("@bob:foo.bar", "BOB000"),
mockCallMembership("@carl:example.com", "C000"),
mockCallMembership("@evil:example.com", "E000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("a", {
a: new Map<string, string>([
// ["@local:example.com", "it's a me"],
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
["@carl:example.com", "Carl (@carl:example.com)"],
["@evil:example.com", "Carl (@evil:example.com)"],
]),
});
});
});
it("should start to disambiguate reactivly when needed", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("ab", {
a: [mockCallMembership("@bob:example.com", "DEVICE1")],
b: [
mockCallMembership("@bob:example.com", "DEVICE1"),
mockCallMembership("@bob:foo.bar", "BOB000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([["@bob:example.com", "Bob"]]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
]),
});
});
});
it("should keep disambiguated name when other leave", () => {
withTestScheduler(({ behavior, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("ab", {
a: [
mockCallMembership("@bob:example.com", "DEVICE1"),
mockCallMembership("@bob:foo.bar", "BOB000"),
],
b: [mockCallMembership("@bob:example.com", "DEVICE1")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@bob:foo.bar", "Bob (@bob:foo.bar)"],
]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
]),
});
});
});
it("should disambiguate on name change", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
setUpBasicRoom();
const memberships$ = behavior("a", {
a: [
mockCallMembership("@bob:example.com", "B000"),
mockCallMembership("@carl:example.com", "C000"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
schedule("-a", {
a: () => {
updateDisplayName("@carl:example.com", "Bob");
},
});
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
a: new Map<string, string>([
["@bob:example.com", "Bob"],
["@carl:example.com", "Carl"],
]),
b: new Map<string, string>([
["@bob:example.com", "Bob (@bob:example.com)"],
["@carl:example.com", "Bob (@carl:example.com)"],
]),
});
});
});
it("should track individual member id with createDisplayNameBehavior", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
setUpBasicRoom();
const BOB = "@bob:example.com";
const CARL = "@carl:example.com";
// for this test we build a mock environment that does all possible changes:
// - memberships join/leave
// - room join/leave
// - disambiguate
const memberships$ = behavior("ab-d", {
a: [mockCallMembership(CARL, "C000")],
b: [
mockCallMembership(CARL, "C000"),
// bob joins
mockCallMembership(BOB, "B000"),
],
// c carl gets renamed to BOB
d: [
// carl leaves
mockCallMembership(BOB, "B000"),
],
});
schedule("--a-", {
a: () => {
// carl renames
updateDisplayName(CARL, "Bob");
},
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const bob$ = metadataStore.createDisplayNameBehavior$(BOB);
const carl$ = metadataStore.createDisplayNameBehavior$(CARL);
expectObservable(bob$).toBe("abc-", {
a: undefined,
b: "Bob",
c: "Bob (@bob:example.com)",
// bob stays disambiguate even though carl left
// d: "Bob (@bob:example.com)",
});
expectObservable(carl$).toBe("a-cd", {
a: "Carl",
// b: "Carl",
// carl gets renamed and disambiguate
c: "Bob (@carl:example.com)",
d: undefined,
});
});
});
it("should disambiguate users with invisible characters", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const bobRtcMember = mockCallMembership("@bob:example.org", "BBBB");
const bobZeroWidthSpaceRtcMember = mockCallMembership(
"@bob2:example.org",
"BBBB",
);
const bob = mockMatrixRoomMember(bobRtcMember, {
rawDisplayName: "Bob",
});
const bobZeroWidthSpace = mockMatrixRoomMember(
bobZeroWidthSpaceRtcMember,
{
rawDisplayName: "Bo\u200bb",
},
);
fakeMemberWith(bob);
fakeMemberWith(bobZeroWidthSpace);
fakeMemberWith({ userId: "@carol:example.org" });
const memberships$ = behavior("ab", {
a: [mockCallMembership("@carol:example.org", "1111"), bobRtcMember],
b: [
mockCallMembership("@carol:example.org", "1111"),
bobRtcMember,
bobZeroWidthSpaceRtcMember,
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const bob$ =
metadataStore.createDisplayNameBehavior$("@bob:example.org");
const bob2$ =
metadataStore.createDisplayNameBehavior$("@bob2:example.org");
const carol$ =
metadataStore.createDisplayNameBehavior$("@carol:example.org");
expectObservable(bob$).toBe("ab", {
a: "Bob",
b: "Bob (@bob:example.org)",
});
expectObservable(bob2$).toBe("ab", {
a: undefined,
b: "Bo\u200bb (@bob2:example.org)",
});
expectObservable(carol$).toBe("a-", {
a: "@carol:example.org",
});
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
// Carol has no displayname - So userId is used.
a: new Map([
["@carol:example.org", "@carol:example.org"],
["@bob:example.org", "Bob"],
]),
// Other Bob joins, and should handle zero width hacks.
b: new Map([
["@carol:example.org", "@carol:example.org"],
[bobRtcMember.userId, `Bob (@bob:example.org)`],
[
bobZeroWidthSpace.userId,
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
],
]),
});
});
});
it("should strip RTL characters from displayname", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const daveRtcMember = mockCallMembership("@dave:example.org", "DDDD");
const daveRTLRtcMember = mockCallMembership(
"@dave2:example.org",
"DDDD",
);
const dave = mockMatrixRoomMember(daveRtcMember, {
rawDisplayName: "Dave",
});
const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD",
});
fakeMemberWith({ userId: "@carol:example.org" });
fakeMemberWith(daveRTL);
fakeMemberWith(dave);
const memberships$ = behavior("ab", {
a: [mockCallMembership("@carol:example.org", "DDDD")],
b: [
mockCallMembership("@carol:example.org", "DDDD"),
daveRtcMember,
daveRTLRtcMember,
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
expectObservable(metadataStore.displaynameMap$).toBe("ab", {
// Carol has no displayname - So userId is used.
a: new Map([["@carol:example.org", "@carol:example.org"]]),
// Both Dave's join. Since after stripping
b: new Map([
["@carol:example.org", "@carol:example.org"],
// Not disambiguated
["@dave:example.org", "Dave"],
// This one is, since it's using RTL.
["@dave2:example.org", "evaD (@dave2:example.org)"],
]),
});
});
});
});
describe("avatarUrl", () => {
function updateAvatarUrl(
userId: `@${string}:${string}`,
avatarUrl: string,
): void {
const member = fakeMembersMap.get(userId);
if (member) {
member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
// Emit the event to notify listeners
mockMatrixRoom.emit(
RoomStateEvent.Members,
{} as unknown as MatrixEvent,
{} as unknown as RoomState,
member as RoomMember,
);
} else {
throw new Error(`No member found with userId: ${userId}`);
}
}
it("should use avatar url from room members", () => {
withTestScheduler(({ behavior, expectObservable }) => {
fakeMemberWith({
userId: "@local:example.com",
});
fakeMemberWith({
userId: "@alice:example.com",
getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
});
const memberships$ = behavior("a", {
a: [
mockCallMembership("@local:example.com", "DEVICE1"),
mockCallMembership("@alice:example.com", "DEVICE1"),
],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
const local$ =
metadataStore.createAvatarUrlBehavior$("@local:example.com");
const alice$ =
metadataStore.createAvatarUrlBehavior$("@alice:example.com");
expectObservable(local$).toBe("a", {
a: "mxc://example.com/@local:example.com",
});
expectObservable(alice$).toBe("a", {
a: "mxc://custom.url/avatar",
});
expectObservable(metadataStore.avatarMap$).toBe("a", {
a: new Map<string, string>([
["@local:example.com", "mxc://example.com/@local:example.com"],
["@alice:example.com", "mxc://custom.url/avatar"],
]),
});
});
});
it("should update on avatar change and user join/leave", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
fakeMemberWith({ userId: "@carl:example.com" });
fakeMemberWith({ userId: "@bob:example.com" });
const memberships$ = behavior("ab-d", {
a: [mockCallMembership("@bob:example.com", "B000")],
b: [
mockCallMembership("@bob:example.com", "B000"),
mockCallMembership("@carl:example.com", "C000"),
],
d: [mockCallMembership("@carl:example.com", "C000")],
});
const metadataStore = createMatrixMemberMetadata$(
testScope,
memberships$,
createRoomMembers$(testScope, mockMatrixRoom),
);
schedule("--c-", {
c: () => {
updateAvatarUrl(
"@carl:example.com",
"mxc://updated.me/updatedAvatar",
);
},
});
const bob$ = metadataStore.createAvatarUrlBehavior$("@bob:example.com");
const carl$ =
metadataStore.createAvatarUrlBehavior$("@carl:example.com");
expectObservable(bob$).toBe("a---", {
a: "mxc://example.com/@bob:example.com",
});
expectObservable(carl$).toBe("a-c-", {
a: "mxc://example.com/@carl:example.com",
c: "mxc://updated.me/updatedAvatar",
});
expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
a: new Map<string, string>([
["@bob:example.com", "mxc://example.com/@bob:example.com"],
["@carl:example.com", "mxc://example.com/@carl:example.com"],
]),
// expect an update once we update the avatar URL
c: new Map<string, string>([
["@bob:example.com", "mxc://example.com/@bob:example.com"],
["@carl:example.com", "mxc://updated.me/updatedAvatar"],
]),
});
});
});
});
});

View File

@@ -0,0 +1,180 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
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 {
KnownMembership,
type Room as MatrixRoom,
} from "matrix-js-sdk/lib/matrix";
// eslint-disable-next-line rxjs/no-internal
import { type ObservableScope } from "../../ObservableScope";
import {
calculateDisplayName,
shouldDisambiguate,
} from "../../../utils/displayname";
import { type Behavior } from "../../Behavior";
const logger = rootLogger.getChild("[MatrixMemberMetadata]");
export type RoomMemberMap = Map<
string,
Pick<RoomMember, "userId" | "getMxcAvatarUrl" | "rawDisplayName">
>;
export function roomToMembersMap(matrixRoom: MatrixRoom): RoomMemberMap {
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),
rawDisplayName: member.rawDisplayName,
});
return acc;
}, new Map());
}
export function createRoomMembers$(
scope: ObservableScope,
matrixRoom: MatrixRoom,
): Behavior<RoomMemberMap> {
return scope.behavior(
fromEvent(matrixRoom, RoomStateEvent.Members).pipe(
map(() => 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
* any displayname that clashes with another member. Only members
* joined to the call are considered here.
*
* @returns Map<userId, displayname> uses the Matrix user ID as the key.
*/
// don't do this work more times than we need to. This is achieved by converting to a behavior:
export const memberDisplaynames$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): Behavior<Map<string, string>> => {
// This map tracks userIds that at some point needed disambiguation.
// This is a memory leak bound to the number of participants.
// A call application will always increase the memory if there have been more members in a call.
// Its capped by room member participants.
const shouldDisambiguateTrackerMap = new Set<string>();
return scope.behavior(
combineLatest([
// Handle call membership changes
memberships$,
// Additionally handle display name changes (implicitly reacting to them)
roomMembers$,
// TODO: do we need: pauseWhen(this.pretendToBeDisconnected$),
]).pipe(
map(([memberships, roomMembers]) => {
const displaynameMap = new Map<string, string>();
// We only consider RTC members for disambiguation as they are the only visible members.
for (const rtcMember of memberships) {
const member = roomMembers.get(rtcMember.userId);
if (!member) {
logger.error(`Could not find member for user ${rtcMember.userId}`);
continue;
}
const disambiguateComputed = shouldDisambiguate(
member,
memberships,
roomMembers,
);
const disambiguate =
shouldDisambiguateTrackerMap.has(rtcMember.userId) ||
disambiguateComputed;
if (disambiguate) shouldDisambiguateTrackerMap.add(rtcMember.userId);
displaynameMap.set(
rtcMember.userId,
calculateDisplayName(member, disambiguate),
);
}
return displaynameMap;
}),
),
);
};
export const createMatrixMemberMetadata$ = (
scope: ObservableScope,
memberships$: Behavior<Pick<CallMembership, "userId">[]>,
roomMembers$: Behavior<RoomMemberMap>,
): {
createDisplayNameBehavior$: (userId: string) => Behavior<string | undefined>;
createAvatarUrlBehavior$: (userId: string) => Behavior<string | undefined>;
displaynameMap$: Behavior<Map<string, string>>;
avatarMap$: Behavior<Map<string, string | undefined>>;
} => {
const displaynameMap$ = memberDisplaynames$(
scope,
memberships$,
roomMembers$,
);
const avatarMap$ = scope.behavior(
roomMembers$.pipe(
map((roomMembers) =>
Array.from(roomMembers.keys()).reduce((acc, key) => {
acc.set(key, roomMembers.get(key)?.getMxcAvatarUrl());
return acc;
}, new Map<string, string | undefined>()),
),
),
);
return {
createDisplayNameBehavior$: (userId: string) =>
scope.behavior(
displaynameMap$.pipe(
map((displaynameMap) => displaynameMap.get(userId)),
),
),
createAvatarUrlBehavior$: (userId: string) =>
scope.behavior(
roomMembers$.pipe(
map((roomMembers) => roomMembers.get(userId)?.getMxcAvatarUrl()),
),
),
// mostly for testing purposes
displaynameMap$,
avatarMap$,
};
};

View File

@@ -0,0 +1,228 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test, vi, expect, beforeEach, afterEach } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type Room as LivekitRoom } from "livekit-client";
import EventEmitter from "events";
import fetchMock from "fetch-mock";
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type Epoch,
ObservableScope,
trackEpoch,
} from "../../ObservableScope.ts";
import { ECConnectionFactory } from "./ConnectionFactory.ts";
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
import {
mockCallMembership,
mockMediaDevices,
withTestScheduler,
} from "../../../utils/test.ts";
import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import {
areLivekitTransportsEqual,
createMatrixLivekitMembers$,
type MatrixLivekitMember,
} from "./MatrixLivekitMembers.ts";
import { createConnectionManager$ } from "./ConnectionManager.ts";
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
// Test the integration of ConnectionManager and MatrixLivekitMerger
let testScope: ObservableScope;
let ecConnectionFactory: ECConnectionFactory;
let mockClient: OpenIDClientParts;
let lkRoomFactory: () => LivekitRoom;
const createdMockLivekitRooms: Map<string, LivekitRoom> = new Map();
beforeEach(() => {
testScope = new ObservableScope();
mockClient = {
getOpenIdToken: vi.fn().mockReturnValue(""),
getDeviceId: vi.fn().mockReturnValue("DEV000"),
};
lkRoomFactory = vi.fn().mockImplementation(() => {
const emitter = new EventEmitter();
const base = {
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
disconnect: vi.fn(),
remoteParticipants: new Map(),
} as unknown as LivekitRoom;
vi.mocked(base).connect = vi.fn().mockImplementation(({ url }) => {
createdMockLivekitRooms.set(url, base);
});
return base;
});
ecConnectionFactory = new ECConnectionFactory(
mockClient,
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
lkRoomFactory,
);
//TODO a bit annoying to have to do a http mock?
fetchMock.post(`path:/sfu/get`, (url) => {
const domain = new URL(url).hostname; // Extract the domain from the URL
return {
status: 200,
body: {
url: `wss://${domain}/livekit/sfu`,
jwt: "ATOKEN",
},
};
});
});
afterEach(() => {
testScope.end();
fetchMock.reset();
});
test("bob, carl, then bob joining no tracks yet", () => {
withTestScheduler(({ expectObservable, behavior, scope }) => {
const bobMembership = mockCallMembership("@bob:example.com", "BDEV000");
const carlMembership = mockCallMembership("@carl:example.com", "CDEV000");
const daveMembership = mockCallMembership("@dave:foo.bar", "DDEV000");
const eMarble = "abc";
const vMarble = "abc";
const memberships$ = scope.behavior(
behavior(eMarble, {
a: [bobMembership],
b: [bobMembership, carlMembership],
c: [bobMembership, carlMembership, daveMembership],
}).pipe(trackEpoch()),
);
const membershipsAndTransports = membershipsAndTransports$(
testScope,
memberships$,
);
const connectionManager = createConnectionManager$({
scope: testScope,
connectionFactory: ecConnectionFactory,
inputTransports$: membershipsAndTransports.transports$,
logger: logger,
});
const matrixLivekitItems$ = createMatrixLivekitMembers$({
scope: testScope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionManager,
});
expectObservable(matrixLivekitItems$).toBe(vMarble, {
a: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
const items = e.value;
expect(items.length).toBe(1);
const item = items[0]!;
expectObservable(item.membership$).toBe("a", {
a: bobMembership,
});
expectObservable(item.connection$).toBe("a", {
a: expect.toSatisfy((co) =>
areLivekitTransportsEqual(
co.transport,
bobMembership.transports[0]! as LivekitTransport,
),
),
});
expectObservable(item.participant$).toBe("a", {
a: null,
});
return true;
}),
b: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
const items = e.value;
expect(items.length).toBe(2);
{
const item = items[0]!;
expectObservable(item.membership$).toBe("a", {
a: bobMembership,
});
expectObservable(item.participant$).toBe("a", {
a: null,
});
}
{
const item = items[1]!;
expectObservable(item.membership$).toBe("a", {
a: carlMembership,
});
expectObservable(item.participant$).toBe("a", {
a: null,
});
expectObservable(item.connection$).toBe("a", {
a: expect.toSatisfy((connection) => {
expect(
areLivekitTransportsEqual(
connection.transport,
carlMembership.transports[0]! as LivekitTransport,
),
).toBe(true);
return true;
}),
});
}
return true;
}),
c: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
const items = e.value;
expect(items.length).toBe(3);
expectObservable(items[0].membership$).toBe("a", {
a: bobMembership,
});
expectObservable(items[1].membership$).toBe("b", {
a: carlMembership,
});
{
const item = items[2]!;
expectObservable(item.membership$).toBe("a", {
a: daveMembership,
});
expectObservable(item.connection$).toBe("a", {
a: expect.toSatisfy((connection) => {
expect(
areLivekitTransportsEqual(
connection.transport,
daveMembership.transports[0]! as LivekitTransport,
),
).toBe(true);
return true;
}),
});
expectObservable(item.participant$).toBe("a", {
a: null,
});
}
return true;
}),
x: expect.anything(),
});
});
});

View File

@@ -5,15 +5,18 @@ Copyright 2025 Element Creations Ltd.
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, expect } from "vitest"; import { it, vi, expect } from "vitest";
import EventEmitter from "events"; import EventEmitter from "events";
// import * as ComponentsCore from "@livekit/components-core";
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
import { constant } from "./Behavior.ts"; import { constant } from "./Behavior.ts";
import { withCallViewModel } from "./CallViewModel.test.ts";
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts"; import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
import { ElementWidgetActions, widget } from "../widget.ts"; import { ElementWidgetActions, widget } from "../widget.ts";
import { E2eeType } from "../e2ee/e2eeType.ts"; import { E2eeType } from "../e2ee/e2eeType.ts";
import { type CallViewModel } from "./CallViewModel.ts";
vi.mock("@livekit/components-core", { spy: true });
vi.mock("../widget", () => ({ vi.mock("../widget", () => ({
ElementWidgetActions: { ElementWidgetActions: {
@@ -31,7 +34,7 @@ vi.mock("../widget", () => ({
}, },
})); }));
test("expect leave when ElementWidgetActions.HangupCall is called", async () => { it("expect leave when ElementWidgetActions.HangupCall is called", async () => {
const pr = Promise.withResolvers<string>(); const pr = Promise.withResolvers<string>();
withCallViewModel( withCallViewModel(
{ {

View File

@@ -27,7 +27,6 @@ import {
RoomEvent as LivekitRoomEvent, RoomEvent as LivekitRoomEvent,
RemoteTrack, RemoteTrack,
} from "livekit-client"; } from "livekit-client";
import { type RoomMember } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { import {
BehaviorSubject, BehaviorSubject,
@@ -44,6 +43,7 @@ import {
startWith, startWith,
switchMap, switchMap,
throttleTime, throttleTime,
distinctUntilChanged,
} from "rxjs"; } from "rxjs";
import { alwaysShowSelf } from "../settings/settings"; import { alwaysShowSelf } from "../settings/settings";
@@ -180,29 +180,35 @@ function observeRemoteTrackReceivingOkay$(
} }
function encryptionErrorObservable$( function encryptionErrorObservable$(
room: LivekitRoom, room$: Behavior<LivekitRoom | undefined>,
participant: Participant, participant: Participant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
criteria: string, criteria: string,
): Observable<boolean> { ): Observable<boolean> {
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe( return room$.pipe(
map((e) => { switchMap((room) => {
const [err] = e; if (room === undefined) return of(false);
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
return ( map((e) => {
// Ideally we would pull the participant identity from the field on the error. const [err] = e;
// However, it gets lost in the serialization process between workers. if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
// So, instead we do a string match return (
(err?.message.includes(participant.identity) && // Ideally we would pull the participant identity from the field on the error.
err?.message.includes(criteria)) ?? // However, it gets lost in the serialization process between workers.
false // So, instead we do a string match
); (err?.message.includes(participant.identity) &&
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) { err?.message.includes(criteria)) ??
return !!err?.message.includes(criteria); false
} );
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
return !!err?.message.includes(criteria);
}
return false; return false;
}),
);
}), }),
distinctUntilChanged(),
throttleTime(1000), // Throttle to avoid spamming the UI throttleTime(1000), // Throttle to avoid spamming the UI
startWith(false), startWith(false),
); );
@@ -220,7 +226,7 @@ abstract class BaseMediaViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>; public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -235,12 +241,10 @@ abstract class BaseMediaViewModel {
private observeTrackReference$( private observeTrackReference$(
source: Track.Source, source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | undefined> { ): Behavior<TrackReferenceOrPlaceholder | null> {
return this.scope.behavior( return this.scope.behavior(
this.participant$.pipe( this.participant$.pipe(
switchMap((p) => switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))),
p === undefined ? of(undefined) : observeTrackReference$(p, source),
),
), ),
); );
} }
@@ -252,23 +256,22 @@ abstract class BaseMediaViewModel {
*/ */
public readonly id: string, public readonly id: string,
/** /**
* The Matrix room member to which this media belongs. * The Matrix user to which this media belongs.
*/ */
// TODO: Fully separate the data layer from the UI layer by keeping the public readonly userId: string,
// member object internal
public readonly member: RoomMember,
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit. // livekit.
protected readonly participant$: Observable< protected readonly participant$: Observable<
LocalParticipant | RemoteParticipant | undefined LocalParticipant | RemoteParticipant | null
>, >,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom: LivekitRoom, livekitRoom$: Behavior<LivekitRoom | undefined>,
public readonly focusURL: string, public readonly focusUrl$: Behavior<string | undefined>,
public readonly displayName$: Behavior<string>, public readonly displayName$: Behavior<string>,
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
) { ) {
const audio$ = this.observeTrackReference$(audioSource); const audio$ = this.observeTrackReference$(audioSource);
this.video$ = this.observeTrackReference$(videoSource); this.video$ = this.observeTrackReference$(videoSource);
@@ -296,13 +299,13 @@ abstract class BaseMediaViewModel {
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
return combineLatest([ return combineLatest([
encryptionErrorObservable$( encryptionErrorObservable$(
livekitRoom, livekitRoom$,
participant, participant,
encryptionSystem, encryptionSystem,
"MissingKey", "MissingKey",
), ),
encryptionErrorObservable$( encryptionErrorObservable$(
livekitRoom, livekitRoom$,
participant, participant,
encryptionSystem, encryptionSystem,
"InvalidKey", "InvalidKey",
@@ -322,7 +325,7 @@ abstract class BaseMediaViewModel {
} else { } else {
return combineLatest([ return combineLatest([
encryptionErrorObservable$( encryptionErrorObservable$(
livekitRoom, livekitRoom$,
participant, participant,
encryptionSystem, encryptionSystem,
"InvalidKey", "InvalidKey",
@@ -404,26 +407,28 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
public constructor( public constructor(
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
member: RoomMember, userId: string,
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>, participant$: Observable<LocalParticipant | RemoteParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl: string, focusUrl$: Behavior<string | undefined>,
displayName$: Behavior<string>, displayName$: Behavior<string>,
mxcAvatarUrl$: Behavior<string | undefined>,
public readonly handRaised$: Behavior<Date | null>, public readonly handRaised$: Behavior<Date | null>,
public readonly reaction$: Behavior<ReactionOption | null>, public readonly reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
scope, scope,
id, id,
member, userId,
participant$, participant$,
encryptionSystem, encryptionSystem,
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom, livekitRoom$,
focusUrl, focusUrl$,
displayName$, displayName$,
mxcAvatarUrl$,
); );
const media$ = this.scope.behavior( const media$ = this.scope.behavior(
@@ -540,25 +545,27 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
member: RoomMember, userId: string,
participant$: Behavior<LocalParticipant | undefined>, participant$: Behavior<LocalParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusURL: string, focusUrl$: Behavior<string | undefined>,
private readonly mediaDevices: MediaDevices, private readonly mediaDevices: MediaDevices,
displayName$: Behavior<string>, displayName$: Behavior<string>,
mxcAvatarUrl$: Behavior<string | undefined>,
handRaised$: Behavior<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
scope, scope,
id, id,
member, userId,
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom$,
focusURL, focusUrl$,
displayName$, displayName$,
mxcAvatarUrl$,
handRaised$, handRaised$,
reaction$, reaction$,
); );
@@ -650,25 +657,27 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
member: RoomMember, userId: string,
participant$: Observable<RemoteParticipant | undefined>, participant$: Observable<RemoteParticipant | null>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl: string, focusUrl$: Behavior<string | undefined>,
private readonly pretendToBeDisconnected$: Behavior<boolean>, private readonly pretendToBeDisconnected$: Behavior<boolean>,
displayname$: Behavior<string>, displayName$: Behavior<string>,
mxcAvatarUrl$: Behavior<string | undefined>,
handRaised$: Behavior<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
scope, scope,
id, id,
member, userId,
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom$,
focusUrl, focusUrl$,
displayname$, displayName$,
mxcAvatarUrl$,
handRaised$, handRaised$,
reaction$, reaction$,
); );
@@ -749,26 +758,28 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor( public constructor(
scope: ObservableScope, scope: ObservableScope,
id: string, id: string,
member: RoomMember, userId: string,
participant$: Observable<LocalParticipant | RemoteParticipant>, participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl: string, focusUrl$: Behavior<string | undefined>,
private readonly pretendToBeDisconnected$: Behavior<boolean>, private readonly pretendToBeDisconnected$: Behavior<boolean>,
displayname$: Behavior<string>, displayName$: Behavior<string>,
mxcAvatarUrl$: Behavior<string | undefined>,
public readonly local: boolean, public readonly local: boolean,
) { ) {
super( super(
scope, scope,
id, id,
member, userId,
participant$, participant$,
encryptionSystem, encryptionSystem,
Track.Source.ScreenShareAudio, Track.Source.ScreenShareAudio,
Track.Source.ScreenShare, Track.Source.ScreenShare,
livekitRoom, livekitRoom$,
focusUrl, focusUrl$,
displayname$, displayName$,
mxcAvatarUrl$,
); );
} }
} }

View File

@@ -0,0 +1,104 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { BehaviorSubject, combineLatest, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import {
Epoch,
mapEpoch,
ObservableScope,
trackEpoch,
} from "./ObservableScope";
import { withTestScheduler } from "../utils/test";
describe("Epoch", () => {
it("should map the value correctly", () => {
const epoch = new Epoch(1);
const mappedEpoch = epoch.mapInner((v) => v + 1);
expect(mappedEpoch.value).toBe(2);
expect(mappedEpoch.epoch).toBe(0);
});
it("should be tracked from an observable", () => {
withTestScheduler(({ expectObservable, behavior }) => {
const observable$ = behavior("abc", {
a: 1,
b: 2,
c: 3,
});
const epochObservable$ = observable$.pipe(trackEpoch());
expectObservable(epochObservable$).toBe("abc", {
a: expect.toSatisfy((e) => e.epoch === 0 && e.value === 1),
b: expect.toSatisfy((e) => e.epoch === 1 && e.value === 2),
c: expect.toSatisfy((e) => e.epoch === 2 && e.value === 3),
});
});
});
it("can be mapped without loosing epoch information", () => {
withTestScheduler(({ expectObservable, behavior }) => {
const observable$ = behavior("abc", {
a: "A",
b: "B",
c: "C",
});
const epochObservable$ = observable$.pipe(trackEpoch());
const derivedEpoch$ = epochObservable$.pipe(
mapEpoch((e) => e + "-mapped"),
);
expectObservable(derivedEpoch$).toBe("abc", {
a: new Epoch("A-mapped", 0),
b: new Epoch("B-mapped", 1),
c: new Epoch("C-mapped", 2),
});
});
});
it("diamonds emits in a predictable order", () => {
const sb$ = new BehaviorSubject("initial");
const root$ = sb$.pipe(trackEpoch());
const derivedA$ = root$.pipe(mapEpoch((e) => e + "-A"));
const derivedB$ = root$.pipe(mapEpoch((e) => e + "-B"));
combineLatest([root$, derivedB$, derivedA$]).subscribe(
([root, derivedA, derivedB]) => {
logger.log(
"combined" +
root.epoch +
root.value +
"\n" +
derivedA.epoch +
derivedA.value +
"\n" +
derivedB.epoch +
derivedB.value,
);
},
);
sb$.next("updated");
sb$.next("ANOTERUPDATE");
});
it("behavior test", () => {
const scope = new ObservableScope();
const s$ = new Subject();
const behavior$ = scope.behavior(s$, 0);
behavior$.subscribe((value) => {
logger.log(`Received value: ${value}`);
});
s$.next(1);
s$.next(2);
s$.next(3);
s$.next(3);
s$.next(3);
s$.next(3);
s$.next(3);
s$.complete();
});
});

View File

@@ -12,7 +12,9 @@ import {
EMPTY, EMPTY,
endWith, endWith,
filter, filter,
map,
type Observable, type Observable,
type OperatorFunction,
share, share,
take, take,
takeUntil, takeUntil,
@@ -22,6 +24,10 @@ import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>; type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
type SplitBehavior<T> = keyof T extends string | number
? { [K in keyof T as `${K}$`]: Behavior<T[K]> }
: never;
const nothing = Symbol("nothing"); const nothing = Symbol("nothing");
/** /**
@@ -145,9 +151,132 @@ export class ObservableScope {
})(); })();
}); });
} }
/**
* Splits a Behavior of objects with static properties into an object with
* Behavior properties.
*
* For example, splitting a `Behavior<{ name: string; age: number }>` results
* in an object of type `{ name$: Behavior<string>; age$: Behavior<number> }`.
*/
public splitBehavior<T extends object>(
input$: Behavior<T>,
): SplitBehavior<T> {
return Object.fromEntries(
Object.keys(input$.value).map((key) => [
`${key}$`,
this.behavior(input$.pipe(map((input) => input[key as keyof T]))),
]),
) as SplitBehavior<T>;
}
} }
/** /**
* The global scope, a scope which never ends. * The global scope, a scope which never ends.
*/ */
export const globalScope = new ObservableScope(); export const globalScope = new ObservableScope();
/**
* `Epoch`'s can be used to create `Behavior`s and `Observable`s which derivitives can be merged
* with `combinedLatest` without duplicated emissions.
*
* This is useful in the following example:
* ```
* const rootObs$ = of("red","green","blue");
* const derivedObs$ = rootObs$.pipe(
* map((v)=> {red:"fire", green:"grass", blue:"water"}[v])
* );
* const otherDerivedObs$ = rootObs$.pipe(
* map((v)=> {red:"tomatoes", green:"leaves", blue:"sky"}[v])
* );
* const mergedObs$ = combineLatest([rootObs$, derivedObs$, otherDerivedObs$]).pipe(
* map(([color, a,b]) => color + " like " + a + " and " + b)
* );
*
* ```
* will result in 6 emissions with mismatching items like "red like fire and leaves"
*
* # Use Epoch
* ```
* const ancestorObs$ = of(1,2,3).pipe(trackEpoch());
* const derivedObs$ = ancestorObs$.pipe(
* mapEpoch((v)=> "this number: " + v)
* );
* const otherDerivedObs$ = ancestorObs$.pipe(
* mapEpoch((v)=> "multiplied by: " + v)
* );
* const mergedObs$ = combineLatest([derivedObs$, otherDerivedObs$]).pipe(
* filter((values) => values.every((v) => v.epoch === values[0].v)),
* map(([color, a, b]) => color + " like " + a + " and " + b)
* );
*
* ```
* will result in 3 emissions all matching (e.g. "blue like water and sky")
*/
export class Epoch<T> {
public readonly epoch: number;
public readonly value: T;
public constructor(value: T, epoch?: number) {
this.value = value;
this.epoch = epoch ?? 0;
}
/**
* Maps the value inside the epoch to a new value while keeping the epoch number.
* # usage
* ```
* const myEpoch$ = myObservable$.pipe(
* map(trackEpoch()),
* // this is the preferred way using mapEpoch
* mapEpoch((v)=> v+1)
* // This is how inner map can be used:
* map((epoch) => epoch.innerMap((v)=> v+1))
* // It is equivalent to:
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
* )
* ```
* See also `Epoch<T>`
*/
public mapInner<U>(map: (value: T) => U): Epoch<U> {
return new Epoch<U>(map(this.value), this.epoch);
}
}
/**
* A `pipe` compatible map oparator that keeps the epoch in tact but allows mapping the value.
* # usage
* ```
* const myEpoch$ = myObservable$.pipe(
* map(trackEpoch()),
* // this is the preferred way using mapEpoch
* mapEpoch((v)=> v+1)
* // This is how inner map can be used:
* map((epoch) => epoch.innerMap((v)=> v+1))
* // It is equivalent to:
* map((epoch) => new Epoch(epoch.value + 1, epoch.epoch))
* )
* ```
* See also `Epoch<T>`
*/
export function mapEpoch<T, U>(
mapFn: (value: T) => U,
): OperatorFunction<Epoch<T>, Epoch<U>> {
return map((e) => e.mapInner(mapFn));
}
/**
* # usage
* ```
* const myEpoch$ = myObservable$.pipe(
* map(trackEpoch()),
* map((epoch) => epoch.innerMap((v)=> v+1))
* )
* const derived = myEpoch$.pipe(
* mapEpoch((v)=>v^2)
* )
* ```
* See also `Epoch<T>`
*/
export function trackEpoch<T>(): OperatorFunction<T, Epoch<T>> {
return map<T, Epoch<T>>((value, number) => new Epoch(value, number));
}

View File

@@ -4,7 +4,7 @@ Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { of, type Observable } from "rxjs"; import { of } from "rxjs";
import { import {
type LocalParticipant, type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
@@ -13,7 +13,6 @@ import {
import { type ObservableScope } from "./ObservableScope.ts"; import { type ObservableScope } from "./ObservableScope.ts";
import { ScreenShareViewModel } from "./MediaViewModel.ts"; import { ScreenShareViewModel } from "./MediaViewModel.ts";
import type { RoomMember } from "matrix-js-sdk";
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
import type { Behavior } from "./Behavior.ts"; import type { Behavior } from "./Behavior.ts";
@@ -28,24 +27,26 @@ export class ScreenShare {
public constructor( public constructor(
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
id: string, id: string,
member: RoomMember, userId: string,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom$: Behavior<LivekitRoom | undefined>,
focusUrl: string, focusUrl$: Behavior<string | undefined>,
pretendToBeDisconnected$: Behavior<boolean>, pretendToBeDisconnected$: Behavior<boolean>,
displayName$: Observable<string>, displayName$: Behavior<string>,
mxcAvatarUrl$: Behavior<string | undefined>,
) { ) {
this.vm = new ScreenShareViewModel( this.vm = new ScreenShareViewModel(
this.scope, this.scope,
id, id,
member, userId,
of(participant), of(participant),
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom$,
focusUrl, focusUrl$,
pretendToBeDisconnected$, pretendToBeDisconnected$,
this.scope.behavior(displayName$), displayName$,
mxcAvatarUrl$,
participant.isLocal, participant.isLocal,
); );
} }

View File

@@ -0,0 +1,81 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type CallMembership,
isLivekitTransport,
type LivekitTransport,
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { fromEvent } from "rxjs";
import {
Epoch,
mapEpoch,
trackEpoch,
type ObservableScope,
} from "./ObservableScope";
import { type Behavior } from "./Behavior";
export const membershipsAndTransports$ = (
scope: ObservableScope,
memberships$: Behavior<Epoch<CallMembership[]>>,
): {
membershipsWithTransport$: Behavior<
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
>;
transports$: Behavior<Epoch<LivekitTransport[]>>;
} => {
/**
* Lists the transports used by ourselves, plus all other MatrixRTC session
* members. For completeness this also lists the preferred transport and
* whether we are in multi-SFU mode or sticky events mode (because
* advertisedTransport$ wants to read them at the same time, and bundling data
* together when it might change together is what you have to do in RxJS to
* avoid reading inconsistent state or observing too many changes.)
*/
const membershipsWithTransport$ = scope.behavior(
memberships$.pipe(
mapEpoch((memberships) => {
return memberships.map((membership) => {
const oldestMembership = memberships[0] ?? membership;
const transport = membership.getTransport(oldestMembership);
return {
membership,
transport: isLivekitTransport(transport) ? transport : undefined,
};
});
}),
),
);
const transports$ = scope.behavior(
membershipsWithTransport$.pipe(
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
),
);
return {
membershipsWithTransport$,
transports$,
};
};
export const createMemberships$ = (
scope: ObservableScope,
matrixRTCSession: MatrixRTCSession,
): Behavior<Epoch<CallMembership[]>> => {
return scope.behavior(
fromEvent(
matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
(_, memberships: CallMembership[]) => memberships,
).pipe(trackEpoch()),
new Epoch(matrixRTCSession.memberships),
);
};

View File

@@ -14,7 +14,7 @@ import { fillGaps } from "../utils/iter";
import { debugTileLayout } from "../settings/settings"; import { debugTileLayout } from "../settings/settings";
function debugEntries(entries: GridTileData[]): string[] { function debugEntries(entries: GridTileData[]): string[] {
return entries.map((e) => e.media.member?.rawDisplayName ?? "[👻]"); return entries.map((e) => e.media.displayName$.value);
} }
let DEBUG_ENABLED = false; let DEBUG_ENABLED = false;
@@ -156,7 +156,7 @@ export class TileStoreBuilder {
public registerSpotlight(media: MediaViewModel[], maximised: boolean): void { public registerSpotlight(media: MediaViewModel[], maximised: boolean): void {
if (DEBUG_ENABLED) if (DEBUG_ENABLED)
logger.debug( logger.debug(
`[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.member?.rawDisplayName ?? "[👻]")}`, `[TileStore, ${this.generation}] register spotlight: ${media.map((m) => m.displayName$.value)}`,
); );
if (this.spotlight !== null) throw new Error("Spotlight already set"); if (this.spotlight !== null) throw new Error("Spotlight already set");
@@ -180,7 +180,7 @@ export class TileStoreBuilder {
public registerGridTile(media: UserMediaViewModel): void { public registerGridTile(media: UserMediaViewModel): void {
if (DEBUG_ENABLED) if (DEBUG_ENABLED)
logger.debug( logger.debug(
`[TileStore, ${this.generation}] register grid tile: ${media.member?.rawDisplayName ?? "[👻]"}`, `[TileStore, ${this.generation}] register grid tile: ${media.displayName$.value}`,
); );
if (this.spotlight !== null) { if (this.spotlight !== null) {
@@ -263,7 +263,7 @@ export class TileStoreBuilder {
public registerPipTile(media: UserMediaViewModel): void { public registerPipTile(media: UserMediaViewModel): void {
if (DEBUG_ENABLED) if (DEBUG_ENABLED)
logger.debug( logger.debug(
`[TileStore, ${this.generation}] register PiP tile: ${media.member?.rawDisplayName ?? "[👻]"}`, `[TileStore, ${this.generation}] register PiP tile: ${media.displayName$.value}`,
); );
// If there is a single grid tile that we can reuse // If there is a single grid tile that we can reuse

View File

@@ -5,17 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import { combineLatest, map, type Observable, of, switchMap } from "rxjs";
BehaviorSubject,
combineLatest,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { import {
type LocalParticipant, type LocalParticipant,
type Participant,
ParticipantEvent, ParticipantEvent,
type RemoteParticipant, type RemoteParticipant,
type Room as LivekitRoom, type Room as LivekitRoom,
@@ -29,11 +21,12 @@ import {
type UserMediaViewModel, type UserMediaViewModel,
} from "./MediaViewModel.ts"; } from "./MediaViewModel.ts";
import type { Behavior } from "./Behavior.ts"; import type { Behavior } from "./Behavior.ts";
import type { RoomMember } from "matrix-js-sdk";
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts"; import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
import type { MediaDevices } from "./MediaDevices.ts"; import type { MediaDevices } from "./MediaDevices.ts";
import type { ReactionOption } from "../reactions"; import type { ReactionOption } from "../reactions";
import { observeSpeaker$ } from "./observeSpeaker.ts"; import { observeSpeaker$ } from "./observeSpeaker.ts";
import { generateItems } from "../utils/observable.ts";
import { ScreenShare } from "./ScreenShare.ts";
/** /**
* Sorting bins defining the order in which media tiles appear in the layout. * Sorting bins defining the order in which media tiles appear in the layout.
@@ -72,35 +65,35 @@ enum SortingBin {
/** /**
* A user media item to be presented in a tile. This is a thin wrapper around * A user media item to be presented in a tile. This is a thin wrapper around
* UserMediaViewModel which additionally determines the media item's sorting bin * UserMediaViewModel which additionally determines the media item's sorting bin
* for inclusion in the call layout. * for inclusion in the call layout and tracks associated screen shares.
*/ */
export class UserMedia { export class UserMedia {
private readonly participant$ = new BehaviorSubject(this.initialParticipant);
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal
? new LocalUserMediaViewModel( ? new LocalUserMediaViewModel(
this.scope, this.scope,
this.id, this.id,
this.member, this.userId,
this.participant$ as Behavior<LocalParticipant>, this.participant$ as Behavior<LocalParticipant | null>,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom$,
this.focusURL, this.focusUrl$,
this.mediaDevices, this.mediaDevices,
this.scope.behavior(this.displayname$), this.displayName$,
this.mxcAvatarUrl$,
this.scope.behavior(this.handRaised$), this.scope.behavior(this.handRaised$),
this.scope.behavior(this.reaction$), this.scope.behavior(this.reaction$),
) )
: new RemoteUserMediaViewModel( : new RemoteUserMediaViewModel(
this.scope, this.scope,
this.id, this.id,
this.member, this.userId,
this.participant$ as Observable<RemoteParticipant | undefined>, this.participant$ as Behavior<RemoteParticipant | null>,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom$,
this.focusURL, this.focusUrl$,
this.pretendToBeDisconnected$, this.pretendToBeDisconnected$,
this.scope.behavior(this.displayname$), this.displayName$,
this.mxcAvatarUrl$,
this.scope.behavior(this.handRaised$), this.scope.behavior(this.handRaised$),
this.scope.behavior(this.reaction$), this.scope.behavior(this.reaction$),
); );
@@ -109,12 +102,55 @@ export class UserMedia {
observeSpeaker$(this.vm.speaking$), observeSpeaker$(this.vm.speaking$),
); );
private readonly presenter$ = this.scope.behavior( /**
* All screen share media associated with this user media.
*/
public readonly screenShares$ = this.scope.behavior(
this.participant$.pipe( this.participant$.pipe(
switchMap((p) => (p === undefined ? of(false) : sharingScreen$(p))), switchMap((p) =>
p === null
? of([])
: observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(
// Technically more than one screen share might be possible... our
// MediaViewModels don't support it though since they look for a unique
// track for the given source. So generateItems here is a bit overkill.
generateItems(
function* (p) {
if (p.isScreenShareEnabled)
yield {
keys: ["screen-share"],
data: undefined,
};
},
(scope, _data$, key) =>
new ScreenShare(
scope,
`${this.id}:${key}`,
this.userId,
p,
this.encryptionSystem,
this.livekitRoom$,
this.focusUrl$,
this.pretendToBeDisconnected$,
this.displayName$,
this.mxcAvatarUrl$,
),
),
),
),
), ),
); );
private readonly presenter$ = this.scope.behavior(
this.screenShares$.pipe(map((screenShares) => screenShares.length > 0)),
);
/** /**
* Which sorting bin the media item should be placed in. * Which sorting bin the media item should be placed in.
*/ */
@@ -147,37 +183,18 @@ export class UserMedia {
public constructor( public constructor(
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
public readonly id: string, public readonly id: string,
private readonly member: RoomMember, private readonly userId: string,
private readonly initialParticipant: private readonly participant$: Behavior<
| LocalParticipant LocalParticipant | RemoteParticipant | null
| RemoteParticipant >,
| undefined,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
private readonly focusURL: string, private readonly focusUrl$: Behavior<string | undefined>,
private readonly mediaDevices: MediaDevices, private readonly mediaDevices: MediaDevices,
private readonly pretendToBeDisconnected$: Behavior<boolean>, private readonly pretendToBeDisconnected$: Behavior<boolean>,
private readonly displayname$: Observable<string>, private readonly displayName$: Behavior<string>,
private readonly mxcAvatarUrl$: Behavior<string | undefined>,
private readonly handRaised$: Observable<Date | null>, private readonly handRaised$: Observable<Date | null>,
private readonly reaction$: Observable<ReactionOption | null>, private readonly reaction$: Observable<ReactionOption | null>,
) {} ) {}
public updateParticipant(
newParticipant: LocalParticipant | RemoteParticipant | undefined,
): void {
if (this.participant$.value !== newParticipant) {
// Update the BehaviourSubject in the UserMedia.
this.participant$.next(newParticipant);
}
}
}
export function sharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled));
} }

View File

@@ -15,7 +15,7 @@ import { GridTile } from "./GridTile";
import { mockRtcMembership, createRemoteMedia } from "../utils/test"; import { mockRtcMembership, createRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {

View File

@@ -58,7 +58,9 @@ interface TileProps {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
focusUrl: string | undefined;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined;
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
focusable: boolean; focusable: boolean;
} }
@@ -81,7 +83,9 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
menuStart, menuStart,
menuEnd, menuEnd,
className, className,
focusUrl,
displayName, displayName,
mxcAvatarUrl,
focusable, focusable,
...props ...props
}) => { }) => {
@@ -144,8 +148,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
const tile = ( const tile = (
<MediaView <MediaView
ref={ref} ref={ref}
video={video} video={video ?? undefined}
member={vm.member} userId={vm.userId}
unencryptedWarning={unencryptedWarning} unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus} encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled} videoEnabled={videoEnabled}
@@ -164,6 +168,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
/> />
} }
displayName={displayName} displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
focusable={focusable} focusable={focusable}
primaryButton={ primaryButton={
primaryButton ?? ( primaryButton ?? (
@@ -190,7 +195,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
currentReaction={reaction ?? undefined} currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local} localParticipant={vm.local}
focusUrl={vm.focusURL} focusUrl={focusUrl}
audioStreamStats={audioStreamStats} audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats} videoStreamStats={videoStreamStats}
{...props} {...props}
@@ -359,7 +364,9 @@ export const GridTile: FC<GridTileProps> = ({
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const media = useBehavior(vm.media$); const media = useBehavior(vm.media$);
const focusUrl = useBehavior(media.focusUrl$);
const displayName = useBehavior(media.displayName$); const displayName = useBehavior(media.displayName$);
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
if (media instanceof LocalUserMediaViewModel) { if (media instanceof LocalUserMediaViewModel) {
return ( return (
@@ -367,7 +374,9 @@ export const GridTile: FC<GridTileProps> = ({
ref={ref} ref={ref}
vm={media} vm={media}
onOpenProfile={onOpenProfile} onOpenProfile={onOpenProfile}
focusUrl={focusUrl}
displayName={displayName} displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
{...props} {...props}
/> />
); );
@@ -376,7 +385,9 @@ export const GridTile: FC<GridTileProps> = ({
<RemoteUserMediaTile <RemoteUserMediaTile
ref={ref} ref={ref}
vm={media} vm={media}
focusUrl={focusUrl}
displayName={displayName} displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
{...props} {...props}
/> />
); );

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, it, test, vi } from "vitest"; import { describe, expect, it, test } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -16,7 +16,6 @@ import {
import { LocalTrackPublication, Track } from "livekit-client"; import { LocalTrackPublication, Track } from "livekit-client";
import { TrackInfo } from "@livekit/protocol"; import { TrackInfo } from "@livekit/protocol";
import { type ComponentProps } from "react"; import { type ComponentProps } from "react";
import { type RoomMember } from "matrix-js-sdk";
import { MediaView } from "./MediaView"; import { MediaView } from "./MediaView";
import { EncryptionStatus } from "../state/MediaViewModel"; import { EncryptionStatus } from "../state/MediaViewModel";
@@ -46,10 +45,8 @@ describe("MediaView", () => {
mirror: false, mirror: false,
unencryptedWarning: false, unencryptedWarning: false,
video: trackReference, video: trackReference,
member: vi.mocked<RoomMember>({ userId: "@alice:example.com",
userId: "@alice:example.com", mxcAvatarUrl: undefined,
getMxcAvatarUrl: vi.fn().mockReturnValue(undefined),
} as unknown as RoomMember),
localParticipant: false, localParticipant: false,
focusable: true, focusable: true,
}; };

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { type RoomMember } from "matrix-js-sdk";
import { type FC, type ComponentProps, type ReactNode } from "react"; import { type FC, type ComponentProps, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
@@ -32,12 +31,13 @@ interface Props extends ComponentProps<typeof animated.div> {
video: TrackReferenceOrPlaceholder | undefined; video: TrackReferenceOrPlaceholder | undefined;
videoFit: "cover" | "contain"; videoFit: "cover" | "contain";
mirror: boolean; mirror: boolean;
member: RoomMember; userId: string;
videoEnabled: boolean; videoEnabled: boolean;
unencryptedWarning: boolean; unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus; encryptionStatus: EncryptionStatus;
nameTagLeadingIcon?: ReactNode; nameTagLeadingIcon?: ReactNode;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined;
focusable: boolean; focusable: boolean;
primaryButton?: ReactNode; primaryButton?: ReactNode;
raisedHandTime?: Date; raisedHandTime?: Date;
@@ -59,11 +59,12 @@ export const MediaView: FC<Props> = ({
video, video,
videoFit, videoFit,
mirror, mirror,
member, userId,
videoEnabled, videoEnabled,
unencryptedWarning, unencryptedWarning,
nameTagLeadingIcon, nameTagLeadingIcon,
displayName, displayName,
mxcAvatarUrl,
focusable, focusable,
primaryButton, primaryButton,
encryptionStatus, encryptionStatus,
@@ -94,10 +95,10 @@ export const MediaView: FC<Props> = ({
> >
<div className={styles.bg}> <div className={styles.bg}>
<Avatar <Avatar
id={member?.userId ?? displayName} id={userId}
name={displayName} name={displayName}
size={avatarSize} size={avatarSize}
src={member?.getMxcAvatarUrl()} src={mxcAvatarUrl}
className={styles.avatar} className={styles.avatar}
style={{ display: video && videoEnabled ? "none" : "initial" }} style={{ display: video && videoEnabled ? "none" : "initial" }}
/> />

View File

@@ -27,7 +27,6 @@ import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { type RoomMember } from "matrix-js-sdk";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
@@ -55,10 +54,12 @@ interface SpotlightItemBaseProps {
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder | undefined; video: TrackReferenceOrPlaceholder | undefined;
videoEnabled: boolean; videoEnabled: boolean;
member: RoomMember; userId: string;
unencryptedWarning: boolean; unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus; encryptionStatus: EncryptionStatus;
focusUrl: string | undefined;
displayName: string; displayName: string;
mxcAvatarUrl: string | undefined;
focusable: boolean; focusable: boolean;
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
localParticipant: boolean; localParticipant: boolean;
@@ -78,7 +79,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
...props ...props
}) => { }) => {
const mirror = useBehavior(vm.mirror$); const mirror = useBehavior(vm.mirror$);
return <MediaView mirror={mirror} focusUrl={vm.focusURL} {...props} />; return <MediaView mirror={mirror} {...props} />;
}; };
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem"; SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
@@ -134,7 +135,9 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
}) => { }) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const focusUrl = useBehavior(vm.focusUrl$);
const displayName = useBehavior(vm.displayName$); const displayName = useBehavior(vm.displayName$);
const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$);
const video = useBehavior(vm.video$); const video = useBehavior(vm.video$);
const videoEnabled = useBehavior(vm.videoEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
@@ -161,11 +164,13 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
className: classNames(styles.item, { [styles.snap]: snap }), className: classNames(styles.item, { [styles.snap]: snap }),
targetWidth, targetWidth,
targetHeight, targetHeight,
video, video: video ?? undefined,
videoEnabled, videoEnabled,
member: vm.member, userId: vm.userId,
unencryptedWarning, unencryptedWarning,
focusUrl,
displayName, displayName,
mxcAvatarUrl,
focusable, focusable,
encryptionStatus, encryptionStatus,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,

View File

@@ -6,10 +6,10 @@ Please see LICENSE in the repository root for full details.
*/ */
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
import { type RoomMember } from "matrix-js-sdk";
import { shouldDisambiguate } from "./displayname"; import { shouldDisambiguate } from "./displayname";
import { alice } from "./test-fixtures"; import { alice } from "./test-fixtures";
import { mockMatrixRoom } from "./test";
// Ideally these tests would be in ./displayname.test.ts but I can't figure out how to // Ideally these tests would be in ./displayname.test.ts but I can't figure out how to
// just spy on the removeHiddenChars() function without impacting the other tests. // just spy on the removeHiddenChars() function without impacting the other tests.
@@ -29,7 +29,7 @@ describe("shouldDisambiguate", () => {
}); });
test("should only call removeHiddenChars once for a single displayname", () => { test("should only call removeHiddenChars once for a single displayname", () => {
const room = mockMatrixRoom({}); const room: Map<string, Pick<RoomMember, "userId">> = new Map([]);
shouldDisambiguate(alice, [], room); shouldDisambiguate(alice, [], room);
expect(jsUtils.removeHiddenChars).toHaveBeenCalledTimes(1); expect(jsUtils.removeHiddenChars).toHaveBeenCalledTimes(1);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {

View File

@@ -20,62 +20,70 @@ import {
daveRTL, daveRTL,
} from "./test-fixtures"; } from "./test-fixtures";
import { mockMatrixRoom } from "./test"; import { mockMatrixRoom } from "./test";
import { roomToMembersMap } from "../state/CallViewModel/remoteMembers/MatrixMemberMetadata";
describe("shouldDisambiguate", () => { describe("shouldDisambiguate", () => {
test("should not disambiguate a solo member", () => { test("should not disambiguate a solo member", () => {
const room = mockMatrixRoom({}); const room = mockMatrixRoom({
expect(shouldDisambiguate(alice, [], room)).toEqual(false); getMembersWithMembership: () => [],
});
expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual(
false,
);
}); });
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({
getMember: (u) => getMembersWithMembership: () => [alice, aliceDoppelganger],
[alice, aliceDoppelganger].find((m) => m.userId === u) ?? null,
}); });
expect( expect(
shouldDisambiguate( shouldDisambiguate(
{ rawDisplayName: "", userId: alice.userId }, { rawDisplayName: "", userId: alice.userId },
[aliceRtcMember, aliceDoppelgangerRtcMember], [aliceRtcMember, aliceDoppelgangerRtcMember],
room, roomToMembersMap(room),
), ),
).toEqual(false); ).toEqual(false);
}); });
test("should disambiguate a member with RTL characters", () => { test("should disambiguate a member with RTL characters", () => {
const room = mockMatrixRoom({}); const room = mockMatrixRoom({ getMembersWithMembership: () => [] });
expect(shouldDisambiguate(daveRTL, [], room)).toEqual(true); expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual(
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({
getMember: (u) => getMembersWithMembership: () => [alice, aliceDoppelganger],
[alice, aliceDoppelganger].find((m) => m.userId === u) ?? null,
}); });
expect( expect(
shouldDisambiguate( shouldDisambiguate(
alice, alice,
[aliceRtcMember, aliceDoppelgangerRtcMember], [aliceRtcMember, aliceDoppelgangerRtcMember],
room, roomToMembersMap(room),
), ),
).toEqual(true); ).toEqual(true);
expect( expect(
shouldDisambiguate( shouldDisambiguate(
aliceDoppelganger, aliceDoppelganger,
[aliceRtcMember, aliceDoppelgangerRtcMember], [aliceRtcMember, aliceDoppelgangerRtcMember],
room, roomToMembersMap(room),
), ),
).toEqual(true); ).toEqual(true);
}); });
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({
getMember: (u) => getMembersWithMembership: () => [bob, bobZeroWidthSpace],
[bob, bobZeroWidthSpace].find((m) => m.userId === u) ?? null,
}); });
expect( expect(
shouldDisambiguate(bob, [bobRtcMember, bobZeroWidthSpaceRtcMember], room), shouldDisambiguate(
bob,
[bobRtcMember, bobZeroWidthSpaceRtcMember],
roomToMembersMap(room),
),
).toEqual(true); ).toEqual(true);
expect( expect(
shouldDisambiguate( shouldDisambiguate(
bobZeroWidthSpace, bobZeroWidthSpace,
[bobRtcMember, bobZeroWidthSpaceRtcMember], [bobRtcMember, bobZeroWidthSpaceRtcMember],
room, roomToMembersMap(room),
), ),
).toEqual(true); ).toEqual(true);
}); });
@@ -83,11 +91,14 @@ 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({
getMember: (u) => getMembersWithMembership: () => [alice, aliceDoppelganger],
[alice, aliceDoppelganger].find((m) => m.userId === u) ?? null,
}); });
expect( expect(
shouldDisambiguate({ rawDisplayName, userId: alice.userId }, [], room), shouldDisambiguate(
{ rawDisplayName, userId: alice.userId },
[],
roomToMembersMap(room),
),
).toEqual(true); ).toEqual(true);
}, },
); );

View File

@@ -10,7 +10,7 @@ import {
removeHiddenChars as removeHiddenCharsUncached, removeHiddenChars as removeHiddenCharsUncached,
} from "matrix-js-sdk/lib/utils"; } from "matrix-js-sdk/lib/utils";
import type { Room } from "matrix-js-sdk"; import type { RoomMember } from "matrix-js-sdk";
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
// Calling removeHiddenChars() can be slow on Safari, so we cache the results. // Calling removeHiddenChars() can be slow on Safari, so we cache the results.
@@ -40,8 +40,8 @@ function removeHiddenChars(str: string): string {
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409 // Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
export function shouldDisambiguate( export function shouldDisambiguate(
member: { rawDisplayName?: string; userId: string }, member: { rawDisplayName?: string; userId: string },
memberships: CallMembership[], memberships: Pick<CallMembership, "userId">[],
room: Room, roomMembers: Map<string, Pick<RoomMember, "userId">>,
): boolean { ): boolean {
const { rawDisplayName: displayName, userId } = member; const { rawDisplayName: displayName, userId } = member;
if (!displayName || displayName === userId) return false; if (!displayName || displayName === userId) return false;
@@ -65,7 +65,7 @@ export function shouldDisambiguate(
// displayname, after hidden character removal. // displayname, after hidden character removal.
return ( return (
memberships memberships
.map((m) => m.userId && room.getMember(m.userId)) .map((m) => m.userId && roomMembers.get(m.userId))
// NOTE: We *should* have a room member for everyone. // NOTE: We *should* have a room member for everyone.
.filter((m) => !!m) .filter((m) => !!m)
.filter((m) => m.userId !== userId) .filter((m) => m.userId !== userId)

View File

@@ -9,7 +9,7 @@ import { test } from "vitest";
import { Subject } from "rxjs"; import { Subject } from "rxjs";
import { withTestScheduler } from "./test"; import { withTestScheduler } from "./test";
import { generateKeyed$, pauseWhen } from "./observable"; import { generateItems, pauseWhen } from "./observable";
test("pauseWhen", () => { test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
@@ -24,7 +24,7 @@ test("pauseWhen", () => {
}); });
}); });
test("generateKeyed$ has the right output and ends scopes at the right times", () => { test("generateItems", () => {
const scope1$ = new Subject<string>(); const scope1$ = new Subject<string>();
const scope2$ = new Subject<string>(); const scope2$ = new Subject<string>();
const scope3$ = new Subject<string>(); const scope3$ = new Subject<string>();
@@ -44,18 +44,27 @@ test("generateKeyed$ has the right output and ends scopes at the right times", (
const scope4Marbles = " ----yn"; const scope4Marbles = " ----yn";
expectObservable( expectObservable(
generateKeyed$(hot<string>(inputMarbles), (input, createOrGet) => { hot<string>(inputMarbles).pipe(
for (let i = 1; i <= +input; i++) { generateItems(
createOrGet(i.toString(), (scope) => { function* (input) {
for (let i = 1; i <= +input; i++) {
yield { keys: [i], data: undefined };
}
},
(scope, data$, i) => {
scopeSubjects[i - 1].next("y"); scopeSubjects[i - 1].next("y");
scope.onEnd(() => scopeSubjects[i - 1].next("n")); scope.onEnd(() => scopeSubjects[i - 1].next("n"));
return i.toString(); return i.toString();
}); },
} ),
return "abcd"[+input - 1]; ),
}),
subscriptionMarbles, subscriptionMarbles,
).toBe(outputMarbles); ).toBe(outputMarbles, {
a: ["1"],
b: ["1", "2"],
c: ["1", "2", "3"],
d: ["1", "2", "3", "4"],
});
expectObservable(scope1$).toBe(scope1Marbles); expectObservable(scope1$).toBe(scope1Marbles);
expectObservable(scope2$).toBe(scope2Marbles); expectObservable(scope2$).toBe(scope2Marbles);

View File

@@ -20,10 +20,12 @@ import {
takeWhile, takeWhile,
tap, tap,
withLatestFrom, withLatestFrom,
BehaviorSubject,
type OperatorFunction,
} from "rxjs"; } from "rxjs";
import { type Behavior } from "../state/Behavior"; import { type Behavior } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope"; import { Epoch, ObservableScope } from "../state/ObservableScope";
const nothing = Symbol("nothing"); const nothing = Symbol("nothing");
@@ -119,70 +121,156 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
); );
} }
interface ItemHandle<Data, Item> {
scope: ObservableScope;
data$: BehaviorSubject<Data>;
item: Item;
}
/** /**
* Maps a changing input value to an output value consisting of items that have * Maps a changing input value to a collection of items that each capture some
* automatically generated ObservableScopes tied to a key. Items will be * dynamic data and are tied to a key. Items will be automatically created when
* automatically created when their key is requested for the first time, reused * their key is requested for the first time, reused when the same key is
* when the same key is requested at a later time, and destroyed (have their * requested at a later time, and destroyed (have their scope ended) when the
* scope ended) when the key is no longer requested. * key is no longer requested.
* *
* @param input$ The input value to be mapped. * @param input$ The input value to be mapped.
* @param project A function mapping input values to output values. This * @param generator A generator function yielding a tuple of keys and the
* function receives an additional callback `createOrGet` which can be used * currently associated data for each item that it wants to exist.
* within the function body to request that an item be generated for a certain * @param factory A function constructing an individual item, given the item's key,
* key. The caller provides a factory which will be used to create the item if * dynamic data, and an automatically managed ObservableScope for the item.
* it is being requested for the first time. Otherwise, the item previously
* existing under that key will be returned.
*/ */
export function generateKeyed$<In, Item, Out>( export function generateItems<
input$: Observable<In>, Input,
project: ( Keys extends [unknown, ...unknown[]],
input: In, Data,
createOrGet: ( Item,
key: string, >(
factory: (scope: ObservableScope) => Item, generator: (
) => Item, input: Input,
) => Out, ) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
): Observable<Out> { factory: (
return input$.pipe( scope: ObservableScope,
// Keep track of the existing items over time, so we can reuse them data$: Behavior<Data>,
scan< ...keys: Keys
In, ) => Item,
{ ): OperatorFunction<Input, Item[]> {
items: Map<string, { item: Item; scope: ObservableScope }>; return generateItemsInternal(generator, factory, (items) => items);
output: Out; }
},
{ items: Map<string, { item: Item; scope: ObservableScope }> }
>(
(state, data) => {
const nextItems = new Map<
string,
{ item: Item; scope: ObservableScope }
>();
const output = project(data, (key, factory) => { /**
let item = state.items.get(key); * Same as generateItems, but preserves epoch data.
if (item === undefined) { */
// First time requesting the key; create the item export function generateItemsWithEpoch<
const scope = new ObservableScope(); Input,
item = { item: factory(scope), scope }; Keys extends [unknown, ...unknown[]],
} Data,
nextItems.set(key, item); Item,
return item.item; >(
}); generator: (
input: Input,
// Destroy all items that are no longer being requested ) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
for (const [key, { scope }] of state.items) factory: (
if (!nextItems.has(key)) scope.end(); scope: ObservableScope,
data$: Behavior<Data>,
return { items: nextItems, output }; ...keys: Keys
}, ) => Item,
{ items: new Map() }, ): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
), return generateItemsInternal(
finalizeValue((state) => { function* (input) {
// Destroy all remaining items when no longer subscribed yield* generator(input.value);
for (const { scope } of state.items.values()) scope.end(); },
}), factory,
map(({ output }) => output), (items, input) => new Epoch(items, input.epoch),
); );
} }
function generateItemsInternal<
Input,
Keys extends [unknown, ...unknown[]],
Data,
Item,
Output,
>(
generator: (
input: Input,
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
factory: (
scope: ObservableScope,
data$: Behavior<Data>,
...keys: Keys
) => Item,
project: (items: Item[], input: Input) => Output,
): OperatorFunction<Input, Output> {
/* eslint-disable @typescript-eslint/no-explicit-any */
return (input$) =>
input$.pipe(
// Keep track of the existing items over time, so they can persist
scan<
Input,
{
map: Map<any, any>;
items: Set<ItemHandle<Data, Item>>;
input: Input;
},
{ map: Map<any, any>; items: Set<ItemHandle<Data, Item>> }
>(
({ map: prevMap, items: prevItems }, input) => {
const nextMap = new Map();
const nextItems = new Set<ItemHandle<Data, Item>>();
for (const { keys, data } of generator(input)) {
// Disable type checks for a second to grab the item out of a nested map
let i: any = prevMap;
for (const key of keys) i = i?.get(key);
let item = i as ItemHandle<Data, Item> | undefined;
if (item === undefined) {
// First time requesting the key; create the item
const scope = new ObservableScope();
const data$ = new BehaviorSubject(data);
item = { scope, data$, item: factory(scope, data$, ...keys) };
} else {
item.data$.next(data);
}
// Likewise, disable type checks to insert the item in the nested map
let m: Map<any, any> = nextMap;
for (let i = 0; i < keys.length - 1; i++) {
let inner = m.get(keys[i]);
if (inner === undefined) {
inner = new Map();
m.set(keys[i], inner);
}
m = inner;
}
const finalKey = keys[keys.length - 1];
if (m.has(finalKey))
throw new Error(
`Keys must be unique (tried to generate multiple items for key ${keys})`,
);
m.set(keys[keys.length - 1], item);
nextItems.add(item);
}
// Destroy all items that are no longer being requested
for (const item of prevItems)
if (!nextItems.has(item)) item.scope.end();
return { map: nextMap, items: nextItems, input };
},
{ map: new Map(), items: new Set() },
),
finalizeValue(({ items }) => {
// Destroy all remaining items when no longer subscribed
for (const { scope } of items) scope.end();
}),
map(({ items, input }) =>
project(
[...items].map(({ item }) => item),
input,
),
),
);
/* eslint-enable @typescript-eslint/no-explicit-any */
}

View File

@@ -11,9 +11,9 @@ import {
mockRemoteParticipant, mockRemoteParticipant,
} from "./test"; } from "./test";
export const localRtcMember = mockRtcMembership("@carol:example.org", "1111"); export const localRtcMember = mockRtcMembership("@local:example.org", "1111");
export const localRtcMemberDevice2 = mockRtcMembership( export const localRtcMemberDevice2 = mockRtcMembership(
"@carol:example.org", "@local:example.org",
"2222", "2222",
); );
export const local = mockMatrixRoomMember(localRtcMember); export const local = mockMatrixRoomMember(localRtcMember);
@@ -37,7 +37,6 @@ export const aliceDoppelganger = mockMatrixRoomMember(
rawDisplayName: "Alice", rawDisplayName: "Alice",
}, },
); );
export const aliceDoppelgangerId = `${aliceDoppelganger.userId}:${aliceDoppelgangerRtcMember.deviceId}`;
export const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB"); export const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
export const bob = mockMatrixRoomMember(bobRtcMember, { export const bob = mockMatrixRoomMember(bobRtcMember, {
@@ -55,10 +54,8 @@ export const bobZeroWidthSpace = mockMatrixRoomMember(
rawDisplayName: "Bo\u200bb", rawDisplayName: "Bo\u200bb",
}, },
); );
export const bobZeroWidthSpaceId = `${bobZeroWidthSpace.userId}:${bobZeroWidthSpaceRtcMember.deviceId}`;
export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD"); export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD");
export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, { export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD", rawDisplayName: "\u202eevaD",
}); });
export const daveRTLId = `${daveRTL.userId}:${daveRTLRtcMember.deviceId}`;

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, of } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { vitest } from "vitest"; import { vitest } from "vitest";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import EventEmitter from "events"; import EventEmitter from "events";
@@ -20,10 +20,12 @@ import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { import {
CallViewModel, type CallViewModel,
createCallViewModel$,
type CallViewModelOptions, type CallViewModelOptions,
} from "../state/CallViewModel"; } from "../state/CallViewModel/CallViewModel";
import { import {
mockConfig,
mockLivekitRoom, mockLivekitRoom,
mockLocalParticipant, mockLocalParticipant,
mockMatrixRoom, mockMatrixRoom,
@@ -36,6 +38,8 @@ import { aliceRtcMember, localRtcMember } from "./test-fixtures";
import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { constant } from "../state/Behavior"; import { constant } from "../state/Behavior";
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
export function getBasicRTCSession( export function getBasicRTCSession(
members: RoomMember[], members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember], initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
@@ -57,6 +61,7 @@ export function getBasicRTCSession(
getUserId: () => localRtcMember.userId, getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId, getDeviceId: () => localRtcMember.deviceId,
getSyncState: () => SyncState.Syncing, getSyncState: () => SyncState.Syncing,
getDomain: () => null,
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }), redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined), decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
@@ -78,6 +83,9 @@ 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()),
getMembersWithMembership: () => Array.from(matrixRoomMembers.values()),
guessDMUserId: vitest.fn(),
roomId: matrixRoomId, roomId: matrixRoomId,
on: vitest on: vitest
.fn() .fn()
@@ -138,7 +146,7 @@ export function getBasicCallViewModelEnvironment(
// const remoteParticipants$ = of([aliceParticipant]); // const remoteParticipants$ = of([aliceParticipant]);
const vm = new CallViewModel( const vm = createCallViewModel$(
testScope(), testScope(),
rtcSession.asMockedSession(), rtcSession.asMockedSession(),
matrixRoom, matrixRoom,
@@ -158,7 +166,7 @@ export function getBasicCallViewModelEnvironment(
}, },
handRaisedSubject$, handRaisedSubject$,
reactionsSubject$, reactionsSubject$,
of({ processor: undefined, supported: false }), constant({ processor: undefined, supported: false }),
); );
return { return {
vm, vm,

View File

@@ -6,25 +6,32 @@ Please see LICENSE in the repository root for full details.
*/ */
import { map, type Observable, of, type SchedulerLike } from "rxjs"; import { map, type Observable, of, type SchedulerLike } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing"; import { type RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, type MockedObject, onTestFinished, vi, vitest } from "vitest";
import { import {
type RoomMember, expect,
type Room as MatrixRoom, type MockedObject,
type MockInstance,
onTestFinished,
vi,
vitest,
} from "vitest";
import {
MatrixEvent, MatrixEvent,
type Room as MatrixRoom,
type Room, type Room,
type RoomMember,
TypedEventEmitter, TypedEventEmitter,
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { import {
CallMembership, CallMembership,
type Transport, type LivekitFocusSelection,
type LivekitTransport,
type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap, type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent, MembershipManagerEvent,
type SessionMembershipData, type SessionMembershipData,
Status, Status,
type LivekitFocusSelection, type Transport,
type MatrixRTCSession,
type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager"; import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import { import {
@@ -78,11 +85,11 @@ export interface OurRunHelpers extends RunHelpers {
* diagram. * diagram.
*/ */
schedule: (marbles: string, actions: Record<string, () => void>) => void; schedule: (marbles: string, actions: Record<string, () => void>) => void;
behavior<T = string>( behavior: <T>(
marbles: string, marbles: string,
values?: { [marble: string]: T }, values?: { [marble: string]: T },
error?: unknown, error?: unknown,
): Behavior<T>; ) => Behavior<T>;
scope: ObservableScope; scope: ObservableScope;
} }
@@ -106,7 +113,7 @@ export function withTestScheduler(
continuation: (helpers: OurRunHelpers) => void, continuation: (helpers: OurRunHelpers) => void,
): void { ): void {
const scheduler = new TestScheduler((actual, expected) => { const scheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equals(expected); expect(actual).toStrictEqual(expected);
}); });
const scope = new ObservableScope(); const scope = new ObservableScope();
// we set the test scheduler as a global so that you can watch it in a debugger // we set the test scheduler as a global so that you can watch it in a debugger
@@ -187,6 +194,29 @@ export const exampleTransport: LivekitTransport = {
livekit_alias: "!alias:example.org", livekit_alias: "!alias:example.org",
}; };
export function mockCallMembership(
userId: string,
deviceId: string,
transport?: Transport,
): CallMembership {
const t = transport ?? transportForUser(userId);
return {
userId: userId,
deviceId: deviceId,
getTransport: vi.fn().mockReturnValue(t),
transports: [t],
} as unknown as CallMembership;
}
function transportForUser(userId: string): Transport {
const domain = userId.split(":")[1];
return {
type: "livekit",
livekit_service_url: `https://lk.${domain}`,
livekit_alias: `!alias:${domain}`,
};
}
export function mockRtcMembership( export function mockRtcMembership(
user: string | RoomMember, user: string | RoomMember,
deviceId: string, deviceId: string,
@@ -246,6 +276,7 @@ export function mockLivekitRoom(
}: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {}, }: { remoteParticipants$?: Observable<RemoteParticipant[]> } = {},
): LivekitRoom { ): LivekitRoom {
const livekitRoom = { const livekitRoom = {
options: {},
...mockEmitter(), ...mockEmitter(),
...room, ...room,
} as Partial<LivekitRoom> as LivekitRoom; } as Partial<LivekitRoom> as LivekitRoom;
@@ -268,6 +299,7 @@ export function mockLocalParticipant(
return { return {
isLocal: true, isLocal: true,
trackPublications: new Map(), trackPublications: new Map(),
unpublishTracks: async () => Promise.resolve(),
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication, ({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
...mockEmitter(), ...mockEmitter(),
@@ -281,18 +313,20 @@ export function createLocalMedia(
localParticipant: LocalParticipant, localParticipant: LocalParticipant,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
): LocalUserMediaViewModel { ): LocalUserMediaViewModel {
const member = mockMatrixRoomMember(localRtcMember, roomMember);
return new LocalUserMediaViewModel( return new LocalUserMediaViewModel(
testScope(), testScope(),
"local", "local",
mockMatrixRoomMember(localRtcMember, roomMember), member.userId,
constant(localParticipant), constant(localParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({ localParticipant }), constant(mockLivekitRoom({ localParticipant })),
"https://rtc-example.org", constant("https://rtc-example.org"),
mediaDevices, mediaDevices,
constant(roomMember.rawDisplayName ?? "nodisplayname"), constant(member.rawDisplayName ?? "nodisplayname"),
constant(member.getMxcAvatarUrl()),
constant(null), constant(null),
constant(null), constant(null),
); );
@@ -306,6 +340,8 @@ export function mockRemoteParticipant(
setVolume() {}, setVolume() {},
getTrackPublication: () => getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication, ({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
// this will only get used for `getTrackPublications().length`
getTrackPublications: () => [0],
...mockEmitter(), ...mockEmitter(),
...participant, ...participant,
} as RemoteParticipant; } as RemoteParticipant;
@@ -316,31 +352,38 @@ export function createRemoteMedia(
roomMember: Partial<RoomMember>, roomMember: Partial<RoomMember>,
participant: Partial<RemoteParticipant>, participant: Partial<RemoteParticipant>,
): RemoteUserMediaViewModel { ): RemoteUserMediaViewModel {
const member = mockMatrixRoomMember(localRtcMember, roomMember);
const remoteParticipant = mockRemoteParticipant(participant); const remoteParticipant = mockRemoteParticipant(participant);
return new RemoteUserMediaViewModel( return new RemoteUserMediaViewModel(
testScope(), testScope(),
"remote", "remote",
mockMatrixRoomMember(localRtcMember, roomMember), member.userId,
of(remoteParticipant), of(remoteParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), constant(
"https://rtc-example.org", mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
),
constant("https://rtc-example.org"),
constant(false), constant(false),
constant(roomMember.rawDisplayName ?? "nodisplayname"), constant(member.rawDisplayName ?? "nodisplayname"),
constant(member.getMxcAvatarUrl()),
constant(null), constant(null),
constant(null), constant(null),
); );
} }
export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void { export function mockConfig(
vi.spyOn(Config, "get").mockReturnValue({ config: Partial<ResolvedConfigOptions> = {},
): MockInstance<() => ResolvedConfigOptions> {
const spy = vi.spyOn(Config, "get").mockReturnValue({
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...config, ...config,
}); });
// simulate loading the config // simulate loading the config
vi.spyOn(Config, "init").mockResolvedValue(void 0); vi.spyOn(Config, "init").mockResolvedValue(void 0);
return spy;
} }
export class MockRTCSession extends TypedEventEmitter< export class MockRTCSession extends TypedEventEmitter<

View File

@@ -50,5 +50,6 @@
"plugins": [{ "name": "typescript-eslint-language-service" }] "plugins": [{ "name": "typescript-eslint-language-service" }]
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"] "include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"],
"exclude": ["**.test.ts"]
} }

View File

@@ -2731,37 +2731,39 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@livekit/components-core@npm:0.12.10, @livekit/components-core@npm:^0.12.0": "@livekit/components-core@npm:0.12.11, @livekit/components-core@npm:^0.12.0":
version: 0.12.10 version: 0.12.11
resolution: "@livekit/components-core@npm:0.12.10" resolution: "@livekit/components-core@npm:0.12.11"
dependencies: dependencies:
"@floating-ui/dom": "npm:1.6.13" "@floating-ui/dom": "npm:1.6.13"
loglevel: "npm:1.9.1" loglevel: "npm:1.9.1"
rxjs: "npm:7.8.2" rxjs: "npm:7.8.2"
peerDependencies: peerDependencies:
livekit-client: ^2.13.3 livekit-client: ^2.15.14
tslib: ^2.6.2 tslib: ^2.6.2
checksum: 10c0/bfd84fb950f72dd037bd5329658c1e750a8ac6b8f2953ea673e3e944b8ea9d412ef9c98eb8b690052323e03c675964b162aacb00e60530cdc5187f77d21979bd checksum: 10c0/9c2ac3d30bb8cc9067ae0b2049784f81e90e57df9eabf7edbaf3c8ceb65a63f644a4e6abeb6cc38d3ebe52663d8dbb88535e01a965011f365d5ae1f3daf86052
languageName: node languageName: node
linkType: hard linkType: hard
"@livekit/components-react@npm:^2.0.0": "@livekit/components-react@npm:^2.0.0":
version: 2.9.15 version: 2.9.16
resolution: "@livekit/components-react@npm:2.9.15" resolution: "@livekit/components-react@npm:2.9.16"
dependencies: dependencies:
"@livekit/components-core": "npm:0.12.10" "@livekit/components-core": "npm:0.12.11"
clsx: "npm:2.1.1" clsx: "npm:2.1.1"
events: "npm:^3.3.0"
jose: "npm:^6.0.12"
usehooks-ts: "npm:3.1.1" usehooks-ts: "npm:3.1.1"
peerDependencies: peerDependencies:
"@livekit/krisp-noise-filter": ^0.2.12 || ^0.3.0 "@livekit/krisp-noise-filter": ^0.2.12 || ^0.3.0
livekit-client: ^2.13.3 livekit-client: ^2.15.14
react: ">=18" react: ">=18"
react-dom: ">=18" react-dom: ">=18"
tslib: ^2.6.2 tslib: ^2.6.2
peerDependenciesMeta: peerDependenciesMeta:
"@livekit/krisp-noise-filter": "@livekit/krisp-noise-filter":
optional: true optional: true
checksum: 10c0/58a93d85c3b8267d0afd00eceb4f34992ce66124f93450e828a78dd825ecc20e254c3123ed22ec33061a3728f50f4f020ff896769fb3a0ff79656ed8cf452a2b checksum: 10c0/4ba4ff473c5a29d3107412733a6676a3b708d70684ed463e9b34cda26abb3d2f317c2828a52e730837b756de9df3fc248260d6f390aedebfb6ec96ef63c7b151
languageName: node languageName: node
linkType: hard linkType: hard
@@ -5247,12 +5249,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/node@npm:^22.0.0": "@types/node@npm:^24.0.0":
version: 22.17.0 version: 24.10.0
resolution: "@types/node@npm:22.17.0" resolution: "@types/node@npm:24.10.0"
dependencies: dependencies:
undici-types: "npm:~6.21.0" undici-types: "npm:~7.16.0"
checksum: 10c0/e1c603b660d3de3243dfc02ded5d40623ff3f36315ffbdd8cdc81bc2c5a8da172035879d437b72e9fa61ca01827f28e9c2b0c32898f411a8e9ba0a5efac0b4ca checksum: 10c0/f82ed7194e16f5590ef7afdc20c6d09068c76d50278b485ede8f0c5749683536e3064ffa8def8db76915196afb3724b854aa5723c64d6571b890b14492943b46
languageName: node languageName: node
linkType: hard linkType: hard
@@ -7507,7 +7509,7 @@ __metadata:
"@types/grecaptcha": "npm:^3.0.9" "@types/grecaptcha": "npm:^3.0.9"
"@types/jsdom": "npm:^21.1.7" "@types/jsdom": "npm:^21.1.7"
"@types/lodash-es": "npm:^4.17.12" "@types/lodash-es": "npm:^4.17.12"
"@types/node": "npm:^22.0.0" "@types/node": "npm:^24.0.0"
"@types/pako": "npm:^2.0.3" "@types/pako": "npm:^2.0.3"
"@types/qrcode": "npm:^1.5.5" "@types/qrcode": "npm:^1.5.5"
"@types/react": "npm:^19.0.0" "@types/react": "npm:^19.0.0"
@@ -9828,6 +9830,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jose@npm:^6.0.12":
version: 6.1.2
resolution: "jose@npm:6.1.2"
checksum: 10c0/55f79426f43e652ed6d5de938d50f66bb0a10dcae078db81a23f8d3303e889ce226f000e815f3211f9956bb84badce10da892d130d40fe2eca658045a6f1778e
languageName: node
linkType: hard
"jose@npm:^6.1.0": "jose@npm:^6.1.0":
version: 6.1.0 version: 6.1.0
resolution: "jose@npm:6.1.0" resolution: "jose@npm:6.1.0"
@@ -10110,8 +10119,8 @@ __metadata:
linkType: hard linkType: hard
"livekit-client@npm:^2.13.0": "livekit-client@npm:^2.13.0":
version: 2.15.13 version: 2.16.0
resolution: "livekit-client@npm:2.15.13" resolution: "livekit-client@npm:2.16.0"
dependencies: dependencies:
"@livekit/mutex": "npm:1.1.1" "@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.42.2" "@livekit/protocol": "npm:1.42.2"
@@ -10125,7 +10134,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1" webrtc-adapter: "npm:^9.0.1"
peerDependencies: peerDependencies:
"@types/dom-mediacapture-record": ^1 "@types/dom-mediacapture-record": ^1
checksum: 10c0/5a061df9000461a6d40ef8aa1e72e8aedc640181cc57fe6f2c48c5c7f90ce96a735b125aede377fc43f4692a685e098f17eeae0f42c5b2fed473305867bf2789 checksum: 10c0/5d03adc5d09efde343ab894db397529dff26117598e773b23a5df90a4fb166bde12c6bb1f2cfd1d28dbaf93fe9f275026d7abb75f2ffd2ba816393a2d58e6c7e
languageName: node languageName: node
linkType: hard linkType: hard
@@ -13603,10 +13612,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"undici-types@npm:~6.21.0": "undici-types@npm:~7.16.0":
version: 6.21.0 version: 7.16.0
resolution: "undici-types@npm:6.21.0" resolution: "undici-types@npm:7.16.0"
checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a
languageName: node languageName: node
linkType: hard linkType: hard