Merge branch 'livekit' into valere/fix_blank_widget_auto_leave
This commit is contained in:
@@ -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: |
|
||||||
|
|||||||
4
.github/workflows/publish.yaml
vendored
4
.github/workflows/publish.yaml
vendored
@@ -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: |
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
22
|
24
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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"> {
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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.`);
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={[]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => [
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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
354
src/state/CallViewModel/CallNotificationLifecycle.test.ts
Normal file
354
src/state/CallViewModel/CallNotificationLifecycle.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
211
src/state/CallViewModel/CallNotificationLifecycle.ts
Normal file
211
src/state/CallViewModel/CallNotificationLifecycle.ts
Normal 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$ };
|
||||||
|
}
|
||||||
1313
src/state/CallViewModel/CallViewModel.test.ts
Normal file
1313
src/state/CallViewModel/CallViewModel.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1495
src/state/CallViewModel/CallViewModel.ts
Normal file
1495
src/state/CallViewModel/CallViewModel.ts
Normal file
File diff suppressed because it is too large
Load Diff
193
src/state/CallViewModel/CallViewModelTestUtils.ts
Normal file
193
src/state/CallViewModel/CallViewModelTestUtils.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
633
src/state/CallViewModel/localMember/LocalMembership.ts
Normal file
633
src/state/CallViewModel/localMember/LocalMembership.ts
Normal 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, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/state/CallViewModel/localMember/LocalTransport.ts
Normal file
179
src/state/CallViewModel/localMember/LocalTransport.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
});
|
// // })
|
||||||
|
// // };
|
||||||
|
// // });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
121
src/state/CallViewModel/remoteMembers/ConnectionFactory.ts
Normal file
121
src/state/CallViewModel/remoteMembers/ConnectionFactory.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
332
src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts
Normal file
332
src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts
Normal 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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
src/state/CallViewModel/remoteMembers/ConnectionManager.ts
Normal file
231
src/state/CallViewModel/remoteMembers/ConnectionManager.ts
Normal 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[]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
138
src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts
Normal file
138
src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
180
src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts
Normal file
180
src/state/CallViewModel/remoteMembers/MatrixMemberMetadata.ts
Normal 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$,
|
||||||
|
};
|
||||||
|
};
|
||||||
228
src/state/CallViewModel/remoteMembers/integration.test.ts
Normal file
228
src/state/CallViewModel/remoteMembers/integration.test.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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$,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/state/ObservableScope.test.ts
Normal file
104
src/state/ObservableScope.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/state/SessionBehaviors.ts
Normal file
81
src/state/SessionBehaviors.ts
Normal 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),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
55
yarn.lock
55
yarn.lock
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user