Merge branch 'main' into valere/component_ec_M1

This commit is contained in:
Timo K.
2026-09-08 20:05:27 +02:00
22 changed files with 2139 additions and 1528 deletions
+11
View File
@@ -39,6 +39,17 @@
"jsdoc/check-property-names": "error", "jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn", "jsdoc/require-param-description": "warn",
"react/display-name": "error", "react/display-name": "error",
// TODO: These React Compiler-based rules joined oxlint's correctness
// category in v1.81 and flag many pre-existing patterns, including hooks
// like useLatest/useInitial/useReactiveState that intentionally access
// refs during render. Re-evaluate them when adopting the React Compiler.
"react/immutability": "off",
"react/no-deriving-state-in-effects": "off",
"react/preserve-manual-memoization": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/static-components": "off",
"react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness. // TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error", // "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [ // "rxjs/finnish": [
+2 -2
View File
@@ -13,7 +13,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z - ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web: element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
element-web-1: element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates. # Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
+1
View File
@@ -116,6 +116,7 @@
"peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.", "peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.",
"room_creation_restricted": "Failed to create call", "room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.", "room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"screen_share_failed": "Could not start screen sharing",
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls", "sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.", "sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin." "unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
+2 -2
View File
@@ -88,7 +88,7 @@
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5", "@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.10", "@vitest/ui": "4.1.11",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
@@ -108,7 +108,7 @@
"node-stdlib-browser": "^1.3.1", "node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3", "observable-hooks": "^4.2.3",
"oxfmt": "^0.61.0", "oxfmt": "^0.66.0",
"oxlint": "^1.70.0", "oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0", "oxlint-tsgolint": "^0.25.0",
"pako": "^2.0.4", "pako": "^2.0.4",
+1 -1
View File
@@ -153,7 +153,7 @@ export const widgetTest = test.extend<MyFixtures>({
// Accept the DM invite from brooks // Accept the DM invite from brooks
// This how playwright record selects the DM invite in the room list // This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("option", { name: "Open room" }).click(); await ewPage2.getByRole("button", { name: "Open room" }).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click(); await ewPage2.getByRole("button", { name: "Start chatting" }).click();
} }
+1475 -1476
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -284,6 +284,7 @@ export const InCallView: FC<InCallViewProps> = ({
const audioParticipants = useBehavior(vm.livekitRoomItems$); const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$); const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$); const reconnecting = useBehavior(vm.reconnecting$);
const screenShareError = useBehavior(vm.screenShareError$);
const layout = useBehavior(vm.layout$); const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$); const edgeToEdge = useBehavior(vm.edgeToEdge$);
const overflowing = useBehavior(vm.overflowing$); const overflowing = useBehavior(vm.overflowing$);
@@ -413,6 +414,21 @@ export const InCallView: FC<InCallViewProps> = ({
); );
} }
const onDismissScreenShareToast = useCallback(
() => vm.dismissScreenShareError(),
[vm],
);
const screenShareToast = (
<Toast
onDismiss={onDismissScreenShareToast}
open={screenShareError !== null}
autoDismiss={5000}
modal={false}
>
{t("error.screen_share_failed")}
</Toast>
);
// The reconnecting toast cannot be dismissed // The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []); const onDismissReconnectingToast = useCallback(() => {}, []);
// We need to use a non-modal toast to avoid trapping focus within the toast. // We need to use a non-modal toast to avoid trapping focus within the toast.
@@ -651,6 +667,7 @@ export const InCallView: FC<InCallViewProps> = ({
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} /> <ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} /> <RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
{reconnectingToast} {reconnectingToast}
{screenShareToast}
{earpieceOverlay} {earpieceOverlay}
<ReactionsOverlay vm={vm} /> <ReactionsOverlay vm={vm} />
{footer} {footer}
+10 -10
View File
@@ -172,7 +172,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_settingsLogoContainer_20b7b4" class="_settingsLogoContainer_20b7b4"
> >
<button <button
aria-labelledby="_r_8_" aria-labelledby="_r_b_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4" class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary" data-kind="secondary"
data-testid="settings-bottom-left" data-testid="settings-bottom-left"
@@ -307,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_buttons_20b7b4" class="_buttons_20b7b4"
> >
<button <button
aria-labelledby="_r_d_" aria-labelledby="_r_g_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -332,7 +332,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_i_" aria-labelledby="_r_l_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -357,7 +357,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-busy="false" aria-busy="false"
aria-checked="false" aria-checked="false"
aria-disabled="true" aria-disabled="true"
aria-labelledby="_r_n_" aria-labelledby="_r_q_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -382,7 +382,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby="_r_s_" aria-labelledby="_r_v_"
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53" class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -405,7 +405,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="_r_14_" aria-labelledby="_r_17_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110" class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -433,8 +433,8 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg" data-size="lg"
> >
<input <input
aria-labelledby="_r_1a_" aria-labelledby="_r_1d_"
name="_r_19_" name="_r_1c_"
type="radio" type="radio"
value="spotlight" value="spotlight"
/> />
@@ -453,9 +453,9 @@ exports[`InCallView > rendering > renders 1`] = `
/> />
</svg> </svg>
<input <input
aria-labelledby="_r_1f_" aria-labelledby="_r_1i_"
checked="" checked=""
name="_r_19_" name="_r_1c_"
type="radio" type="radio"
value="grid" value="grid"
/> />
+52
View File
@@ -0,0 +1,52 @@
/*
Copyright 2026 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, expect, it, vi } from "vitest";
import { init as initRageshake } from "./rageshake";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("flushes logs to IndexedDB periodically without an explicit flush", async () => {
vi.useFakeTimers();
const add = vi.fn();
const txn = {
oncomplete: undefined as (() => void) | undefined,
onerror: undefined,
objectStore: (name: string) =>
name === "logs"
? {
add: (entry: unknown): void => {
add(entry);
queueMicrotask(() => txn.oncomplete?.());
},
}
: { put: vi.fn() },
};
const open = (): unknown => {
const req = {
result: { transaction: () => txn },
onsuccess: undefined as (() => void) | undefined,
};
queueMicrotask(() => req.onsuccess?.());
return req;
};
vi.stubGlobal("indexedDB", { open });
await initRageshake();
global.mx_rage_logger.log(1, "test", "hello from the buffer");
expect(add).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(add).toHaveBeenCalledOnce();
expect(add.mock.calls[0][0]).toMatchObject({
lines: expect.stringContaining("hello from the buffer"),
});
});
+7 -4
View File
@@ -204,10 +204,13 @@ class IndexedDBLogStore {
// Throttled function to flush logs. We use throttle rather // Throttled function to flush logs. We use throttle rather
// than debounce as we want logs to be written regularly, otherwise // than debounce as we want logs to be written regularly, otherwise
// if there's a constant stream of logging, we'd never write anything. // if there's a constant stream of logging, we'd never write anything.
private throttledFlush = throttle(() => this.flush, MAX_FLUSH_INTERVAL_MS, { private throttledFlush = throttle(
leading: false, () => {
trailing: true, this.flush().catch((e) => logger.error("Failed to flush logs", e));
}); },
MAX_FLUSH_INTERVAL_MS,
{ leading: false, trailing: true },
);
/** /**
* Flush logs to disk. * Flush logs to disk.
+7
View File
@@ -336,6 +336,11 @@ export interface CallViewModel {
* Whether we are sharing our screen. * Whether we are sharing our screen.
*/ */
sharingScreen$: Behavior<boolean>; sharingScreen$: Behavior<boolean>;
/**
* The last error from toggling screen sharing, until dismissed.
*/
screenShareError$: Behavior<Error | null>;
dismissScreenShareError: () => void;
// UI interactions // UI interactions
/** /**
@@ -1926,6 +1931,8 @@ export function createCallViewModel$(
reconnecting$: localMembership.reconnecting$, reconnecting$: localMembership.reconnecting$,
livekitRoomItems$, livekitRoomItems$,
connected$: localMembership.connected$, connected$: localMembership.connected$,
screenShareError$: localMembership.screenShareError$,
dismissScreenShareError: localMembership.dismissScreenShareError,
}; };
} }
@@ -31,6 +31,7 @@ import {
flushPromises, flushPromises,
mockConfig, mockConfig,
mockLivekitRoom, mockLivekitRoom,
mockLocalParticipant,
mockMuteStates, mockMuteStates,
withTestScheduler, withTestScheduler,
ownMemberMock, ownMemberMock,
@@ -41,6 +42,7 @@ import {
enterRTCSession, enterRTCSession,
PublishState, PublishState,
TrackState, TrackState,
watchScreenShareToggle,
} from "./LocalMember"; } from "./LocalMember";
import { import {
FailToGetOpenIdToken, FailToGetOpenIdToken,
@@ -69,6 +71,31 @@ vi.mock("@livekit/components-core", () => ({
.mockReturnValue(of({ isScreenShareEnabled: false })), .mockReturnValue(of({ isScreenShareEnabled: false })),
})); }));
describe("watchScreenShareToggle", () => {
it("reports nothing when the toggle completes", async () => {
const onError = vi.fn();
watchScreenShareToggle(Promise.resolve(), true, logger, onError);
await flushPromises();
expect(onError).not.toHaveBeenCalled();
});
it("reports failures other than the user cancelling", async () => {
const onError = vi.fn();
const e = new Error("NotReadableError");
watchScreenShareToggle(Promise.reject(e), true, logger, onError);
await flushPromises();
expect(onError).toHaveBeenCalledWith(e);
});
it("does not report the user cancelling the picker", async () => {
const onError = vi.fn();
const cancelled = new DOMException("Permission denied", "NotAllowedError");
watchScreenShareToggle(Promise.reject(cancelled), true, logger, onError);
await flushPromises();
expect(onError).not.toHaveBeenCalled();
});
});
describe("LocalMembership", () => { describe("LocalMembership", () => {
describe("enterRTCSession", () => { describe("enterRTCSession", () => {
it("It joins the correct Session", () => { it("It joins the correct Session", () => {
@@ -857,4 +884,98 @@ describe("LocalMembership", () => {
scope.end(); scope.end();
}); });
}); });
describe("toggleScreenSharing", () => {
let originalMediaDevices: MediaDevices | undefined;
beforeAll(() => {
mockConfig();
// Screen sharing is only offered when getDisplayMedia is available.
originalMediaDevices = navigator.mediaDevices;
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getDisplayMedia: vi.fn() },
});
});
afterAll(() => {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: originalMediaDevices,
});
});
const createMembershipWithConnection = (
connection: Connection | null,
): {
scope: ObservableScope;
localMembership: ReturnType<typeof createLocalMembership$>;
} => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
if (connection) connectionManagerData.add(connection, []);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: new BehaviorSubject({
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
}),
});
return { scope, localMembership };
};
it("surfaces a failure and clears it on dismiss", async () => {
const error = new Error("NotReadableError");
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = {
state$: constant(ConnectionState.LivekitConnected),
transport: aTransport,
livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({
isScreenShareEnabled: false,
setScreenShareEnabled,
}),
}),
} as unknown as Connection;
const { scope, localMembership } =
createMembershipWithConnection(connection);
await flushPromises();
expect(localMembership.toggleScreenSharing).not.toBeNull();
expect(localMembership.screenShareError$.value).toBeNull();
localMembership.toggleScreenSharing!();
await flushPromises();
expect(setScreenShareEnabled).toHaveBeenCalledWith(
true,
expect.any(Object),
undefined,
);
expect(localMembership.screenShareError$.value).toBe(error);
localMembership.dismissScreenShareError();
expect(localMembership.screenShareError$.value).toBeNull();
scope.end();
});
it("does nothing when there is no local participant", async () => {
// No connection means participant$ never resolves to a participant.
const { scope, localMembership } = createMembershipWithConnection(null);
await flushPromises();
expect(localMembership.toggleScreenSharing).not.toBeNull();
localMembership.toggleScreenSharing!();
await flushPromises();
expect(localMembership.screenShareError$.value).toBeNull();
scope.end();
});
});
}); });
@@ -206,6 +206,11 @@ export const createLocalMembership$ = ({
* Callback to toggle screen sharing. If null, screen sharing is not possible. * Callback to toggle screen sharing. If null, screen sharing is not possible.
*/ */
toggleScreenSharing: (() => void) | null; toggleScreenSharing: (() => void) | null;
/**
* The last error from toggling screen sharing, until dismissed.
*/
screenShareError$: Behavior<Error | null>;
dismissScreenShareError: () => void;
// tracks$: Behavior<LocalTrack[]>; // tracks$: Behavior<LocalTrack[]>;
participant$: Behavior<LocalParticipant | null>; participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>; connection$: Behavior<Connection | null>;
@@ -711,6 +716,7 @@ export const createLocalMembership$ = ({
), ),
); );
const screenShareError$ = new BehaviorSubject<Error | null>(null);
let toggleScreenSharing: (() => void) | null = null; let toggleScreenSharing: (() => void) | null = null;
if ( if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) && "getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
@@ -782,13 +788,18 @@ export const createLocalMembership$ = ({
// We also allow screen sharing to be toggled even if the connection // We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no // is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can. // technical reason to disallow this. LiveKit will publish if it can.
participant$.value const participant = participant$.value;
?.setScreenShareEnabled( if (!participant) return;
watchScreenShareToggle(
participant.setScreenShareEnabled(
targetScreenshareState, targetScreenshareState,
screenshareSettings, screenshareSettings,
publishOptions, publishOptions,
) ),
.catch(logger.error); targetScreenshareState,
logger,
(e) => screenShareError$.next(e),
);
}; };
} }
@@ -807,11 +818,44 @@ export const createLocalMembership$ = ({
), ),
sharingScreen$, sharingScreen$,
toggleScreenSharing, toggleScreenSharing,
screenShareError$,
dismissScreenShareError: () => screenShareError$.next(null),
connection$: localConnection$, connection$: localConnection$,
internalLoggerRef: logger, internalLoggerRef: logger,
}; };
}; };
/**
* Logs the outcome of a screen share toggle and reports failures.
*
* getDisplayMedia may legitimately take a long time (the user is choosing
* what to share) or never settle at all, so nothing is inferred from silence:
* the request and its completion are logged with the elapsed time so that a
* hang is visible in the logs, and only an explicit rejection is reported.
*
* The user cancelling the picker rejects with a NotAllowedError; that is
* logged but not reported.
*/
export function watchScreenShareToggle(
toggle: Promise<unknown>,
enable: boolean,
logger: Logger,
onError: (e: Error) => void,
): void {
const what = `Screen share ${enable ? "start" : "stop"}`;
const started = Date.now();
const elapsed = (): string => `${Date.now() - started} ms`;
logger.info(`${what} requested`);
toggle.then(
() => logger.info(`${what} completed in ${elapsed()}`),
(e: unknown) => {
logger.error(`${what} failed after ${elapsed()}:`, e);
if (e instanceof DOMException && e.name === "NotAllowedError") return;
onError(e instanceof Error ? e : new Error(String(e)));
},
);
}
export function observeSharingScreen$(p: Participant): Observable<boolean> { export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents( return observeParticipantEvents(
p, p,
@@ -19,14 +19,17 @@ import {
import { import {
type LocalParticipant, type LocalParticipant,
type RemoteParticipant, type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
type Room as LivekitRoom, type Room as LivekitRoom,
RoomEvent, RoomEvent,
Track,
ConnectionState as LivekitConnectionState, 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 { logger, type Logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { import {
@@ -509,6 +512,97 @@ describe("remote participants", () => {
}); });
}); });
describe("remote track logging", () => {
it("logs remote participant and track events on the connection logger", () => {
setupTest();
const info = vi.fn();
const warn = vi.fn();
const testLogger = {
getChild: (): unknown => testLogger,
info,
debug: vi.fn(),
warn,
error: vi.fn(),
} as unknown as Logger;
new Connection(
{
client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
},
testLogger,
);
info.mockClear(); // drop the constructor log line
const bob = mockRemoteParticipant({
identity: "@bob:example.org:DEV111",
sid: "PA_bob",
});
const pub = {
kind: "audio",
source: "microphone",
trackSid: "TR_mic",
isMuted: false,
isEncrypted: true,
} as unknown as RemoteTrackPublication;
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob);
fakeLivekitRoom.emit(
RoomEvent.TrackSubscribed,
{} as RemoteTrack,
pub,
bob,
);
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob);
fakeLivekitRoom.emit(
RoomEvent.TrackStreamStateChanged,
pub,
Track.StreamState.Paused,
bob,
);
expect(messages()).toEqual([
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
]);
// Encryption status changes are logged; cryptor errors are warnings
fakeLivekitRoom.emit(
RoomEvent.ParticipantEncryptionStatusChanged,
false,
bob,
);
expect(messages().at(-1)).toBe(
"Encryption status of @bob:example.org:DEV111: encrypted=false",
);
const cryptorError = new Error("missing key at index 3");
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
expect(warn).toHaveBeenCalledWith(
"Encryption error for @bob:example.org:DEV111:",
cryptorError,
);
// Local mute events are already logged by the Publisher
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
...fakeLocalParticipant,
isLocal: true,
} as unknown as LocalParticipant);
expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1);
// Listeners are removed when the scope ends
testScope.end();
fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob);
expect(
messages().filter((m) => m.startsWith("Participant disconnected")),
).toHaveLength(0);
});
});
// //
// NOT USED ANYMORE ? // NOT USED ANYMORE ?
// //
@@ -13,8 +13,14 @@ import {
import { import {
ConnectionError, ConnectionError,
ConnectionErrorReason, ConnectionErrorReason,
type Participant,
type RemoteParticipant, type RemoteParticipant,
type RemoteTrackPublication,
type Room as LivekitRoom, type Room as LivekitRoom,
RoomEvent,
type SubscriptionError,
type Track,
type TrackPublication,
} from "livekit-client"; } from "livekit-client";
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
@@ -164,6 +170,7 @@ export class Connection {
// Only tracks remote participants // Only tracks remote participants
connectedParticipantsObserver(this.livekitRoom), connectedParticipantsObserver(this.livekitRoom),
); );
this.logRemoteTrackEvents();
scope.onEnd(() => { scope.onEnd(() => {
this.logger.info(`Connection scope ended, stopping connection`); this.logger.info(`Connection scope ended, stopping connection`);
@@ -171,6 +178,114 @@ export class Connection {
}); });
} }
/**
* Logs the lifecycle of remote participants and their tracks as seen by
* livekit-client, so that rageshakes show what a tile should have been
* rendering (published, subscribed, muted, paused) for each remote member.
*/
private logRemoteTrackEvents(): void {
const room = this.livekitRoom;
const log = this.logger.getChild("[RemoteTracks]");
// The encryption flag matters: if the publisher encrypts but this client
// believes the track is unencrypted, the cryptor is bypassed and raw
// ciphertext reaches the decoder (audible as loud noise bursts).
const track = (pub: TrackPublication, p: Participant): string =>
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
const onParticipantConnected = (p: RemoteParticipant): void =>
log.info(`Participant connected: ${p.identity} (${p.sid})`);
const onParticipantDisconnected = (p: RemoteParticipant): void =>
log.info(`Participant disconnected: ${p.identity} (${p.sid})`);
const onTrackPublished = (
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`);
const onTrackUnpublished = (
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Unpublished: ${track(pub, p)}`);
const onTrackSubscribed = (
_t: Track,
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`);
const onTrackUnsubscribed = (
_t: Track,
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Unsubscribed: ${track(pub, p)}`);
const onTrackSubscriptionFailed = (
trackSid: string,
p: RemoteParticipant,
reason?: SubscriptionError,
): void =>
log.warn(
`Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`,
);
// Mute events also fire for the local participant, which the Publisher
// already logs.
const onTrackMuted = (pub: TrackPublication, p: Participant): void => {
if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`);
};
const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => {
if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`);
};
const onTrackStreamStateChanged = (
pub: RemoteTrackPublication,
state: Track.StreamState,
p: RemoteParticipant,
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
const onEncryptionStatusChanged = (
encrypted: boolean,
p?: Participant,
): void =>
log.info(
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
);
// livekit-client throttles these per cryptor; they indicate frames being
// dropped (missing/invalid key).
const onEncryptionError = (error: Error, p?: Participant): void =>
log.warn(
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
error,
);
room
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
.on(RoomEvent.TrackPublished, onTrackPublished)
.on(RoomEvent.TrackUnpublished, onTrackUnpublished)
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.on(RoomEvent.TrackMuted, onTrackMuted)
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.on(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.on(RoomEvent.EncryptionError, onEncryptionError);
this.scope.onEnd(() => {
room
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
.off(RoomEvent.TrackPublished, onTrackPublished)
.off(RoomEvent.TrackUnpublished, onTrackUnpublished)
.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.off(RoomEvent.TrackMuted, onTrackMuted)
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.off(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.off(RoomEvent.EncryptionError, onEncryptionError);
});
}
/** /**
* Starts the connection. * Starts the connection.
* *
@@ -14,7 +14,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts"; import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
import { import {
createConnectionManager$, createConnectionManager$,
type ConnectionManagerData, ConnectionManagerData,
} from "./ConnectionManager.ts"; } from "./ConnectionManager.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts"; import { type Connection } from "./Connection.ts";
@@ -203,6 +203,24 @@ describe("connections$ stream", () => {
}); });
}); });
describe("ConnectionManagerData", () => {
test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionManagerData(logger);
const connection = { transport: TRANSPORT_1 } as unknown as Connection;
const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant;
data.add(connection, [p("a")]);
data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
"second connection to https://lk.example.org: existing [a], adding [b]",
),
);
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
});
});
describe("connectionManagerData$ stream", () => { describe("connectionManagerData$ stream", () => {
// Used in test to control fake connections' remoteParticipants$ streams // Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>; let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
@@ -30,7 +30,7 @@ export class ConnectionManagerData {
{ connection: Connection; participants: RemoteParticipant[] } { connection: Connection; participants: RemoteParticipant[] }
> = new Map(); > = new Map();
public constructor() {} public constructor(private readonly logger?: Logger) {}
public add(connection: Connection, participants: RemoteParticipant[]): void { public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport); const key = this.getKey(connection.transport);
@@ -38,6 +38,11 @@ export class ConnectionManagerData {
if (!existing) { if (!existing) {
this.store.set(key, { connection, participants }); this.store.set(key, { connection, participants });
} else { } else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants); existing.participants.push(...participants);
} }
} }
@@ -239,7 +244,7 @@ export function createConnectionManager$({
lists.reduce((data, { connection, participants }) => { lists.reduce((data, { connection, participants }) => {
data.add(connection, participants); data.add(connection, participants);
return data; return data;
}, new ConnectionManagerData()), }, new ConnectionManagerData(logger)),
epoch, epoch,
), ),
), ),
@@ -5,7 +5,8 @@ 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, test, expect, beforeEach, afterEach } from "vitest"; import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { import {
type CallMembership, type CallMembership,
type LivekitTransport, type LivekitTransport,
@@ -77,6 +78,7 @@ function epochMeWith$<T, U>(
} }
test("should signal participant not yet connected to livekit", async () => { test("should signal participant not yet connected to livekit", async () => {
const info = vi.spyOn(logger, "info");
const mockedMemberships$ = new BehaviorSubject([bobMembership]); const mockedMemberships$ = new BehaviorSubject([bobMembership]);
const mockConnectionManagerData$ = new BehaviorSubject( const mockConnectionManagerData$ = new BehaviorSubject(
new ConnectionManagerData(), new ConnectionManagerData(),
@@ -108,6 +110,9 @@ test("should signal participant not yet connected to livekit", async () => {
return true; return true;
}, },
); );
expect(info).toHaveBeenCalledWith(
"[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing on no connection",
);
}); });
// Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable. // Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable.
@@ -11,6 +11,7 @@ import {
type LivekitTransportConfig, type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, filter, map } from "rxjs"; import { combineLatest, filter, map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior"; import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager"; import { type IConnectionManager } from "./ConnectionManager";
@@ -110,13 +111,17 @@ export function createRemoteMatrixLivekitMembers$({
const participants = transport const participants = transport
? managerData.getParticipantsForTransport(transport) ? managerData.getParticipantsForTransport(transport)
: []; : [];
const participant = const matches = participants.filter(
participants.find( (p) => p.identity == membership.rtcBackendIdentity,
(p) => p.identity == membership.rtcBackendIdentity, );
) ?? null; const participant = matches[0] ?? null;
const connection = transport const connection = transport
? managerData.getConnectionForTransport(transport) ? managerData.getConnectionForTransport(transport)
: null; : null;
if (matches.length > 1)
logger.warn(
`[RemoteMatrixLivekitMembers] ${membership.rtcBackendIdentity}: ${matches.length} LiveKit participants match (sids ${matches.map((p) => p.sid).join(", ")}), using ${participant?.sid}`,
);
yield { yield {
// This could just be the backend identity without the other keys. // This could just be the backend identity without the other keys.
@@ -133,8 +138,16 @@ export function createRemoteMatrixLivekitMembers$({
} }
}, },
// Each update where the key of the generator array do not change will result in updates to the `data$` behavior. // Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
(scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => { (scope, data$, userId, _deviceId, _memberId, rtcBackendIdentity) => {
const { participant$, ...rest } = scope.splitBehavior(data$); const { participant$, ...rest } = scope.splitBehavior(data$);
// Log whether the member could be matched to a LiveKit participant,
// since a tile shows "waiting for media" for as long as it cannot.
participant$.pipe(scope.bind()).subscribe((p) => {
const url = data$.value.connection?.transport.livekit_service_url;
logger.info(
`[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${url ?? "no connection"}`,
);
});
// will only get called once per backend identity. // will only get called once per backend identity.
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent. // updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
return { return {
+59 -5
View File
@@ -16,7 +16,7 @@ import {
ObservableScope, ObservableScope,
trackEpoch, trackEpoch,
} from "./ObservableScope"; } from "./ObservableScope";
import { withTestScheduler } from "../utils/test"; import { testScope, withTestScheduler } from "../utils/test";
describe("Epoch", () => { describe("Epoch", () => {
it("should map the value correctly", () => { it("should map the value correctly", () => {
@@ -87,7 +87,7 @@ describe("Epoch", () => {
}); });
it("behavior test", () => { it("behavior test", () => {
const scope = new ObservableScope(); const scope = testScope();
const s$ = new Subject(); const s$ = new Subject();
const behavior$ = scope.behavior(s$, 0); const behavior$ = scope.behavior(s$, 0);
behavior$.subscribe((value) => { behavior$.subscribe((value) => {
@@ -115,7 +115,7 @@ describe("Reconcile", () => {
it("should wait clean up before processing next", async () => { it("should wait clean up before processing next", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const scope = new ObservableScope(); const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0); const behavior$ = new BehaviorSubject<number>(0);
const setup = vi.fn().mockImplementation(async () => await sleep(100)); const setup = vi.fn().mockImplementation(async () => await sleep(100));
@@ -149,7 +149,7 @@ describe("Reconcile", () => {
it("should skip intermediates values that are not setup", async () => { it("should skip intermediates values that are not setup", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const scope = new ObservableScope(); const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0); const behavior$ = new BehaviorSubject<number>(0);
const setup = vi const setup = vi
@@ -194,7 +194,7 @@ describe("Reconcile", () => {
it("should wait for setup to complete before starting cleanup", async () => { it("should wait for setup to complete before starting cleanup", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const scope = new ObservableScope(); const scope = testScope();
const behavior$ = new BehaviorSubject<number>(0); const behavior$ = new BehaviorSubject<number>(0);
const setup = vi const setup = vi
@@ -237,3 +237,57 @@ describe("Reconcile", () => {
expect(setup).toHaveBeenCalledWith(1); expect(setup).toHaveBeenCalledWith(1);
}); });
}); });
describe("behavior", () => {
it("warns with the tag and nesting depth, logging each new depth once", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = testScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0, "tagged");
// Re-enter once on value 1 (depth 2), then a nested re-entry on value 2
// (depth 3), which is the shape needed to strand a middle subscriber.
behavior$.subscribe((v) => {
if (v === 1) source$.next(2);
});
behavior$.subscribe((v) => {
if (v === 2) source$.next(3);
});
const seen: number[] = [];
behavior$.subscribe((v) => seen.push(v));
source$.next(1);
const messages = warn.mock.calls.map((c) => c[0] as string);
expect(messages).toEqual([
expect.stringContaining("Behavior (tagged) re-entered at depth 2"),
expect.stringContaining("Behavior (tagged) re-entered at depth 3"),
]);
warn.mock.calls.forEach((c) => expect(c[1]).toEqual(expect.any(String)));
// The behavior settles on the newest value while the last subscriber is
// left stranded on the oldest.
expect(behavior$.value).toBe(3);
expect(seen.at(-1)).toBe(1);
});
it("warns when the scope is ended while a behavior is mid-delivery", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = new ObservableScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0);
behavior$.subscribe((v) => {
if (v === 1) scope.end();
});
// A derived behavior is bound to the scope, so ending the scope
// mid-delivery unsubscribes it before the value reaches it.
const derived$ = scope.behavior(behavior$);
source$.next(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Scope ended while 1 of its behaviors"),
expect.any(String),
);
expect(behavior$.value).toBe(1);
expect(derived$.value).toBe(0);
});
});
+50 -4
View File
@@ -20,6 +20,8 @@ import {
takeUntil, takeUntil,
} from "rxjs"; } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior"; import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>; type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
@@ -34,6 +36,12 @@ const nothing = Symbol("nothing");
* A scope which limits the execution lifetime of its bound Observables. * A scope which limits the execution lifetime of its bound Observables.
*/ */
export class ObservableScope { export class ObservableScope {
/**
* Number of this scope's behaviors currently mid-delivery. Used to detect a
* scope being torn down synchronously from within one of its own emissions.
*/
private delivering = 0;
private readonly ended$ = new BehaviorSubject(false); private readonly ended$ = new BehaviorSubject(false);
private readonly bindImpl: MonoTypeOperator = takeUntil( private readonly bindImpl: MonoTypeOperator = takeUntil(
@@ -66,6 +74,9 @@ export class ObservableScope {
public behavior<T>( public behavior<T>(
setValue$: Observable<T>, setValue$: Observable<T>,
initialValue: T | typeof nothing = nothing, initialValue: T | typeof nothing = nothing,
// An optional label used only in the re-entrancy warning below, so that a
// torn behavior can be identified from a rageshake.
tag?: string,
): Behavior<T> { ): Behavior<T> {
const subject$ = new BehaviorSubject(initialValue); const subject$ = new BehaviorSubject(initialValue);
// Push values from the Observable into the BehaviorSubject. // Push values from the Observable into the BehaviorSubject.
@@ -73,11 +84,37 @@ export class ObservableScope {
// they will no longer re-emit their current value upon subscription. We want // they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to // to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event. // take care to not propagate the completion event.
// If a subscriber synchronously causes this same behavior to emit again,
// rxjs delivers the nested value to every subscriber first and then
// resumes delivering the outer (older) value to the remaining subscribers,
// leaving them permanently out of sync with the others. A single such
// re-entry can only strand a contiguous run of subscribers; stranding a
// subscriber in the middle of the list needs a nested (deeper) re-entry, so
// we report the depth and log each new depth (with a stack trace) rather
// than only the first occurrence, to make a nested re-entry visible.
let depth = 0;
let maxReportedDepth = 1;
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({ setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next(value) { next: (value) => {
subject$.next(value); if (depth > 0 && depth + 1 > maxReportedDepth) {
maxReportedDepth = depth + 1;
logger.warn(
`Behavior${tag ? ` (${tag})` : ""} re-entered at depth ${
depth + 1
} while delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
}
depth++;
this.delivering++;
try {
subject$.next(value);
} finally {
depth--;
this.delivering--;
}
}, },
error(err: unknown) { error: (err: unknown) => {
subject$.error(err); subject$.error(err);
}, },
}); });
@@ -90,6 +127,11 @@ export class ObservableScope {
* Ends the scope, causing any bound Observables to complete. * Ends the scope, causing any bound Observables to complete.
*/ */
public end(): void { public end(): void {
if (this.delivering > 0)
logger.warn(
`Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
this.ended$.next(true); this.ended$.next(true);
} }
@@ -171,7 +213,11 @@ export class ObservableScope {
return Object.fromEntries( return Object.fromEntries(
Object.keys(input$.value).map((key) => [ Object.keys(input$.value).map((key) => [
`${key}$`, `${key}$`,
this.behavior(input$.pipe(map((input) => input[key as keyof T]))), this.behavior(
input$.pipe(map((input) => input[key as keyof T])),
nothing,
key,
),
]), ]),
) as SplitBehavior<T>; ) as SplitBehavior<T>;
} }
+16 -10
View File
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type RemoteParticipant } from "livekit-client"; import { type RemoteParticipant } from "livekit-client";
import { combineLatest, map, of, switchMap } from "rxjs"; import { combineLatest, map, of, switchMap } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior"; import { type Behavior } from "../Behavior";
import { createVolumeControls, type VolumeControls } from "../VolumeControls"; import { createVolumeControls, type VolumeControls } from "../VolumeControls";
@@ -45,6 +46,20 @@ export function createRemoteUserMedia(
statsType: "inbound-rtp", statsType: "inbound-rtp",
}); });
const waitingForMedia$ = scope.behavior(
combineLatest(
[inputs.livekitRoom$, inputs.participant$],
(livekitRoom, participant) =>
// If livekitRoom is undefined, the user is not attempting to publish on
// any transport and so we shouldn't expect a participant. (They might
// be a subscribe-only bot for example.)
livekitRoom !== undefined && participant === null,
),
);
waitingForMedia$.pipe(scope.bind()).subscribe((waiting) => {
logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`);
});
return { return {
...baseUserMedia, ...baseUserMedia,
...createVolumeControls(scope, { ...createVolumeControls(scope, {
@@ -68,15 +83,6 @@ export function createRemoteUserMedia(
), ),
), ),
), ),
waitingForMedia$: scope.behavior( waitingForMedia$,
combineLatest(
[inputs.livekitRoom$, inputs.participant$],
(livekitRoom, participant) =>
// If livekitRoom is undefined, the user is not attempting to publish on
// any transport and so we shouldn't expect a participant. (They might
// be a subscribe-only bot for example.)
livekitRoom !== undefined && participant === null,
),
),
}; };
} }