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
20 changed files with 176 additions and 65 deletions
Showing only changes of commit 6e44332fd1 - Show all commits
+10 -3
View File
@@ -116,7 +116,7 @@ export async function createMatrixRTCSdk(
logger.info("client created");
// url params
const { roomId } = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = getUrlParams();
if (roomId === null) throw Error("could not get roomId from url params");
const room = client.getRoom(roomId);
if (room === null) throw Error("could not get room from client");
@@ -128,7 +128,10 @@ export async function createMatrixRTCSdk(
const rtcSession = rtcSessionManager.getRoomSession(room);
// media devices
const mediaDevices = new MediaDevices(scope);
const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices,
callIntent,
});
const muteStates = new MuteStates(scope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
@@ -141,7 +144,11 @@ export async function createMatrixRTCSdk(
room,
mediaDevices,
muteStates,
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
controlledAudioDevices,
callIntent,
},
of({}),
of({}),
constant({ supported: false, processor: undefined }),
+8 -17
View File
@@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -32,7 +25,6 @@ import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import {
getUrlParams,
HeaderStyle,
UrlParamsProvider,
useUrlParams,
@@ -75,6 +67,12 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
return children;
};
/** Wraps the app in an {@link AppBar}, if the params ask for one. */
const MaybeAppBar: FC<SimpleProviderProps> = ({ children }) => {
const { header } = useUrlParams();
return header === HeaderStyle.AppBar ? <AppBar>{children}</AppBar> : children;
};
interface Props {
vm: AppViewModel;
}
@@ -91,9 +89,6 @@ export const App: FC<Props> = ({ vm }) => {
.catch(logger.error);
});
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
@@ -123,11 +118,7 @@ export const App: FC<Props> = ({ vm }) => {
<ThemeProvider>
<TooltipProvider>
<Suspense fallback={null}>
{header === HeaderStyle.AppBar ? (
<AppBar>{content}</AppBar>
) : (
content
)}
<MaybeAppBar>{content}</MaybeAppBar>
</Suspense>
</TooltipProvider>
</ThemeProvider>
+3 -1
View File
@@ -29,7 +29,9 @@ const reactionData = {
reactions$: new BehaviorSubject({}),
};
const mediaDevices = new MediaDevices(globalScope);
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
/**
* A wrapper component that is used for:
@@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => {
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$.value).toEqual([]);
@@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => {
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$?.value).toEqual([
+5 -2
View File
@@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
@@ -138,6 +138,8 @@ function buildDeviceBehaviors(
* @param mediaDevices - Available and selected input devices.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
* @param options - `showControls`: whether the call controls should be shown.
* `header`: the style of header, which decides whether to show the logo.
*/
export function createCallFooterViewModel(
scope: ObservableScope,
@@ -145,8 +147,9 @@ export function createCallFooterViewModel(
muteStates: MuteStates,
mediaDevices: MediaDevices,
reactionIdentifier: string | undefined,
options: { showControls: boolean; header: HeaderStyle },
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const { showControls, header: headerStyle } = options;
const showLogo = headerStyle === HeaderStyle.Standard;
const isPip$ = scope.behavior(
@@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
const mediaDevices = new MediaDevices(globalScope);
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
const meta = {
component: MediaMuteAndSwitchButton,
+10 -1
View File
@@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
import { globalScope } from "./state/ObservableScope";
import { getUrlParams } from "./UrlParams";
initRageshake().catch((e) => {
logger.error("Failed to initialize rageshake", e);
@@ -49,9 +50,17 @@ if (fatalError !== null) {
Initializer.initBeforeReact()
.then(() => {
const { controlledAudioDevices, callIntent } = getUrlParams();
root.render(
<StrictMode>
<App vm={new AppViewModel(globalScope)} />
<App
vm={
new AppViewModel(globalScope, {
controlledAudioDevices,
callIntent,
})
}
/>
</StrictMode>,
);
})
+3 -7
View File
@@ -54,12 +54,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import {
getUrlParams,
HeaderStyle,
type UrlParams,
useUrlParams,
} from "../UrlParams";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import {
@@ -406,7 +401,7 @@ export const GroupCallView: FC<Props> = ({
}
// On a normal user hangup we can shut down and close the widget. But if an
// error occurs we should keep the widget open until the user reads it.
if (reason != "error" && !getUrlParams().returnToLobby) {
if (reason != "error" && !returnToLobby) {
try {
await widget.api.transport.send(ElementWidgetActions.Close, {});
} catch (e) {
@@ -425,6 +420,7 @@ export const GroupCallView: FC<Props> = ({
rtcSession,
isPasswordlessUser,
confineToRoom,
returnToLobby,
navigate,
],
);
+17 -2
View File
@@ -122,8 +122,16 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const {
autoLeaveWhenOthersLeft,
waitForCallPickup,
sendNotificationType,
controlledAudioDevices,
header,
showControls,
hideScreensharing,
callIntent,
} = urlParams;
const vm = createCallViewModel$(
scope,
@@ -136,6 +144,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$,
controlledAudioDevices,
header,
showControls,
hideScreensharing,
sendNotificationType,
callIntent,
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -172,6 +186,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.muteStates,
mediaDevices,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
{ showControls: urlParams.showControls, header: urlParams.header },
);
setFooterVm(footerVm);
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
+9 -3
View File
@@ -5,17 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { MediaDevices } from "./MediaDevices";
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
import { type ObservableScope } from "./ObservableScope";
/**
* The top-level state holder for the application.
*/
export class AppViewModel {
public readonly mediaDevices = new MediaDevices(this.scope);
public readonly mediaDevices = new MediaDevices(
this.scope,
this.audioOutputOptions,
);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
public constructor(private readonly scope: ObservableScope) {}
public constructor(
private readonly scope: ObservableScope,
private readonly audioOutputOptions: AudioOutputOptions,
) {}
}
@@ -1593,12 +1593,13 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
it.skip("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
of([]),
);
const devices = new MediaDevices(testScope());
const devices = new MediaDevices(testScope(), {
controlledAudioDevices: true,
});
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
+38 -6
View File
@@ -45,6 +45,8 @@ import {
MembershipManagerEvent,
type LivekitTransportConfig,
type MatrixRTCSession,
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";
@@ -85,7 +87,7 @@ import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import {
@@ -171,6 +173,23 @@ import { type GridTileViewModel } from "../TileViewModel.ts";
// callMembership -> rtcMembership
export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem;
/**
* Whether the app hosting Element Call controls the audio output devices,
* rather than the browser. Defaults to false.
*/
controlledAudioDevices?: boolean;
/** The style of header to show. Defaults to {@link HeaderStyle.Standard}. */
header?: HeaderStyle;
/** Whether the call controls should be shown. Defaults to true. */
showControls?: boolean;
/** Whether to hide the screen-sharing button. Defaults to false. */
hideScreensharing?: boolean;
/**
* Whether and what kind of notification to send when joining the call.
*/
sendNotificationType?: RTCNotificationType;
/** The kind of call being placed. */
callIntent?: RTCCallIntent;
autoLeaveWhenOthersLeft?: boolean;
/**
* If the call is started in a way where we want it to behave like a telephone usecase
@@ -435,6 +454,17 @@ export function createCallViewModel$(
if (!(userId && deviceId))
throw new UnknownCallError(new Error("userId and deviceId are required"));
// 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 {
controlledAudioDevices = false,
header = HeaderStyle.Standard,
showControls = true,
hideScreensharing = false,
sendNotificationType,
callIntent,
} = options;
const livekitKeyProvider = getE2eeKeyProvider(
options.encryptionSystem,
matrixRTCSession,
@@ -523,7 +553,7 @@ export function createCallViewModel$(
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
getUrlParams().controlledAudioDevices,
controlledAudioDevices,
options.livekitRoomFactory,
);
@@ -563,6 +593,8 @@ export function createCallViewModel$(
encryptMedia: livekitKeyProvider !== undefined,
// TODO. This might need to get called again on each change of matrixRTCMode...
matrixRTCMode: mode,
sendNotificationType,
callIntent,
})),
),
);
@@ -592,12 +624,14 @@ export function createCallViewModel$(
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
controlledAudioDevices,
);
},
connectionManager,
matrixRTCSession,
localTransport$,
roomId: matrixRoom.roomId,
hideScreensharing,
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -1457,9 +1491,8 @@ export function createCallViewModel$(
),
);
const urlParams = getUrlParams();
const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false
header === HeaderStyle.None && showControls === false
);
const showFooter$ = scope.behavior(
naturallyShowFooter$.pipe(
@@ -1778,8 +1811,7 @@ export function createCallViewModel$(
return {
autoLeave$: autoLeave$,
ringingVm$: ringingMedia$,
ringingStatusLocation:
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile",
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish,
@@ -199,6 +199,7 @@ describe("LocalMembership", () => {
rtsSession$: constant(RTCMemberStatus.Connected),
},
roomId: "!test-room-id:example.org",
hideScreensharing: false,
};
it("throws error on missing RTC config error", () => {
@@ -20,6 +20,8 @@ import {
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
@@ -52,7 +54,7 @@ import {
UnknownCallError,
} from "../../../utils/errors.ts";
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
advancedScreenShare,
@@ -141,6 +143,8 @@ interface Props {
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
>;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
logger: Logger;
}
@@ -160,6 +164,7 @@ interface Props {
* @param props.muteStates The mute states for video and audio.
* @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.
* @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)
@@ -178,6 +183,7 @@ export const createLocalMembership$ = ({
muteStates,
matrixRTCSession,
roomId,
hideScreensharing,
}: Props): {
/**
* This request to start audio and video tracks.
@@ -709,7 +715,7 @@ export const createLocalMembership$ = ({
let toggleScreenSharing: (() => void) | null = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!getUrlParams().hideScreensharing
!hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
@@ -820,6 +826,10 @@ export function observeSharingScreen$(p: Participant): Observable<boolean> {
interface EnterRTCSessionOptions {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
/** Whether and what kind of notification to send when joining. */
sendNotificationType?: RTCNotificationType;
/** The kind of call being placed. */
callIntent?: RTCCallIntent;
}
/**
@@ -832,7 +842,9 @@ interface EnterRTCSessionOptions {
* @param rtcSession - The MatrixRTCSession to join.
* @param ownMembershipIdentity - Options for entering the RTC session.
* @param transport - The LivekitTransport to use for this session.
* @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use.
* @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.
*/
// Exported for unit testing
@@ -842,7 +854,12 @@ export function enterRTCSession(
transport: LivekitTransportConfig,
options: EnterRTCSessionOptions,
): void {
const { encryptMedia, matrixRTCMode } = options;
const {
encryptMedia,
matrixRTCMode,
sendNotificationType: notificationType,
callIntent,
} = options;
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -851,7 +868,6 @@ export function enterRTCSession(
// groupCallOTelMembership?.onJoinCall();
const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility ||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
@@ -192,6 +192,7 @@ describe("Publisher", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
});
@@ -309,6 +310,7 @@ describe("Publisher", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
});
afterEach(async () => {
@@ -364,6 +366,7 @@ describe("Bug fix", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
audioEnabled$.next(true);
@@ -29,7 +29,7 @@ import {
type ProcessorState,
trackProcessorSync,
} from "../../../livekit/TrackProcessorContext.tsx";
import { getUrlParams } from "../../../UrlParams.ts";
import { observeTrackReference$ } from "../../observeTrackReference";
import { type Connection } from "../remoteMembers/Connection.ts";
import { ObservableScope } from "../../ObservableScope.ts";
@@ -56,6 +56,8 @@ export class Publisher {
* @param muteStates - The mute states for audio and video.
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
* @param logger - The logger to use for logging :D.
* @param controlledAudioDevices - Whether the app hosting Element Call
* controls the audio output devices, rather than the browser.
*/
public constructor(
private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
@@ -63,8 +65,8 @@ export class Publisher {
private readonly muteStates: MuteStates,
trackerProcessorState$: Behavior<ProcessorState>,
private logger: Logger,
controlledAudioDevices: boolean,
) {
const { controlledAudioDevices } = getUrlParams();
const room = connection.livekitRoom;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
+26 -5
View File
@@ -16,6 +16,7 @@ import {
} from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
@@ -25,7 +26,6 @@ import {
} from "../settings/settings";
import { type ObservableScope } from "./ObservableScope";
import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls";
import { getUrlParams } from "../UrlParams";
import { platform } from "../Platform";
import { switchWhen } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
@@ -338,6 +338,22 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
}
}
/**
* How Element Call should manage audio output.
*/
export interface AudioOutputOptions {
/**
* Whether the list of output devices is controlled by the app hosting Element
* Call, through the global JS controls, rather than by the browser.
*/
controlledAudioDevices: boolean;
/**
* The kind of call being placed, which decides the initial output route when
* the host controls the devices.
*/
callIntent?: RTCCallIntent;
}
export class MediaDevices {
private readonly deviceNamesRequest$ = new Subject<void>();
/**
@@ -368,23 +384,28 @@ export class MediaDevices {
public readonly audioOutput: MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
> = this.audioOutputOptions.controlledAudioDevices
? platform == "android"
? new AndroidControlledAudioOutput(
controlledAvailableOutputDevices$,
this.scope,
getUrlParams().callIntent,
this.audioOutputOptions.callIntent,
window.controls,
)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
getUrlParams().callIntent,
this.audioOutputOptions.callIntent,
)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);
public constructor(private readonly scope: ObservableScope) {}
// Note: both parameters are read by the field initializers above, which is
// safe because TypeScript assigns parameter properties before running them.
public constructor(
private readonly scope: ObservableScope,
private readonly audioOutputOptions: AudioOutputOptions,
) {}
}
+4 -4
View File
@@ -19,10 +19,10 @@ import EventEmitter from "events";
import { WidgetApiToWidgetAction } from "matrix-widget-api";
import { useTheme } from "./useTheme";
import { getUrlParams } from "./UrlParams";
import { useUrlParams } from "./UrlParams";
import { widget } from "./widget";
vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() }));
vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() }));
vi.mock("./widget", () => ({
widget: {
api: { transport: { reply: vi.fn() } },
@@ -39,7 +39,7 @@ describe("useTheme", () => {
vi.spyOn(originalClassList, "add");
vi.spyOn(originalClassList, "remove");
vi.spyOn(originalClassList, "item").mockReturnValue(null);
(getUrlParams as Mock).mockReturnValue({ theme: "dark" });
(useUrlParams as Mock).mockReturnValue({ theme: "dark" });
});
afterEach(() => {
@@ -53,7 +53,7 @@ describe("useTheme", () => {
{ setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] },
])("apply procedure", ({ setTheme, add }) => {
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
(getUrlParams as Mock).mockReturnValue({ theme: setTheme });
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
renderHook(() => useTheme());
+3 -4
View File
@@ -9,15 +9,14 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { WidgetApiToWidgetAction } from "matrix-widget-api";
import { type IThemeChangeActionRequest } from "matrix-widget-api";
import { getUrlParams } from "./UrlParams";
import { useUrlParams } from "./UrlParams";
import { widget } from "./widget";
import { useRootElement } from "./RootElementContext";
export const useTheme = (): void => {
const rootElement = useRootElement();
const [requestedTheme, setRequestedTheme] = useState(
() => getUrlParams().theme,
);
const { theme } = useUrlParams();
const [requestedTheme, setRequestedTheme] = useState(theme);
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
useEffect(() => {
+2
View File
@@ -40,6 +40,7 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { constant } from "../state/Behavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { createCallFooterViewModel } from "../components/CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
import { type FooterSnapshot } from "../components/CallFooter";
import { type ViewModel } from "../state/ViewModel";
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel";
@@ -187,6 +188,7 @@ export function getBasicCallViewModelEnvironment(
muteStates,
mediaDevices,
"reactionId",
{ showControls: true, header: HeaderStyle.Standard },
);
return {
vm,