merge: upstream Element Call v0.28.0 (cinny #201) #61

Merged
jared merged 197 commits from lotus-upstream-v0.28.0 into lotus 2026-10-24 01:14:32 -04:00
10 changed files with 120 additions and 128 deletions
Showing only changes of commit bc6c232ef0 - Show all commits
+9 -4
View File
@@ -60,6 +60,7 @@ import {
initializeWidget,
} from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { createWidgetHostBridge } from "../src/HostBridge";
interface MatrixRTCSdk {
/**
@@ -113,6 +114,7 @@ export async function createMatrixRTCSdk(
const widget = _widget;
if (!widget) throw Error("No widget. This webapp can only start as a widget");
const client = await widget.client;
const hostBridge = createWidgetHostBridge(widget);
logger.info("client created");
// url params
@@ -132,10 +134,12 @@ export async function createMatrixRTCSdk(
controlledAudioDevices,
callIntent,
});
const muteStates = new MuteStates(scope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
const muteStates = new MuteStates(
scope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
hostBridge,
);
// call view model
const callViewModel = createCallViewModel$(
@@ -146,6 +150,7 @@ export async function createMatrixRTCSdk(
muteStates,
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
controlledAudioDevices,
callIntent,
},
+4
View File
@@ -29,6 +29,7 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge.ts";
import styles from "./InCallView.module.css";
import { GridTile } from "../tile/GridTile";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
@@ -116,6 +117,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => {
@@ -141,6 +143,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.muteStates,
{
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$,
@@ -171,6 +174,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeSystem,
props.onLeft,
urlParams,
hostBridge,
mediaDevices,
trackProcessorState$,
props.client,
+4 -1
View File
@@ -30,6 +30,7 @@ import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge.ts";
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
@@ -44,6 +45,7 @@ import { calculateInitialMuteState } from "../state/initialMuteState.ts";
export const RoomPage: FC = (): ReactNode => {
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -77,10 +79,11 @@ export const RoomPage: FC = (): ReactNode => {
urlParams.callIntent,
widget !== null,
),
hostBridge,
),
);
return (): void => scope.end();
}, [devices, urlParams]);
}, [devices, urlParams, hostBridge]);
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
+14 -16
View File
@@ -48,7 +48,6 @@ import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { v4 as uuidv4 } from "uuid";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
@@ -89,7 +88,7 @@ import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
layoutShallowEquals,
type Alignment,
@@ -173,6 +172,11 @@ import { type GridTileViewModel } from "../TileViewModel.ts";
// callMembership -> rtcMembership
export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem;
/**
* The application hosting Element Call, which can ask it to hang up and wants
* to know when the user joins or leaves. Defaults to no host.
*/
hostBridge?: HostBridge;
/**
* Whether the app hosting Element Call controls the audio output devices,
* rather than the browser. Defaults to false.
@@ -457,6 +461,7 @@ export function createCallViewModel$(
// Defaults match what the URL parameters resolve to outside of widget mode,
// so that callers which don't care (chiefly tests) behave as they always have.
const {
hostBridge = nullHostBridge,
controlledAudioDevices = false,
header = HeaderStyle.Standard,
showControls = true,
@@ -632,6 +637,7 @@ export function createCallViewModel$(
localTransport$,
roomId: matrixRoom.roomId,
hideScreensharing,
hostBridge,
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -923,24 +929,16 @@ export function createCallViewModel$(
const userHangup$ = new Subject<void>();
const widgetHangup$ =
widget === null
? NEVER
: (
fromEvent(
widget.lazyActions,
ElementWidgetActions.HangupCall,
) as Observable<CustomEvent<IWidgetApiRequest>>
).pipe(
tap((ev) => {
widget!.api.transport.reply(ev.detail, {});
}),
);
const hostHangup$ = hostBridge.hangUp$.pipe(
tap((request) => {
request.reply();
}),
);
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
merge(
autoLeave$,
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)),
).pipe(scope.share);
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
@@ -52,6 +52,7 @@ import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge";
import {
type LocalTransport,
type LocalTransportWithSFUConfig,
@@ -200,6 +201,7 @@ describe("LocalMembership", () => {
},
roomId: "!test-room-id:example.org",
hideScreensharing: false,
hostBridge: nullHostBridge,
};
it("throws error on missing RTC config error", () => {
@@ -53,7 +53,7 @@ import {
MembershipManagerError,
UnknownCallError,
} from "../../../utils/errors.ts";
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { type HostBridge } from "../../../HostBridge.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
@@ -145,6 +145,8 @@ interface Props {
>;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
/** The application hosting Element Call, to be kept informed of join/leave. */
hostBridge: HostBridge;
logger: Logger;
}
@@ -165,6 +167,7 @@ interface Props {
* @param props.matrixRTCSession The matrix RTC session to join.
* @param props.roomId The room ID used as the call identifier in analytics events.
* @param props.hideScreensharing Whether to hide the screen-sharing button.
* @param props.hostBridge The application hosting Element Call.
* @returns
* - publisher: The handle to create tracks and publish them to the room.
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
@@ -184,6 +187,7 @@ export const createLocalMembership$ = ({
matrixRTCSession,
roomId,
hideScreensharing,
hostBridge,
}: Props): {
/**
* This request to start audio and video tracks.
@@ -576,29 +580,24 @@ export const createLocalMembership$ = ({
}
});
// inform the widget about the connect and disconnect intent from the user.
// inform the host about the connect and disconnect intent from the user.
scope
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
undefined,
joinAndPublishRequested$.value,
])
.subscribe(([prev, current]) => {
if (!widget) return;
// JOIN prev=false (was left) => current-true (now joiend)
if (!prev && current) {
widget.api.transport
.send(ElementWidgetActions.JoinCall, {})
.catch((e) => {
logger.error("Failed to send join action", e);
});
hostBridge.notifyJoined().catch((e) => {
logger.error("Failed to notify the host that we joined", e);
});
}
// LEAVE prev=false (was joined) => current-true (now left)
if (prev && !current) {
widget.api.transport
.send(ElementWidgetActions.HangupCall, {})
.catch((e) => {
logger.error("Failed to send hangup action", e);
});
hostBridge.notifyHungUp().catch((e) => {
logger.error("Failed to notify the host that we hung up", e);
});
}
});
@@ -845,7 +844,7 @@ interface EnterRTCSessionOptions {
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
* Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of
* notification to send on join. `callIntent`: The kind of call being placed.
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
* @throws If the host could not be told that we are joining.
*/
// Exported for unit testing
export function enterRTCSession(
+15 -31
View File
@@ -6,39 +6,31 @@ Please see LICENSE in the repository root for full details.
*/
import { it, vi, expect } from "vitest";
import EventEmitter from "events";
import { Subject } from "rxjs";
// import * as ComponentsCore from "@livekit/components-core";
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
import { constant } from "./Behavior.ts";
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
import { ElementWidgetActions, widget } from "../widget.ts";
import {
type HostBridge,
type HostRequest,
nullHostBridge,
} from "../HostBridge.ts";
import { E2eeType } from "../e2ee/e2eeType.ts";
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
vi.mock("@livekit/components-core", { spy: true });
vi.mock("../widget", () => ({
ElementWidgetActions: {
HangupCall: "HangupCall",
// Add other actions if needed
},
widget: {
api: {
transport: {
send: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined),
},
},
lazyActions: new EventEmitter(),
},
}));
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
"expect leave when the host asks us to hang up (%s mode)",
async (mode) => {
const pr = Promise.withResolvers<string>();
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
const hostBridge: HostBridge = { ...nullHostBridge, hangUp$ };
const reply = vi.fn();
withCallViewModel(mode)(
{
remoteParticipants$: constant([aliceParticipant]),
@@ -49,25 +41,17 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
pr.resolve(s);
});
widget!.lazyActions!.emit(
ElementWidgetActions.HangupCall,
new CustomEvent(ElementWidgetActions.HangupCall, {
detail: {
action: "im.vector.hangup",
api: "toWidget",
data: {},
requestId: "widgetapi-1761237395918",
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
},
}),
);
hangUp$.next({ data: {}, reply });
},
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
},
);
const source = await pr.promise;
expect(source).toBe("user");
// The host expects to hear back that we acted on its request
expect(reply).toHaveBeenCalledOnce();
},
);
+7 -4
View File
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { nullHostBridge } from "../HostBridge";
import { MuteStates, MuteState } from "./MuteStates";
import {
type AudioOutputDeviceLabel,
@@ -228,10 +229,12 @@ describe("MuteStates", () => {
videoInput: aVideoInput(),
// other devices are not relevant for this test
});
const muteStates = new MuteStates(testScope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
const muteStates = new MuteStates(
testScope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
nullHostBridge,
);
let latestSyncedState: boolean | null = null;
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
+45 -54
View File
@@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
firstValueFrom,
fromEvent,
map,
merge,
Observable,
@@ -24,7 +22,7 @@ import {
} from "rxjs";
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
import { ElementWidgetActions, widget } from "../widget";
import { type DeviceMuteState, type HostBridge } from "../HostBridge";
import { type ObservableScope } from "./ObservableScope";
import { type Behavior, constant } from "./Behavior";
@@ -216,58 +214,51 @@ export class MuteStates {
audioEnabled: boolean;
videoEnabled: boolean;
},
hostBridge: HostBridge,
) {
if (widget !== null) {
// Sync our mute states with the hosting client
const widgetApiState$ = combineLatest(
[this.audio.enabled$, this.video.enabled$],
(audio, video) => ({ audio_enabled: audio, video_enabled: video }),
);
widgetApiState$.pipe(this.scope.bind()).subscribe((state) => {
widget!.api.transport
.send(ElementWidgetActions.DeviceMute, state)
.catch((e) =>
logger.warn("Could not send DeviceMute action to widget", e),
);
});
// Keep the host informed of our mute state
const muteState$ = combineLatest(
[this.audio.enabled$, this.video.enabled$],
(audio, video): DeviceMuteState => ({
audio_enabled: audio,
video_enabled: video,
}),
);
muteState$.pipe(this.scope.bind()).subscribe((state) => {
hostBridge
.notifyDeviceMute(state)
.catch((e) => logger.warn("Could not send mute state to the host", e));
});
// Also sync the hosting client's mute states back with ours
const muteActions$ = fromEvent(
widget.lazyActions,
ElementWidgetActions.DeviceMute,
) as Observable<CustomEvent<IWidgetApiRequest>>;
muteActions$
.pipe(
withLatestFrom(
widgetApiState$,
this.audio.setEnabled$,
this.video.setEnabled$,
),
this.scope.bind(),
)
.subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => {
// First copy the current state into our new state
const newState = { ...state };
// Update new state if there are any requested changes from the widget
// action in `ev.detail.data`.
if (
ev.detail.data.audio_enabled != null &&
typeof ev.detail.data.audio_enabled === "boolean" &&
setAudioEnabled !== null
) {
newState.audio_enabled = ev.detail.data.audio_enabled;
setAudioEnabled(newState.audio_enabled);
}
if (
ev.detail.data.video_enabled != null &&
typeof ev.detail.data.video_enabled === "boolean" &&
setVideoEnabled !== null
) {
newState.video_enabled = ev.detail.data.video_enabled;
setVideoEnabled(newState.video_enabled);
}
widget!.api.transport.reply(ev.detail, newState);
});
}
// And apply the changes the host asks for
hostBridge.deviceMute$
.pipe(
withLatestFrom(
muteState$,
this.audio.setEnabled$,
this.video.setEnabled$,
),
this.scope.bind(),
)
.subscribe(([request, state, setAudioEnabled, setVideoEnabled]) => {
// First copy the current state into our new state
const newState = { ...state };
// Then apply whichever changes the host asked for
if (
typeof request.data.audio_enabled === "boolean" &&
setAudioEnabled !== null
) {
newState.audio_enabled = request.data.audio_enabled;
setAudioEnabled(newState.audio_enabled);
}
if (
typeof request.data.video_enabled === "boolean" &&
setVideoEnabled !== null
) {
newState.video_enabled = request.data.video_enabled;
setVideoEnabled(newState.video_enabled);
}
request.reply(newState);
});
}
}
+7 -4
View File
@@ -63,6 +63,7 @@ import { type MediaDevices } from "../state/MediaDevices";
import { type Behavior, constant } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope";
import { MuteStates } from "../state/MuteStates";
import { nullHostBridge } from "../HostBridge";
import {
createLocalUserMedia,
type LocalUserMediaViewModel,
@@ -577,10 +578,12 @@ export function mockMuteStates(
joined$: Observable<boolean> = of(true),
): MuteStates {
const observableScope = new ObservableScope();
return new MuteStates(observableScope, mockMediaDevices({}), {
audioEnabled: false,
videoEnabled: false,
});
return new MuteStates(
observableScope,
mockMediaDevices({}),
{ audioEnabled: false, videoEnabled: false },
nullHostBridge,
);
}
export class MockConnection extends Connection {