Merge remote-tracking branch 'origin/livekit' into hs/reduce-join-sound-noises

This commit is contained in:
Half-Shot
2024-12-06 16:41:46 +00:00
30 changed files with 758 additions and 472 deletions

View File

@@ -42,7 +42,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}} tags: ${{ inputs.docker_tags}}
@@ -51,7 +51,7 @@ jobs:
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -92,6 +92,9 @@ work:
experimental_features: experimental_features:
# MSC3266: Room summary API. Used for knocking over federation # MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. # per MSC4140.
@@ -109,6 +112,10 @@ summary contains the room join rules. We need that to decide if the user gets
prompted with the option to knock ("Request to join call"), a cannot join error or the prompted with the option to knock ("Request to join call"), a cannot join error or the
join view. join view.
MSC4222 allow clients to opt-in to a change of the sync v2 API that allows them
to correctly track the state of the room. This is required by Element Call to
track room state reliably.
Element Call requires a Livekit SFU alongside a [Livekit JWT Element Call requires a Livekit SFU alongside a [Livekit JWT
service](https://github.com/element-hq/lk-jwt-service) to work. The url to the service](https://github.com/element-hq/lk-jwt-service) to work. The url to the
Livekit JWT service can either be configured in the config of Element Call Livekit JWT service can either be configured in the config of Element Call
@@ -213,7 +220,7 @@ To add a new translation key you can do these steps:
1. Add the new key entry to the code where the new key is used: `t("some_new_key")` 1. Add the new key entry to the code where the new key is used: `t("some_new_key")`
1. Run `yarn i18n` to extract the new key and update the translation files. This 1. Run `yarn i18n` to extract the new key and update the translation files. This
will add a skeleton entry to the `locales/en-GB/app.json` file: will add a skeleton entry to the `locales/en/app.json` file:
```jsonc ```jsonc
{ {
... ...
@@ -221,7 +228,7 @@ To add a new translation key you can do these steps:
... ...
} }
``` ```
1. Update the skeleton entry in the `locales/en-GB/app.json` file with 1. Update the skeleton entry in the `locales/en/app.json` file with
the English translation: the English translation:
```jsonc ```jsonc

View File

@@ -25,6 +25,9 @@ trusted_key_servers:
experimental_features: experimental_features:
# MSC3266: Room summary API. Used for knocking over federation # MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as # The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no # per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -21,7 +21,7 @@ export default {
}, },
], ],
}, },
locales: ["en-GB"], locales: ["en"],
output: "locales/$LOCALE/$NAMESPACE.json", output: "locales/$LOCALE/$NAMESPACE.json",
input: ["src/**/*.{ts,tsx}"], input: ["src/**/*.{ts,tsx}"],
sort: true, sort: true,

View File

@@ -7,13 +7,13 @@
"features": ["plural_postfix_us", "filter_untranslated"], "features": ["plural_postfix_us", "filter_untranslated"],
"files": [ "files": [
{ {
"pattern": "locales/en-GB/*.json", "pattern": "locales/en/*.json",
"lang": "inherited" "lang": "inherited"
}, },
{ {
"group": "existing", "group": "existing",
"pattern": "locales/*/*.json", "pattern": "locales/*/*.json",
"excludes": ["locales/en-GB/*.json"], "excludes": ["locales/en/*.json"],
"lang": "${autodetectLang}" "lang": "${autodetectLang}"
} }
] ]
@@ -25,9 +25,6 @@
"output": "locales/${langLsrDash}/${file}" "output": "locales/${langLsrDash}/${file}"
} }
], ],
"includeSourceLang": "${includeSourceLang|false}", "includeSourceLang": "${includeSourceLang|false}"
"langAliases": {
"en": "en_GB"
}
} }
} }

View File

@@ -194,6 +194,7 @@
"expand": "Expand", "expand": "Expand",
"mute_for_me": "Mute for me", "mute_for_me": "Mute for me",
"muted_for_me": "Muted for me", "muted_for_me": "Muted for me",
"volume": "Volume" "volume": "Volume",
"waiting_for_media": "Waiting for media..."
} }
} }

View File

@@ -53,7 +53,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": "^20.0.0", "@types/node": "^22.0.0",
"@types/pako": "^2.0.3", "@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -63,7 +63,7 @@
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.9.1", "@vector-im/compound-design-tokens": "^2.0.0",
"@vector-im/compound-web": "^7.2.0", "@vector-im/compound-web": "^7.2.0",
"@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1", "@vitejs/plugin-react": "^4.0.1",

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import "i18next"; import "i18next";
// import all namespaces (for the default language, only) // import all namespaces (for the default language, only)
import app from "../../locales/en-GB/app.json"; import app from "../../locales/en/app.json";
declare module "i18next" { declare module "i18next" {
interface CustomTypeOptions { interface CustomTypeOptions {

View File

@@ -22,7 +22,7 @@ export abstract class TranslatedError extends Error {
messageKey: ParseKeys<DefaultNamespace, TOptions>, messageKey: ParseKeys<DefaultNamespace, TOptions>,
translationFn: TFunction<DefaultNamespace>, translationFn: TFunction<DefaultNamespace>,
) { ) {
super(translationFn(messageKey, { lng: "en-GB" } as TOptions)); super(translationFn(messageKey, { lng: "en" } as TOptions));
this.translatedMessage = translationFn(messageKey); this.translatedMessage = translationFn(messageKey);
} }
} }

View File

@@ -51,7 +51,7 @@ export interface ConfigOptions {
// a livekit service url in the client well-known. // a livekit service url in the client well-known.
// The well known needs to be formatted like so: // The well known needs to be formatted like so:
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"} // {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
// and stored under the key: "livekit_focus" // and stored under the key: "org.matrix.msc4143.rtc_foci"
livekit_service_url: string; livekit_service_url: string;
}; };

View File

@@ -47,6 +47,11 @@ layer(compound);
--background-gradient: url("graphics/backgroundGradient.svg"); --background-gradient: url("graphics/backgroundGradient.svg");
} }
:root,
[class*="cpd-theme-"] {
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
}
.cpd-theme-dark { .cpd-theme-dark {
--cpd-color-border-accent: var(--cpd-color-green-1100); --cpd-color-border-accent: var(--cpd-color-green-1100);
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary); --stopgap-color-on-solid-accent: var(--cpd-color-text-primary);

View File

@@ -24,7 +24,7 @@ import { platform } from "./Platform";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// { // {
// "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json", // "../locales/en/app.json": "/whatever/assets/root/locales/en-aabbcc.json",
// ... // ...
// } // }
const locales = import.meta.glob<string>("../locales/*/*.json", { const locales = import.meta.glob<string>("../locales/*/*.json", {
@@ -41,7 +41,7 @@ const getLocaleUrl = (
const supportedLngs = [ const supportedLngs = [
...new Set( ...new Set(
Object.keys(locales).map((url) => { Object.keys(locales).map((url) => {
// The URLs are of the form ../locales/en-GB/app.json // The URLs are of the form ../locales/en/app.json
// This extracts the language code from the URL // This extracts the language code from the URL
const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1]; const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
if (!lang) { if (!lang) {
@@ -133,7 +133,7 @@ export class Initializer {
.use(languageDetector) .use(languageDetector)
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
fallbackLng: "en-GB", fallbackLng: "en",
defaultNS: "app", defaultNS: "app",
keySeparator: ".", keySeparator: ".",
nsSeparator: false, nsSeparator: false,

View File

@@ -8,23 +8,30 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { import {
afterAll, afterAll,
afterEach,
beforeEach, beforeEach,
expect, expect,
Mock,
MockedFunction, MockedFunction,
test, test,
vitest, vitest,
} from "vitest"; } from "vitest";
import { ConnectionState, RemoteParticipant, Room } from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { of } from "rxjs"; import { ConnectionState } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { afterEach } from "node:test";
import { act, ReactNode } from "react"; import { act, ReactNode } from "react";
import {
CallMembership,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc";
import { import {
EmittableMockLivekitRoom, mockLivekitRoom,
mockLocalParticipant, mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockRemoteParticipant, mockRemoteParticipant,
mockRtcMembership,
MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel"; import { CallViewModel } from "../state/CallViewModel";
@@ -33,19 +40,17 @@ import {
MAX_PARTICIPANT_COUNT_FOR_SOUND, MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer"; } from "./CallEventAudioRenderer";
import { prefetchSounds, useAudioContext } from "../useAudioContext"; import { prefetchSounds, useAudioContext } from "../useAudioContext";
import { import { TestReactionsWrapper } from "../utils/testReactions";
MockRoom, import { RoomMember } from "matrix-js-sdk/src/matrix";
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" }); const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" }); const local = mockMatrixRoomMember(localRtcMember);
const aliceId = `${alice.userId}:AAAA`; const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
const bobId = `${bob.userId}:BBBB`; const alice = mockMatrixRoomMember(aliceRtcMember);
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const localParticipant = mockLocalParticipant({ identity: "" }); const localParticipant = mockLocalParticipant({ identity: "" });
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
const bobParticipant = mockRemoteParticipant({ identity: bobId });
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
@@ -57,7 +62,7 @@ afterAll(() => {
vitest.restoreAllMocks(); vitest.restoreAllMocks();
}); });
let playSound: Mock< let playSound: MockedFunction<
NonNullable<ReturnType<typeof useAudioContext>>["playSound"] NonNullable<ReturnType<typeof useAudioContext>>["playSound"]
>; >;
@@ -72,19 +77,63 @@ beforeEach(() => {
}); });
function TestComponent({ function TestComponent({
room, rtcSession,
vm, vm,
}: { }: {
room: MockRoom; rtcSession: MockRTCSession;
vm: CallViewModel; vm: CallViewModel;
}): ReactNode { }): ReactNode {
return ( return (
<TestReactionsWrapper rtcSession={new MockRTCSession(room, {})}> <TestReactionsWrapper rtcSession={rtcSession}>
<CallEventAudioRenderer vm={vm} /> <CallEventAudioRenderer vm={vm} />
</TestReactionsWrapper> </TestReactionsWrapper>
); );
} }
function getMockEnv(
members: RoomMember[],
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
): {
vm: CallViewModel;
session: MockRTCSession;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
} {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const matrixRoom = mockMatrixRoom({
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
on: vitest.fn(),
off: vitest.fn(),
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
});
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships,
);
const session = new MockRTCSession(
matrixRoom,
localRtcMember,
).withMemberships(remoteRtcMemberships);
const vm = new CallViewModel(
session as unknown as MatrixRTCSession,
liveKitRoom,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
return { vm, session, remoteRtcMemberships };
}
/** /**
* We don't want to play a sound when loading the call state * We don't want to play a sound when loading the call state
* because typically this occurs in two stages. We first join * because typically this occurs in two stages. We first join
@@ -93,118 +142,56 @@ function TestComponent({
* a noise every time. * a noise every time.
*/ */
test("plays one sound when entering a call", () => { test("plays one sound when entering a call", () => {
const liveKitRoom = new EmittableMockLivekitRoom({ const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
localParticipant, render(<TestComponent rtcSession={session} vm={vm} />);
remoteParticipants: new Map(),
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom.getAsLivekitRoom(),
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
// Joining a call usually means remote participants are added later. // Joining a call usually means remote participants are added later.
act(() => { act(() => {
liveKitRoom.addParticipant(bobParticipant); remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
}); });
expect(playSound).toHaveBeenCalledOnce();
render(<TestComponent room={room} vm={vm} />);
expect(playSound).toBeCalled();
}); });
// TODO: Same test?
test("plays a sound when a user joins", () => { test("plays a sound when a user joins", () => {
const remoteParticipants = new Map( const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
[aliceParticipant].map((p) => [p.identity, p]), render(<TestComponent rtcSession={session} vm={vm} />);
);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom as unknown as Room,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<TestComponent room={room} vm={vm} />);
act(() => { act(() => {
liveKitRoom.addParticipant(bobParticipant); remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
}); });
// Play a sound when joining a call. // Play a sound when joining a call.
expect(playSound).toBeCalledWith("join"); expect(playSound).toBeCalledWith("join");
}); });
test("plays a sound when a user leaves", () => { test("plays a sound when a user leaves", () => {
const remoteParticipants = new Map( const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]);
[aliceParticipant].map((p) => [p.identity, p]), render(<TestComponent rtcSession={session} vm={vm} />);
);
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom.getAsLivekitRoom(),
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
render(<TestComponent room={room} vm={vm} />);
act(() => { act(() => {
liveKitRoom.removeParticipant(aliceParticipant); remoteRtcMemberships.next([]);
}); });
expect(playSound).toBeCalledWith("left"); expect(playSound).toBeCalledWith("left");
}); });
test("plays no sound when the participant list is more than the maximum size", () => { test("plays no sound when the participant list is more than the maximum size", () => {
expect(playSound).not.toBeCalled(); const mockRtcMemberships: CallMembership[] = [];
const remoteParticipants = new Map<string, RemoteParticipant>([ for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
[aliceParticipant.identity, aliceParticipant], mockRtcMemberships.push(
// You + other participants to hit the max. mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
...Array.from({ length: MAX_PARTICIPANT_COUNT_FOR_SOUND - 1 }).map< );
[string, RemoteParticipant] }
>((_, index) => {
const p = mockRemoteParticipant({
identity: `@user${index}:example.com:DEV${index}`,
});
return [p.identity, p];
}),
]);
// Preload the call with the maximum members, assume that const { session, vm, remoteRtcMemberships } = getMockEnv(
// we're already in the call by this point rather than [local, alice],
// joining. mockRtcMemberships,
const liveKitRoom = new EmittableMockLivekitRoom({
localParticipant,
remoteParticipants,
});
const room = new MockRoom(alice.userId);
const vm = new CallViewModel(
room.testGetAsMatrixRoom(),
liveKitRoom.getAsLivekitRoom(),
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
); );
render(<TestComponent room={room} vm={vm} />);
// When the count drops, play a leave sound. render(<TestComponent rtcSession={session} vm={vm} />);
expect(playSound).not.toBeCalled();
act(() => { act(() => {
liveKitRoom.removeParticipant(aliceParticipant); remoteRtcMemberships.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
);
}); });
expect(playSound).toBeCalledWith("left"); expect(playSound).toBeCalledWith("left");
}); });

View File

@@ -120,7 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => { useEffect(() => {
if (livekitRoom !== undefined) { if (livekitRoom !== undefined) {
const vm = new CallViewModel( const vm = new CallViewModel(
props.rtcSession.room, props.rtcSession,
livekitRoom, livekitRoom,
props.e2eeSystem, props.e2eeSystem,
connStateObservable, connStateObservable,
@@ -128,12 +128,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
setVm(vm); setVm(vm);
return (): void => vm.destroy(); return (): void => vm.destroy();
} }
}, [ }, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
props.rtcSession.room,
livekitRoom,
props.e2eeSystem,
connStateObservable,
]);
if (livekitRoom === undefined || vm === null) return null; if (livekitRoom === undefined || vm === null) return null;

View File

@@ -18,8 +18,7 @@ Please see LICENSE in the repository root for full details.
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
background-color: black; background-color: var(--video-tile-background);
background-color: var(--cpd-color-bg-subtle-primary);
} }
video.mirror { video.mirror {
@@ -35,7 +34,7 @@ video.mirror {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background-color: var(--cpd-color-bg-subtle-secondary); background-color: var(--video-tile-background);
} }
.buttonBar { .buttonBar {

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, onTestFinished } from "vitest"; import { test, vi, onTestFinished, it } from "vitest";
import { import {
combineLatest, combineLatest,
debounceTime, debounceTime,
@@ -25,6 +25,7 @@ import {
} from "livekit-client"; } from "livekit-client";
import * as ComponentsCore from "@livekit/components-core"; import * as ComponentsCore from "@livekit/components-core";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { CallViewModel, Layout } from "./CallViewModel"; import { CallViewModel, Layout } from "./CallViewModel";
import { import {
@@ -34,6 +35,8 @@ import {
mockMatrixRoomMember, mockMatrixRoomMember,
mockRemoteParticipant, mockRemoteParticipant,
withTestScheduler, withTestScheduler,
mockRtcMembership,
MockRTCSession,
} from "../utils/test"; } from "../utils/test";
import { import {
ECAddonConnectionState, ECAddonConnectionState,
@@ -43,14 +46,19 @@ import { E2eeType } from "../e2ee/e2eeType";
vi.mock("@livekit/components-core"); vi.mock("@livekit/components-core");
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" }); const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" }); const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
const carol = mockMatrixRoomMember({ userId: "@carol:example.org" }); const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
const dave = mockMatrixRoomMember({ userId: "@dave:example.org" }); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
const aliceId = `${alice.userId}:AAAA`; const alice = mockMatrixRoomMember(aliceRtcMember);
const bobId = `${bob.userId}:BBBB`; const bob = mockMatrixRoomMember(bobRtcMember);
const daveId = `${dave.userId}:DDDD`; const carol = mockMatrixRoomMember(localRtcMember);
const dave = mockMatrixRoomMember(daveRtcMember);
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
const bobId = `${bob.userId}:${bobRtcMember.deviceId}`;
const daveId = `${dave.userId}:${daveRtcMember.deviceId}`;
const localParticipant = mockLocalParticipant({ identity: "" }); const localParticipant = mockLocalParticipant({ identity: "" });
const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
@@ -65,7 +73,9 @@ const bobSharingScreen = mockRemoteParticipant({
}); });
const daveParticipant = mockRemoteParticipant({ identity: daveId }); const daveParticipant = mockRemoteParticipant({ identity: daveId });
const members = new Map([alice, bob, carol, dave].map((p) => [p.userId, p])); const roomMembers = new Map(
[alice, bob, carol, dave].map((p) => [p.userId, p]),
);
export interface GridLayoutSummary { export interface GridLayoutSummary {
type: "grid"; type: "grid";
@@ -173,10 +183,23 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
function withCallViewModel( function withCallViewModel(
remoteParticipants: Observable<RemoteParticipant[]>, remoteParticipants: Observable<RemoteParticipant[]>,
rtcMembers: Observable<Partial<CallMembership>[]>,
connectionState: Observable<ECConnectionState>, connectionState: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>, speaking: Map<Participant, Observable<boolean>>,
continuation: (vm: CallViewModel) => void, continuation: (vm: CallViewModel) => void,
): void { ): void {
const room = mockMatrixRoom({
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => roomMembers.get(userId) ?? null,
});
const rtcSession = new MockRTCSession(
room,
localRtcMember,
[],
).withMemberships(rtcMembers);
const participantsSpy = vi const participantsSpy = vi
.spyOn(ComponentsCore, "connectedParticipantsObserver") .spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants); .mockReturnValue(remoteParticipants);
@@ -209,12 +232,7 @@ function withCallViewModel(
); );
const vm = new CallViewModel( const vm = new CallViewModel(
mockMatrixRoom({ rtcSession as unknown as MatrixRTCSession,
client: {
getUserId: () => "@carol:example.org",
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => members.get(userId) ?? null,
}),
liveKitRoom, liveKitRoom,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
@@ -247,6 +265,7 @@ test("participants are retained during a focus switch", () => {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [], b: [],
}), }),
of([aliceRtcMember, bobRtcMember]),
hot(connectionInputMarbles, { hot(connectionInputMarbles, {
c: ConnectionState.Connected, c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus, s: ECAddonConnectionState.ECSwitchingFocus,
@@ -288,6 +307,7 @@ test("screen sharing activates spotlight layout", () => {
c: [aliceSharingScreen, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen], d: [aliceParticipant, bobSharingScreen],
}), }),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
@@ -356,7 +376,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
const modeInputMarbles = " a"; const modeInputMarbles = " a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = "n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = "n- 1998ms - 1999ms y";
const bSpeakingInputMarbles = "ny 1998ms n 1999ms "; const bSpeakingInputMarbles = "ny 1998ms n 1999ms -";
const dSpeakingInputMarbles = "n- 1998ms y 1999ms n"; const dSpeakingInputMarbles = "n- 1998ms y 1999ms n";
// Nothing should change when Bob speaks, because Bob is already on screen. // Nothing should change when Bob speaks, because Bob is already on screen.
// When Dave speaks he should switch with Alice because she's the one who // When Dave speaks he should switch with Alice because she's the one who
@@ -366,6 +386,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
@@ -427,6 +448,7 @@ test("spotlight speakers swap places", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), of([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })],
@@ -475,6 +497,7 @@ test("layout enters picture-in-picture mode when requested", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
@@ -515,6 +538,7 @@ test("spotlight remembers whether it's expanded", () => {
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
(vm) => { (vm) => {
@@ -559,3 +583,104 @@ test("spotlight remembers whether it's expanded", () => {
); );
}); });
}); });
test("participants must have a MatrixRTCSession to be visible", () => {
withTestScheduler(({ hot, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership
const scenarioInputMarbles = " abcdec";
// Bob should never be visible
const expectedLayoutMarbles = "a-bc-b";
withCallViewModel(
hot(scenarioInputMarbles, {
a: [],
b: [bobParticipant],
c: [aliceParticipant, bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen],
}),
hot(scenarioInputMarbles, {
a: [],
b: [],
c: [aliceRtcMember],
d: [aliceRtcMember, daveRtcMember],
e: [aliceRtcMember, daveRtcMember],
}),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0"],
},
b: {
type: "one-on-one",
local: "local:0",
remote: `${aliceId}:0`,
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
},
);
},
);
});
});
it("should show at least one tile per MatrixRTCSession", () => {
withTestScheduler(({ hot, expectObservable }) => {
// iterate through some combinations of MatrixRTC memberships
const scenarioInputMarbles = " abcd";
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "abcd";
withCallViewModel(
of([]),
hot(scenarioInputMarbles, {
a: [],
b: [aliceRtcMember],
c: [aliceRtcMember, daveRtcMember],
d: [daveRtcMember],
}),
of(ConnectionState.Connected),
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: ["local:0"],
},
b: {
type: "one-on-one",
local: "local:0",
remote: `${aliceId}:0`,
},
c: {
type: "grid",
spotlight: undefined,
grid: ["local:0", `${aliceId}:0`, `${daveId}:0`],
},
d: {
type: "one-on-one",
local: "local:0",
remote: `${daveId}:0`,
},
},
);
},
);
});
});

View File

@@ -18,12 +18,9 @@ import {
RemoteParticipant, RemoteParticipant,
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { Room as MatrixRoom, RoomMember } from "matrix-js-sdk/src/matrix";
import { import {
Room as MatrixRoom, BehaviorSubject,
RoomMember,
RoomStateEvent,
} from "matrix-js-sdk/src/matrix";
import {
EMPTY, EMPTY,
Observable, Observable,
Subject, Subject,
@@ -49,6 +46,10 @@ import {
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { import {
@@ -222,41 +223,67 @@ interface LayoutScanState {
class UserMedia { class UserMedia {
private readonly scope = new ObservableScope(); private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel; public readonly vm: UserMediaViewModel;
private readonly participant: BehaviorSubject<
LocalParticipant | RemoteParticipant | undefined
>;
public readonly speaker: Observable<boolean>; public readonly speaker: Observable<boolean>;
public readonly presenter: Observable<boolean>; public readonly presenter: Observable<boolean>;
public constructor( public constructor(
public readonly id: string, public readonly id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
this.vm = participant.isLocal this.participant = new BehaviorSubject(participant);
? new LocalUserMediaViewModel(
id, if (participant?.isLocal) {
member, this.vm = new LocalUserMediaViewModel(
participant as LocalParticipant, this.id,
encryptionSystem, member,
livekitRoom, this.participant.asObservable() as Observable<LocalParticipant>,
) encryptionSystem,
: new RemoteUserMediaViewModel( livekitRoom,
id, );
member, } else {
participant as RemoteParticipant, this.vm = new RemoteUserMediaViewModel(
encryptionSystem, id,
livekitRoom, member,
); this.participant.asObservable() as Observable<
RemoteParticipant | undefined
>,
encryptionSystem,
livekitRoom,
);
}
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state()); this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state());
this.presenter = observeParticipantEvents( this.presenter = this.participant.pipe(
participant, switchMap(
ParticipantEvent.TrackPublished, (p) =>
ParticipantEvent.TrackUnpublished, (p &&
ParticipantEvent.LocalTrackPublished, observeParticipantEvents(
ParticipantEvent.LocalTrackUnpublished, p,
).pipe(map((p) => p.isScreenShareEnabled)); ParticipantEvent.TrackPublished,
ParticipantEvent.TrackUnpublished,
ParticipantEvent.LocalTrackPublished,
ParticipantEvent.LocalTrackUnpublished,
).pipe(map((p) => p.isScreenShareEnabled))) ??
of(false),
),
this.scope.state(),
);
}
public updateParticipant(
newParticipant: LocalParticipant | RemoteParticipant | undefined,
): void {
if (this.participant.value !== newParticipant) {
// Update the BehaviourSubject in the UserMedia.
this.participant.next(newParticipant);
}
} }
public destroy(): void { public destroy(): void {
@@ -267,6 +294,9 @@ class UserMedia {
class ScreenShare { class ScreenShare {
public readonly vm: ScreenShareViewModel; public readonly vm: ScreenShareViewModel;
private readonly participant: BehaviorSubject<
LocalParticipant | RemoteParticipant
>;
public constructor( public constructor(
id: string, id: string,
@@ -275,12 +305,15 @@ class ScreenShare {
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom, liveKitRoom: LivekitRoom,
) { ) {
this.participant = new BehaviorSubject(participant);
this.vm = new ScreenShareViewModel( this.vm = new ScreenShareViewModel(
id, id,
member, member,
participant, this.participant.asObservable(),
encryptionSystem, encryptionSystem,
liveKitRoom, liveKitRoom,
participant.isLocal,
); );
} }
@@ -317,11 +350,11 @@ function findMatrixRoomMember(
export class CallViewModel extends ViewModel { export class CallViewModel extends ViewModel {
public readonly localVideo: Observable<LocalVideoTrack | null> = public readonly localVideo: Observable<LocalVideoTrack | null> =
observeTrackReference( observeTrackReference(
this.livekitRoom.localParticipant, of(this.livekitRoom.localParticipant),
Track.Source.Camera, Track.Source.Camera,
).pipe( ).pipe(
map((trackRef) => { map((trackRef) => {
const track = trackRef.publication?.track; const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
}), }),
); );
@@ -401,49 +434,87 @@ export class CallViewModel extends ViewModel {
this.remoteParticipants, this.remoteParticipants,
observeParticipantMedia(this.livekitRoom.localParticipant), observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value, duplicateTiles.value,
// Also react to changes in the list of members // Also react to changes in the MatrixRTC session list.
fromEvent(this.matrixRoom, RoomStateEvent.Update).pipe(startWith(null)), // The session list will also be update if a room membership changes.
// No additional RoomState event listener needs to be set up.
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)),
]).pipe( ]).pipe(
scan( scan(
( (
prevItems, prevItems,
[remoteParticipants, { participant: localParticipant }, duplicateTiles], [
remoteParticipants,
{ participant: localParticipant },
duplicateTiles,
_membershipsChanged,
],
) => { ) => {
const newItems = new Map( const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> { function* (this: CallViewModel): Iterable<[string, MediaItem]> {
for (const p of [localParticipant, ...remoteParticipants]) { // m.rtc.members are the basis for calculating what is visible in the call
const id = p === localParticipant ? "local" : p.identity; for (const rtcMember of this.matrixRTCSession.memberships) {
const member = findMatrixRoomMember(this.matrixRoom, id); const room = this.matrixRTCSession.room;
if (member === undefined) // WARN! This is not exactly the sender but the user defined in the state key.
logger.warn( // This will be available once we change to the new "member as object" format in the MatrixRTC object.
`Ruh, roh! No matrix member found for SFU participant '${p.identity}': creating g-g-g-ghost!`, let livekitParticipantId =
); rtcMember.sender + ":" + rtcMember.deviceId;
// Create as many tiles for this participant as called for by let participant:
// the duplicateTiles option | LocalParticipant
| RemoteParticipant
| undefined = undefined;
if (
rtcMember.sender === room.client.getUserId()! &&
rtcMember.deviceId === room.client.getDeviceId()
) {
livekitParticipantId = "local";
participant = localParticipant;
} else {
participant = remoteParticipants.find(
(p) => p.identity === livekitParticipantId,
);
}
const member = findMatrixRoomMember(room, livekitParticipantId);
if (!member) {
logger.error(
"Could not find member for media id: ",
livekitParticipantId,
);
}
for (let i = 0; i < 1 + duplicateTiles; i++) { for (let i = 0; i < 1 + duplicateTiles; i++) {
const userMediaId = `${id}:${i}`; const indexedMediaId = `${livekitParticipantId}:${i}`;
const prevMedia = prevItems.get(indexedMediaId);
if (prevMedia && prevMedia instanceof UserMedia) {
prevMedia.updateParticipant(participant);
}
yield [ yield [
userMediaId, indexedMediaId,
prevItems.get(userMediaId) ?? // We create UserMedia with or without a participant.
// This will be the initial value of a BehaviourSubject.
// Once a participant appears we will update the BehaviourSubject. (see above)
prevMedia ??
new UserMedia( new UserMedia(
userMediaId, indexedMediaId,
member, member,
p, participant,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
), ),
]; ];
if (p.isScreenShareEnabled) { if (participant?.isScreenShareEnabled) {
const screenShareId = `${userMediaId}:screen-share`; const screenShareId = `${indexedMediaId}:screen-share`;
yield [ yield [
screenShareId, screenShareId,
prevItems.get(screenShareId) ?? prevItems.get(screenShareId) ??
new ScreenShare( new ScreenShare(
screenShareId, screenShareId,
member, member,
p, participant,
this.encryptionSystem, this.encryptionSystem,
this.livekitRoom, this.livekitRoom,
), ),
@@ -454,7 +525,6 @@ export class CallViewModel extends ViewModel {
}.bind(this)(), }.bind(this)(),
); );
for (const [id, t] of prevItems) if (!newItems.has(id)) t.destroy();
return newItems; return newItems;
}, },
new Map<string, MediaItem>(), new Map<string, MediaItem>(),
@@ -488,11 +558,6 @@ export class CallViewModel extends ViewModel {
), ),
); );
private readonly localUserMedia: Observable<LocalUserMediaViewModel> =
this.mediaItems.pipe(
map((ms) => ms.find((m) => m.vm.local)!.vm as LocalUserMediaViewModel),
);
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */
@@ -504,7 +569,7 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
private readonly spotlightSpeaker: Observable<UserMediaViewModel> = private readonly spotlightSpeaker: Observable<UserMediaViewModel | null> =
this.userMedia.pipe( this.userMedia.pipe(
switchMap((mediaItems) => switchMap((mediaItems) =>
mediaItems.length === 0 mediaItems.length === 0
@@ -515,7 +580,7 @@ export class CallViewModel extends ViewModel {
), ),
), ),
), ),
scan<(readonly [UserMedia, boolean])[], UserMedia, null>( scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>(
(prev, mediaItems) => { (prev, mediaItems) => {
// Only remote users that are still in the call should be sticky // Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] = const [stickyMedia, stickySpeaking] =
@@ -532,11 +597,11 @@ export class CallViewModel extends ViewModel {
// Otherwise, spotlight an arbitrary remote user // Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.vm.local)?.[0] ?? mediaItems.find(([m]) => !m.vm.local)?.[0] ??
// Otherwise, spotlight the local user // Otherwise, spotlight the local user
mediaItems.find(([m]) => m.vm.local)![0]); mediaItems.find(([m]) => m.vm.local)?.[0]);
}, },
null, null,
), ),
map((speaker) => speaker.vm), map((speaker) => speaker?.vm ?? null),
this.scope.state(), this.scope.state(),
); );
@@ -576,37 +641,57 @@ export class CallViewModel extends ViewModel {
}), }),
); );
private readonly spotlightAndPip: Observable<
[Observable<MediaViewModel[]>, Observable<UserMediaViewModel | null>]
> = this.screenShares.pipe(
map((screenShares) =>
screenShares.length > 0
? ([of(screenShares.map((m) => m.vm)), this.spotlightSpeaker] as const)
: ([
this.spotlightSpeaker.pipe(map((speaker) => [speaker!])),
this.spotlightSpeaker.pipe(
switchMap((speaker) =>
speaker.local
? of(null)
: this.localUserMedia.pipe(
switchMap((vm) =>
vm.alwaysShow.pipe(
map((alwaysShow) => (alwaysShow ? vm : null)),
),
),
),
),
),
] as const),
),
);
private readonly spotlight: Observable<MediaViewModel[]> = private readonly spotlight: Observable<MediaViewModel[]> =
this.spotlightAndPip.pipe( this.screenShares.pipe(
switchMap(([spotlight]) => spotlight), switchMap((screenShares) => {
if (screenShares.length > 0) {
return of(screenShares.map((m) => m.vm));
}
return this.spotlightSpeaker.pipe(
map((speaker) => (speaker ? [speaker] : [])),
);
}),
this.scope.state(), this.scope.state(),
); );
private readonly pip: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares,
this.spotlightSpeaker,
this.mediaItems,
]).pipe(
switchMap(([screenShares, spotlight, mediaItems]) => {
if (screenShares.length > 0) {
return this.spotlightSpeaker;
}
if (!spotlight || spotlight.local) {
return of(null);
}
const localUserMedia = mediaItems.find(
(m) => m.vm instanceof LocalUserMediaViewModel,
) as UserMedia | undefined;
const localUserMediaViewModel = localUserMedia?.vm as
| LocalUserMediaViewModel
| undefined;
if (!localUserMediaViewModel) {
return of(null);
}
return localUserMediaViewModel.alwaysShow.pipe(
map((alwaysShow) => {
if (alwaysShow) {
return localUserMediaViewModel;
}
return null;
}),
);
}),
this.scope.state(),
);
private readonly hasRemoteScreenShares: Observable<boolean> = private readonly hasRemoteScreenShares: Observable<boolean> =
this.spotlight.pipe( this.spotlight.pipe(
map((spotlight) => map((spotlight) =>
@@ -615,9 +700,6 @@ export class CallViewModel extends ViewModel {
distinctUntilChanged(), distinctUntilChanged(),
); );
private readonly pip: Observable<UserMediaViewModel | null> =
this.spotlightAndPip.pipe(switchMap(([, pip]) => pip));
private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe( private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe(
startWith(false), startWith(false),
); );
@@ -721,15 +803,16 @@ export class CallViewModel extends ViewModel {
this.mediaItems.pipe( this.mediaItems.pipe(
map((mediaItems) => { map((mediaItems) => {
if (mediaItems.length !== 2) return null; if (mediaItems.length !== 2) return null;
const local = mediaItems.find((vm) => vm.vm.local)! const local = mediaItems.find((vm) => vm.vm.local)?.vm as
.vm as LocalUserMediaViewModel; | LocalUserMediaViewModel
| undefined;
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
| RemoteUserMediaViewModel | RemoteUserMediaViewModel
| undefined; | undefined;
// There might not be a remote tile if there are screen shares, or if // There might not be a remote tile if there are screen shares, or if
// only the local user is in the call and they're using the duplicate // only the local user is in the call and they're using the duplicate
// tiles option // tiles option
if (remote === undefined) return null; if (!remote || !local) return null;
return { type: "one-on-one", local, remote }; return { type: "one-on-one", local, remote };
}), }),
@@ -1010,7 +1093,7 @@ export class CallViewModel extends ViewModel {
public constructor( public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room // A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRoom: MatrixRoom, private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom, private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem, private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>, private readonly connectionState: Observable<ECConnectionState>,

View File

@@ -8,14 +8,17 @@ Please see LICENSE in the repository root for full details.
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import { import {
mockRtcMembership,
withLocalMedia, withLocalMedia,
withRemoteMedia, withRemoteMedia,
withTestScheduler, withTestScheduler,
} from "../utils/test"; } from "../utils/test";
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
test("control a participant's volume", async () => { test("control a participant's volume", async () => {
const setVolumeSpy = vi.fn(); const setVolumeSpy = vi.fn();
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) => await withRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy }, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab---c---d|", { schedule("-ab---c---d|", {
a() { a() {
@@ -60,7 +63,7 @@ test("control a participant's volume", async () => {
}); });
test("toggle fit/contain for a participant's video", async () => { test("toggle fit/contain for a participant's video", async () => {
await withRemoteMedia({}, {}, (vm) => await withRemoteMedia(rtcMembership, {}, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-ab|", { schedule("-ab|", {
a: () => vm.toggleFitContain(), a: () => vm.toggleFitContain(),
@@ -76,17 +79,21 @@ test("toggle fit/contain for a participant's video", async () => {
}); });
test("local media remembers whether it should always be shown", async () => { test("local media remembers whether it should always be shown", async () => {
await withLocalMedia({}, (vm) => await withLocalMedia(rtcMembership, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => { withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) }); schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false }); expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
}), }),
); );
// Next local media should start out *not* always shown // Next local media should start out *not* always shown
await withLocalMedia({}, (vm) => await withLocalMedia(
withTestScheduler(({ expectObservable, schedule }) => { rtcMembership,
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true }); {},
}), (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
}),
); );
}); });

View File

@@ -32,7 +32,6 @@ import {
Observable, Observable,
Subject, Subject,
combineLatest, combineLatest,
distinctUntilChanged,
distinctUntilKeyChanged, distinctUntilKeyChanged,
filter, filter,
fromEvent, fromEvent,
@@ -40,7 +39,6 @@ import {
map, map,
merge, merge,
of, of,
shareReplay,
startWith, startWith,
switchMap, switchMap,
throttleTime, throttleTime,
@@ -77,16 +75,24 @@ export function useDisplayName(vm: MediaViewModel): string {
} }
export function observeTrackReference( export function observeTrackReference(
participant: Participant, participant: Observable<Participant | undefined>,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder> { ): Observable<TrackReferenceOrPlaceholder | undefined> {
return observeParticipantMedia(participant).pipe( return participant.pipe(
map(() => ({ switchMap((p) => {
participant, if (p) {
publication: participant.getTrackPublication(source), return observeParticipantMedia(p).pipe(
source, map(() => ({
})), participant: p,
distinctUntilKeyChanged("publication"), publication: p.getTrackPublication(source),
source,
})),
distinctUntilKeyChanged("publication"),
);
} else {
return of(undefined);
}
}),
); );
} }
@@ -105,11 +111,11 @@ function observeRemoteTrackReceivingOkay(
}; };
return combineLatest([ return combineLatest([
observeTrackReference(participant, source), observeTrackReference(of(participant), source),
interval(1000).pipe(startWith(0)), interval(1000).pipe(startWith(0)),
]).pipe( ]).pipe(
switchMap(async ([trackReference]) => { switchMap(async ([trackReference]) => {
const track = trackReference.publication?.track; const track = trackReference?.publication?.track;
if (!track || !(track instanceof RemoteTrack)) { if (!track || !(track instanceof RemoteTrack)) {
return undefined; return undefined;
} }
@@ -200,14 +206,10 @@ export enum EncryptionStatus {
} }
abstract class BaseMediaViewModel extends ViewModel { abstract class BaseMediaViewModel extends ViewModel {
/**
* Whether the media belongs to the local user.
*/
public readonly local = this.participant.isLocal;
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video: Observable<TrackReferenceOrPlaceholder>; public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
@@ -215,6 +217,11 @@ abstract class BaseMediaViewModel extends ViewModel {
public readonly encryptionStatus: Observable<EncryptionStatus>; public readonly encryptionStatus: Observable<EncryptionStatus>;
/**
* Whether this media corresponds to the local participant.
*/
public abstract readonly local: boolean;
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@@ -226,7 +233,12 @@ abstract class BaseMediaViewModel extends ViewModel {
// TODO: Fully separate the data layer from the UI layer by keeping the // TODO: Fully separate the data layer from the UI layer by keeping the
// member object internal // member object internal
public readonly member: RoomMember | undefined, public readonly member: RoomMember | undefined,
protected readonly participant: LocalParticipant | RemoteParticipant, // We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit.
protected readonly participant: Observable<
LocalParticipant | RemoteParticipant | undefined
>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
@@ -243,69 +255,72 @@ abstract class BaseMediaViewModel extends ViewModel {
[audio, this.video], [audio, this.video],
(a, v) => (a, v) =>
encryptionSystem.kind !== E2eeType.NONE && encryptionSystem.kind !== E2eeType.NONE &&
(a.publication?.isEncrypted === false || (a?.publication?.isEncrypted === false ||
v.publication?.isEncrypted === false), v?.publication?.isEncrypted === false),
).pipe( ).pipe(this.scope.state());
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
);
if (participant.isLocal || encryptionSystem.kind === E2eeType.NONE) { this.encryptionStatus = this.participant.pipe(
this.encryptionStatus = of(EncryptionStatus.Okay).pipe( switchMap((participant): Observable<EncryptionStatus> => {
this.scope.state(), if (!participant) {
); return of(EncryptionStatus.Connecting);
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { } else if (
this.encryptionStatus = combineLatest([ participant.isLocal ||
encryptionErrorObservable( encryptionSystem.kind === E2eeType.NONE
livekitRoom, ) {
participant, return of(EncryptionStatus.Okay);
encryptionSystem, } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
"MissingKey", return combineLatest([
), encryptionErrorObservable(
encryptionErrorObservable( livekitRoom,
livekitRoom, participant,
participant, encryptionSystem,
encryptionSystem, "MissingKey",
"InvalidKey", ),
), encryptionErrorObservable(
observeRemoteTrackReceivingOkay(participant, audioSource), livekitRoom,
observeRemoteTrackReceivingOkay(participant, videoSource), participant,
]).pipe( encryptionSystem,
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { "InvalidKey",
if (keyMissing) return EncryptionStatus.KeyMissing; ),
if (keyInvalid) return EncryptionStatus.KeyInvalid; observeRemoteTrackReceivingOkay(participant, audioSource),
if (audioOkay || videoOkay) return EncryptionStatus.Okay; observeRemoteTrackReceivingOkay(participant, videoSource),
return undefined; // no change ]).pipe(
}), map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
filter((x) => !!x), if (keyMissing) return EncryptionStatus.KeyMissing;
startWith(EncryptionStatus.Connecting), if (keyInvalid) return EncryptionStatus.KeyInvalid;
this.scope.state(), if (audioOkay || videoOkay) return EncryptionStatus.Okay;
); return undefined; // no change
} else { }),
this.encryptionStatus = combineLatest([ filter((x) => !!x),
encryptionErrorObservable( startWith(EncryptionStatus.Connecting),
livekitRoom, );
participant, } else {
encryptionSystem, return combineLatest([
"InvalidKey", encryptionErrorObservable(
), livekitRoom,
observeRemoteTrackReceivingOkay(participant, audioSource), participant,
observeRemoteTrackReceivingOkay(participant, videoSource), encryptionSystem,
]).pipe( "InvalidKey",
map( ),
([keyInvalid, audioOkay, videoOkay]): observeRemoteTrackReceivingOkay(participant, audioSource),
| EncryptionStatus observeRemoteTrackReceivingOkay(participant, videoSource),
| undefined => { ]).pipe(
if (keyInvalid) return EncryptionStatus.PasswordInvalid; map(
if (audioOkay || videoOkay) return EncryptionStatus.Okay; ([keyInvalid, audioOkay, videoOkay]):
return undefined; // no change | EncryptionStatus
}, | undefined => {
), if (keyInvalid) return EncryptionStatus.PasswordInvalid;
filter((x) => !!x), if (audioOkay || videoOkay) return EncryptionStatus.Okay;
startWith(EncryptionStatus.Connecting), return undefined; // no change
this.scope.state(), },
); ),
} filter((x) => !!x),
startWith(EncryptionStatus.Connecting),
);
}
}),
this.scope.state(),
);
} }
} }
@@ -324,11 +339,14 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking = observeParticipantEvents( public readonly speaking = this.participant.pipe(
this.participant, switchMap((p) =>
ParticipantEvent.IsSpeakingChanged, p
).pipe( ? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
map((p) => p.isSpeaking), map((p) => p.isSpeaking),
)
: of(false),
),
this.scope.state(), this.scope.state(),
); );
@@ -350,7 +368,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
@@ -364,18 +382,25 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
livekitRoom, livekitRoom,
); );
const media = observeParticipantMedia(participant).pipe(this.scope.state()); const media = participant.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(),
);
this.audioEnabled = media.pipe( this.audioEnabled = media.pipe(
map((m) => m.microphoneTrack?.isMuted === false), map((m) => m?.microphoneTrack?.isMuted === false),
); );
this.videoEnabled = media.pipe( this.videoEnabled = media.pipe(
map((m) => m.cameraTrack?.isMuted === false), map((m) => m?.cameraTrack?.isMuted === false),
); );
} }
public toggleFitContain(): void { public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value); this._cropVideo.next(!this._cropVideo.value);
} }
public get local(): boolean {
return this instanceof LocalUserMediaViewModel;
}
} }
/** /**
@@ -387,7 +412,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
*/ */
public readonly mirror = this.video.pipe( public readonly mirror = this.video.pipe(
switchMap((v) => { switchMap((v) => {
const track = v.publication?.track; const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false); if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch // Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe( return fromEvent(track, TrackEvent.Restarted).pipe(
@@ -409,7 +434,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant, participant: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
@@ -470,18 +495,17 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: RemoteParticipant, participant: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
) { ) {
super(id, member, participant, encryptionSystem, livekitRoom); super(id, member, participant, encryptionSystem, livekitRoom);
// Sync the local volume with LiveKit // Sync the local volume with LiveKit
this.localVolume combineLatest([
.pipe(this.scope.bind()) participant,
.subscribe((volume) => this.localVolume.pipe(this.scope.bind()),
(this.participant as RemoteParticipant).setVolume(volume), ]).subscribe(([p, volume]) => p && p.setVolume(volume));
);
} }
public toggleLocallyMuted(): void { public toggleLocallyMuted(): void {
@@ -504,9 +528,10 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant, participant: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
public readonly local: boolean,
) { ) {
super( super(
id, id,

View File

@@ -13,7 +13,7 @@ import { of } from "rxjs";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { GridTile } from "./GridTile"; import { GridTile } from "./GridTile";
import { withRemoteMedia } from "../utils/test"; import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsProvider } from "../useReactions"; import { ReactionsProvider } from "../useReactions";
@@ -25,6 +25,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
test("GridTile is accessible", async () => { test("GridTile is accessible", async () => {
await withRemoteMedia( await withRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{ {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",

View File

@@ -175,6 +175,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
raisedHandTime={handRaised} raisedHandTime={handRaised}
currentReaction={currentReaction} currentReaction={currentReaction}
raisedHandOnClick={raisedHandOnClick} raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
{...props} {...props}
/> />
); );

View File

@@ -15,7 +15,7 @@ Please see LICENSE in the repository root for full details.
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
object-fit: contain; object-fit: contain;
background-color: var(--cpd-color-bg-subtle-primary); background-color: var(--video-tile-background);
/* This transform is a no-op, but it forces Firefox to use a different /* This transform is a no-op, but it forces Firefox to use a different
rendering path, one that actually clips the corners of <video> elements into rendering path, one that actually clips the corners of <video> elements into
the intended rounded shape. We can remove this if Firefox stops being broken. */ the intended rounded shape. We can remove this if Firefox stops being broken. */
@@ -35,7 +35,7 @@ Please see LICENSE in the repository root for full details.
} }
.bg { .bg {
background-color: var(--cpd-color-bg-subtle-secondary); background-color: var(--video-tile-background);
inline-size: 100%; inline-size: 100%;
block-size: 100%; block-size: 100%;
border-radius: inherit; border-radius: inherit;
@@ -74,9 +74,9 @@ unconditionally select the container so we can use cqmin units */
calc(var(--media-view-border-radius) - var(--cpd-space-3x)) calc(var(--media-view-border-radius) - var(--cpd-space-3x))
); );
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 30px 1fr 30px;
grid-template-rows: 1fr auto; grid-template-rows: 1fr auto;
grid-template-areas: "status status" "nameTag button"; grid-template-areas: "reactions status ." "nameTag nameTag button";
gap: var(--cpd-space-1x); gap: var(--cpd-space-1x);
place-items: start; place-items: start;
} }
@@ -101,8 +101,8 @@ unconditionally select the container so we can use cqmin units */
grid-area: status; grid-area: status;
justify-self: center; justify-self: center;
align-self: start; align-self: start;
padding: var(--cpd-space-1x); padding: var(--cpd-space-2x);
padding-block: var(--cpd-space-1x); padding-block: var(--cpd-space-2x);
color: var(--cpd-color-text-primary); color: var(--cpd-color-text-primary);
background-color: var(--cpd-color-bg-canvas-default); background-color: var(--cpd-color-bg-canvas-default);
display: flex; display: flex;
@@ -116,6 +116,12 @@ unconditionally select the container so we can use cqmin units */
text-align: center; text-align: center;
} }
.reactions {
grid-area: reactions;
display: flex;
gap: var(--cpd-space-1x);
}
.nameTag > svg, .nameTag > svg,
.nameTag > span { .nameTag > span {
flex-shrink: 0; flex-shrink: 0;

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { describe, expect, test } 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";
@@ -42,6 +42,7 @@ describe("MediaView", () => {
unencryptedWarning: false, unencryptedWarning: false,
video: trackReference, video: trackReference,
member: undefined, member: undefined,
localParticipant: false,
}; };
test("is accessible", async () => { test("is accessible", async () => {
@@ -59,6 +60,25 @@ describe("MediaView", () => {
}); });
}); });
describe("with no participant", () => {
it("shows avatar for local user", () => {
render(
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
);
expect(screen.getByRole("img", { name: "some name" })).toBeVisible();
expect(screen.queryAllByText("video_tile.waiting_for_media").length).toBe(
0,
);
});
it("shows avatar and label for remote user", () => {
render(
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
);
expect(screen.getByRole("img", { name: "some name" })).toBeVisible();
expect(screen.getByText("video_tile.waiting_for_media")).toBeVisible();
});
});
describe("name tag", () => { describe("name tag", () => {
test("is shown with name", () => { test("is shown with name", () => {
render(<MediaView {...baseProps} displayName="Bob" />); render(<MediaView {...baseProps} displayName="Bob" />);

View File

@@ -28,7 +28,7 @@ interface Props extends ComponentProps<typeof animated.div> {
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video: TrackReferenceOrPlaceholder | undefined;
videoFit: "cover" | "contain"; videoFit: "cover" | "contain";
mirror: boolean; mirror: boolean;
member: RoomMember | undefined; member: RoomMember | undefined;
@@ -41,6 +41,7 @@ interface Props extends ComponentProps<typeof animated.div> {
raisedHandTime?: Date; raisedHandTime?: Date;
currentReaction?: ReactionOption; currentReaction?: ReactionOption;
raisedHandOnClick?: () => void; raisedHandOnClick?: () => void;
localParticipant: boolean;
} }
export const MediaView = forwardRef<HTMLDivElement, Props>( export const MediaView = forwardRef<HTMLDivElement, Props>(
@@ -63,6 +64,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
raisedHandTime, raisedHandTime,
currentReaction, currentReaction,
raisedHandOnClick, raisedHandOnClick,
localParticipant,
...props ...props
}, },
ref, ref,
@@ -90,21 +92,21 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
size={avatarSize} size={avatarSize}
src={member?.getMxcAvatarUrl()} src={member?.getMxcAvatarUrl()}
className={styles.avatar} className={styles.avatar}
style={{ display: videoEnabled ? "none" : "initial" }} style={{ display: video && videoEnabled ? "none" : "initial" }}
/> />
{video.publication !== undefined && ( {video?.publication !== undefined && (
<VideoTrack <VideoTrack
trackRef={video} trackRef={video}
// There's no reason for this to be focusable // There's no reason for this to be focusable
tabIndex={-1} tabIndex={-1}
disablePictureInPicture disablePictureInPicture
style={{ display: videoEnabled ? "block" : "none" }} style={{ display: video && videoEnabled ? "block" : "none" }}
data-testid="video" data-testid="video"
/> />
)} )}
</div> </div>
<div className={styles.fg}> <div className={styles.fg}>
<div style={{ display: "flex", gap: "var(--cpd-space-1x)" }}> <div className={styles.reactions}>
<RaisedHandIndicator <RaisedHandIndicator
raisedHandTime={raisedHandTime} raisedHandTime={raisedHandTime}
miniature={avatarSize < 96} miniature={avatarSize < 96}
@@ -118,6 +120,11 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
/> />
)} )}
</div> </div>
{!video && !localParticipant && (
<div className={styles.status}>
{t("video_tile.waiting_for_media")}
</div>
)}
{/* TODO: Bring this back once encryption status is less broken */} {/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && ( {/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}> <div className={styles.status}>

View File

@@ -12,7 +12,11 @@ import userEvent from "@testing-library/user-event";
import { of } from "rxjs"; import { of } from "rxjs";
import { SpotlightTile } from "./SpotlightTile"; import { SpotlightTile } from "./SpotlightTile";
import { withLocalMedia, withRemoteMedia } from "../utils/test"; import {
mockRtcMembership,
withLocalMedia,
withRemoteMedia,
} from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
@@ -22,6 +26,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
test("SpotlightTile is accessible", async () => { test("SpotlightTile is accessible", async () => {
await withRemoteMedia( await withRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{ {
rawDisplayName: "Alice", rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg", getMxcAvatarUrl: () => "mxc://adfsg",
@@ -29,6 +34,7 @@ test("SpotlightTile is accessible", async () => {
{}, {},
async (vm1) => { async (vm1) => {
await withLocalMedia( await withLocalMedia(
mockRtcMembership("@bob:example.org", "BBBB"),
{ {
rawDisplayName: "Bob", rawDisplayName: "Bob",
getMxcAvatarUrl: () => "mxc://dlskf", getMxcAvatarUrl: () => "mxc://dlskf",

View File

@@ -49,12 +49,13 @@ interface SpotlightItemBaseProps {
"data-id": string; "data-id": string;
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
video: TrackReferenceOrPlaceholder; video: TrackReferenceOrPlaceholder | undefined;
member: RoomMember | undefined; member: RoomMember | undefined;
unencryptedWarning: boolean; unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus; encryptionStatus: EncryptionStatus;
displayName: string; displayName: string;
"aria-hidden"?: boolean; "aria-hidden"?: boolean;
localParticipant: boolean;
} }
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
@@ -163,6 +164,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
displayName, displayName,
encryptionStatus, encryptionStatus,
"aria-hidden": ariaHidden, "aria-hidden": ariaHidden,
localParticipant: vm.local,
}; };
return vm instanceof ScreenShareViewModel ? ( return vm instanceof ScreenShareViewModel ? (
@@ -210,7 +212,9 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised); const maximised = useObservableEagerState(vm.maximised);
const media = useObservableEagerState(vm.media); const media = useObservableEagerState(vm.media);
const [visibleId, setVisibleId] = useState(media[0].id); const [visibleId, setVisibleId] = useState<string | undefined>(
media[0]?.id,
);
const latestMedia = useLatest(media); const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId); const latestVisibleId = useLatest(visibleId);
const visibleIndex = media.findIndex((vm) => vm.id === visibleId); const visibleIndex = media.findIndex((vm) => vm.id === visibleId);

View File

@@ -7,7 +7,20 @@ Please see LICENSE in the repository root for full details.
import { map, Observable, of, SchedulerLike } from "rxjs"; import { map, Observable, of, SchedulerLike } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing"; import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi } from "vitest";
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix"; import {
RoomMember,
Room as MatrixRoom,
MatrixEvent,
Room,
TypedEventEmitter,
} from "matrix-js-sdk/src/matrix";
import {
CallMembership,
Focus,
MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap,
SessionMembershipData,
} from "matrix-js-sdk/src/matrixrtc";
import { import {
LocalParticipant, LocalParticipant,
LocalTrackPublication, LocalTrackPublication,
@@ -100,48 +113,46 @@ function mockEmitter<T>(): EmitterMock<T> {
}; };
} }
export function mockRtcMembership(
user: string | RoomMember,
deviceId: string,
callId = "",
fociPreferred: Focus[] = [],
focusActive: Focus = { type: "oldest_membership" },
membership: Partial<SessionMembershipData> = {},
): CallMembership {
const data: SessionMembershipData = {
application: "m.call",
call_id: callId,
device_id: deviceId,
foci_preferred: fociPreferred,
focus_active: focusActive,
...membership,
};
const event = new MatrixEvent({
sender: typeof user === "string" ? user : user.userId,
});
return new CallMembership(event, data);
}
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are // Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
// rather simple, but if one util to mock a member is good enough for us, maybe // rather simple, but if one util to mock a member is good enough for us, maybe
// it's useful for matrix-js-sdk consumers in general. // it's useful for matrix-js-sdk consumers in general.
export function mockMatrixRoomMember(member: Partial<RoomMember>): RoomMember { export function mockMatrixRoomMember(
return { ...mockEmitter(), ...member } as RoomMember; rtcMembership: CallMembership,
member: Partial<RoomMember> = {},
): RoomMember {
return {
...mockEmitter(),
userId: rtcMembership.sender,
...member,
} as RoomMember;
} }
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom { export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom; return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
} }
/**
* A mock of a Livekit Room that can emit events.
*/
export class EmittableMockLivekitRoom extends EventEmitter {
public localParticipant?: LocalParticipant;
public remoteParticipants: Map<string, RemoteParticipant>;
public constructor(room: {
localParticipant?: LocalParticipant;
remoteParticipants: Map<string, RemoteParticipant>;
}) {
super();
this.localParticipant = room.localParticipant;
this.remoteParticipants = room.remoteParticipants ?? new Map();
}
public addParticipant(remoteParticipant: RemoteParticipant): void {
this.remoteParticipants.set(remoteParticipant.identity, remoteParticipant);
this.emit(RoomEvent.ParticipantConnected, remoteParticipant);
}
public removeParticipant(remoteParticipant: RemoteParticipant): void {
this.remoteParticipants.delete(remoteParticipant.identity);
this.emit(RoomEvent.ParticipantDisconnected, remoteParticipant);
}
public getAsLivekitRoom(): LivekitRoom {
return this as unknown as LivekitRoom;
}
}
export function mockLivekitRoom( export function mockLivekitRoom(
room: Partial<LivekitRoom>, room: Partial<LivekitRoom>,
{ {
@@ -178,14 +189,15 @@ export function mockLocalParticipant(
} }
export async function withLocalMedia( export async function withLocalMedia(
member: Partial<RoomMember>, localRtcMember: CallMembership,
roomMember: Partial<RoomMember>,
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>, continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
): Promise<void> { ): Promise<void> {
const localParticipant = mockLocalParticipant({}); const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"local", "local",
mockMatrixRoomMember(member), mockMatrixRoomMember(localRtcMember, roomMember),
localParticipant, of(localParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
@@ -212,15 +224,16 @@ export function mockRemoteParticipant(
} }
export async function withRemoteMedia( export async function withRemoteMedia(
member: Partial<RoomMember>, localRtcMember: CallMembership,
roomMember: Partial<RoomMember>,
participant: Partial<RemoteParticipant>, participant: Partial<RemoteParticipant>,
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>, continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
): Promise<void> { ): Promise<void> {
const remoteParticipant = mockRemoteParticipant(participant); const remoteParticipant = mockRemoteParticipant(participant);
const vm = new RemoteUserMediaViewModel( const vm = new RemoteUserMediaViewModel(
"remote", "remote",
mockMatrixRoomMember(member), mockMatrixRoomMember(localRtcMember, roomMember),
remoteParticipant, of(remoteParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
@@ -239,3 +252,30 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
...config, ...config,
}); });
} }
export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap
> {
public constructor(
public readonly room: Room,
private localMembership: CallMembership,
public memberships: CallMembership[] = [],
) {
super();
}
public withMemberships(
rtcMembers: Observable<Partial<CallMembership>[]>,
): MockRTCSession {
rtcMembers.subscribe((m) => {
const old = this.memberships;
// always prepend the local participant
const updated = [this.localMembership, ...(m as CallMembership[])];
this.memberships = updated;
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
});
return this;
}
}

View File

@@ -16,10 +16,7 @@ import {
EventTimelineSet, EventTimelineSet,
Room, Room,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ReactionsProvider } from "../useReactions"; import { ReactionsProvider } from "../useReactions";
import { import {
@@ -27,6 +24,7 @@ import {
ElementCallReactionEventType, ElementCallReactionEventType,
ReactionOption, ReactionOption,
} from "../reactions"; } from "../reactions";
import { MockRTCSession } from "./test";
export const TestReactionsWrapper = ({ export const TestReactionsWrapper = ({
rtcSession, rtcSession,
@@ -41,40 +39,6 @@ export const TestReactionsWrapper = ({
); );
}; };
export class MockRTCSession extends EventEmitter {
public memberships: {
sender: string;
eventId: string;
createdTs: () => Date;
}[];
public constructor(
public readonly room: MockRoom,
membership: Record<string, string>,
) {
super();
this.memberships = Object.entries(membership).map(([eventId, sender]) => ({
sender,
eventId,
createdTs: (): Date => new Date(),
}));
}
public testRemoveMember(userId: string): void {
this.memberships = this.memberships.filter((u) => u.sender !== userId);
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
public testAddMember(sender: string): void {
this.memberships.push({
sender,
eventId: `!fake-${randomUUID()}:event`,
createdTs: (): Date => new Date(),
});
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
}
export function createHandRaisedReaction( export function createHandRaisedReaction(
parentMemberEvent: string, parentMemberEvent: string,
membershipOrOverridenSender: Record<string, string> | string, membershipOrOverridenSender: Record<string, string> | string,

View File

@@ -17,20 +17,20 @@ import "vitest-axe/extend-expect";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import EN_GB from "../locales/en-GB/app.json"; import EN from "../locales/en/app.json";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
// Bare-minimum i18n config // Bare-minimum i18n config
i18n i18n
.use(initReactI18next) .use(initReactI18next)
.init({ .init({
lng: "en-GB", lng: "en",
fallbackLng: "en-GB", fallbackLng: "en",
supportedLngs: ["en-GB"], supportedLngs: ["en"],
// We embed the translations, so that it never needs to fetch // We embed the translations, so that it never needs to fetch
resources: { resources: {
"en-GB": { en: {
app: EN_GB, app: EN,
}, },
}, },
interpolation: { interpolation: {

View File

@@ -3051,12 +3051,12 @@
dependencies: dependencies:
undici-types "~6.19.8" undici-types "~6.19.8"
"@types/node@^20.0.0": "@types/node@^22.0.0":
version "20.17.9" version "22.10.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.9.tgz#5f141d4b7ee125cdee5faefe28de095398865bab" resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.1.tgz#41ffeee127b8975a05f8c4f83fb89bcb2987d766"
integrity sha512-0JOXkRyLanfGPE2QRCwgxhzlBAvaRdCNMcvbd7jFfpmD4eEXll7LRwy5ymJmyeZqk7Nh7eD2LeUyQ68BbndmXw== integrity sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==
dependencies: dependencies:
undici-types "~6.19.2" undici-types "~6.20.0"
"@types/normalize-package-data@^2.4.0": "@types/normalize-package-data@^2.4.0":
version "2.4.4" version "2.4.4"
@@ -3307,12 +3307,10 @@
dependencies: dependencies:
"@use-gesture/core" "10.3.1" "@use-gesture/core" "10.3.1"
"@vector-im/compound-design-tokens@^1.9.1": "@vector-im/compound-design-tokens@^2.0.0":
version "1.9.2" version "2.1.1"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.9.2.tgz#0b76e5475da3bc36443f7dc87951b937b5013d6f" resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-2.1.1.tgz#d6175a99fe4b97688464126f255386990f3048d6"
integrity sha512-gQmK4dHR2iws3ZskDv8Il6A4/rvQV7TPSmEOXLsahDhBTInWqexXeQnNRSt9Z5DsLPrkxL3/KoCt9lfYu/yiag== integrity sha512-QnUi2K14D9KTXxcLQKUU3V75cforZLMwhaaJDNftT8F5mG86950hAM+qhgDNEpEU+pkTffQj0/g/5859YmqWzQ==
dependencies:
prettier "^3.3.3"
"@vector-im/compound-web@^7.2.0": "@vector-im/compound-web@^7.2.0":
version "7.4.0" version "7.4.0"
@@ -7007,7 +7005,7 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.0.0, prettier@^3.3.3: prettier@^3.0.0:
version "3.4.1" version "3.4.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.1.tgz#e211d451d6452db0a291672ca9154bc8c2579f7b" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.1.tgz#e211d451d6452db0a291672ca9154bc8c2579f7b"
integrity sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg== integrity sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==
@@ -8262,11 +8260,16 @@ underscore.string@~3.3.4:
sprintf-js "^1.1.1" sprintf-js "^1.1.1"
util-deprecate "^1.0.2" util-deprecate "^1.0.2"
undici-types@~6.19.2, undici-types@~6.19.8: undici-types@~6.19.8:
version "6.19.8" version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
undici-types@~6.20.0:
version "6.20.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433"
integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==
undici@^5.25.4: undici@^5.25.4:
version "5.28.4" version "5.28.4"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068" resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068"
@@ -8411,9 +8414,9 @@ value-or-function@^4.0.0:
integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg== integrity sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg==
vaul@^1.0.0: vaul@^1.0.0:
version "1.1.0" version "1.1.1"
resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.0.tgz#7da4bc965e0b184ada632f1208096b0f5575d920" resolved "https://registry.yarnpkg.com/vaul/-/vaul-1.1.1.tgz#93aceaad16f7c53aacf28a2609b2dd43b5a91fa0"
integrity sha512-YhO/bikcauk48hzhMhvIvT+U87cuCbNbKk9fF4Ou5UkI9t2KkBMernmdP37pCzF15hrv55fcny1YhexK8h6GVQ== integrity sha512-+ejzF6ffQKPcfgS7uOrGn017g39F8SO4yLPXbBhpC7a0H+oPqPna8f1BUfXaz8eU4+pxbQcmjxW+jWBSbxjaFg==
dependencies: dependencies:
"@radix-ui/react-dialog" "^1.1.1" "@radix-ui/react-dialog" "^1.1.1"