This commit is contained in:
Timo K
2026-05-15 18:37:25 +02:00
parent e10bc6c7cf
commit 88f660e43f
12 changed files with 192 additions and 118 deletions
+1
View File
@@ -202,6 +202,7 @@
"camera_numbered": "Camera {{n}}", "camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device", "change_device_button": "Change audio device",
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset", "handset": "Handset",
"loudspeaker": "Loudspeaker", "loudspeaker": "Loudspeaker",
"microphone": "Microphone", "microphone": "Microphone",
+4
View File
@@ -26,6 +26,10 @@ Please see LICENSE in the repository root for full details.
); );
} }
.footer.hidden {
display: none;
}
.footer.overlay { .footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain /* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */ tiles can move up out of the way of the footer when visible. */
+19 -2
View File
@@ -13,7 +13,7 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter"; import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css"; import inCallViewStyles from "../room/InCallView.module.css";
import { createStaticViewModel } from "../state/ViewModel"; import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender"; import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel"; import { type GridMode } from "../state/CallViewModel/CallViewModel";
@@ -39,7 +39,7 @@ function CallFooterStoryWrapper({
}: FooterSnapshot & { }: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined; children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode { }): ReactNode {
const vm = createStaticViewModel(vmSnapshot); const vm = useStaticViewModel(vmSnapshot);
return ( return (
<div className={inCallViewStyles.inRoom}> <div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext <ReactionsSenderContext
@@ -82,6 +82,23 @@ export const Default: Story = {
toggleScreenSharing: fn(), toggleScreenSharing: fn(),
hangup: fn(), hangup: fn(),
buttonSize: "lg", buttonSize: "lg",
showFooter: true,
hideControls: false,
asOverlay: false,
showLayoutSwitcher: false,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
videoToggles: [],
}, },
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
+79 -60
View File
@@ -34,63 +34,81 @@ import {
type MenuOptions, type MenuOptions,
type ToggleOption, type ToggleOption,
} from "./MediaMuteAndSwitchButton"; } from "./MediaMuteAndSwitchButton";
import { type ViewModel, useViewModel } from "../state/ViewModel"; import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
export interface AudioOutputSwitcher { export interface AudioOutputSwitcher {
targetOutput: string; targetOutput: string;
switch: () => void; switch: () => void;
} }
export interface FooterSnapshot { /**
audioEnabled: boolean; * The Snapshot combines all fields required to populate the view.
*
* It is a combination of Actions and State.
* All Actions and State will be wrappen in behaviors.
* This has the advantage, that actions can mutate.
* (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
* With it being reactive we can use the existance of the action to update the rendering without
* requiring additional state.
*
* Comment: It might not make sense to seperate the two interfaces. Hence the seperation
* just happens on the syntax level with the `type = ... & ...` notation.
*/
export type FooterSnapshot = FooterActions & FooterState;
export interface FooterActions {
/** Also controls if the audioMute button is disabled */ /** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined; toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */ /** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined; toggleVideo: (() => void) | undefined;
/** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
hangup: (() => void) | undefined;
}
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
videoEnabled: boolean;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */ /* This is needed for WindowMode = "flat" */
hideControls?: boolean; hideControls: boolean;
/** The footer should be used as an overlay. /** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens. */ * (Over the Call Grid) This saves spaces on small screens. */
asOverlay?: boolean; asOverlay: boolean;
buttonSize: "md" | "lg"; buttonSize: "md" | "lg";
showSettingsButton?: boolean; showSettingsButton: boolean;
showLayoutSwitcher?: boolean; showLayoutSwitcher: boolean;
showLogo?: boolean; showLogo: boolean;
layoutMode?: GridMode; layoutMode: GridMode | undefined;
/** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void;
sharingScreen?: boolean; sharingScreen: boolean;
toggleScreenSharing?: () => void;
/** Also controls if the audio output button is visible */ /** Also controls if the audio output button is visible */
audioOutputSwitcher?: AudioOutputSwitcher; audioOutputSwitcher: AudioOutputSwitcher | undefined;
/** Also controls if the settings button is visible */
openSettings?: () => void;
/** Also controls if the hangup button is visible */
hangup?: () => void;
reactionIdentifier?: string; reactionIdentifier: string | undefined;
reactionData?: ReactionData; reactionData: ReactionData | undefined;
// debug stuff // debug stuff
debugTileLayout?: boolean; debugTileLayout: boolean;
tileStoreGeneration?: number; tileStoreGeneration: number | undefined;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions?: MenuOptions[]; audioOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */ /** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions?: MenuOptions[]; videoOptions: MenuOptions[];
selectedAudio?: string; selectedAudio: string | undefined;
selectedVideo?: string; selectedVideo: string | undefined;
selectAudioButtonOption?: (deviceId: string) => void; selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption?: (option: string) => void; selectVideoButtonOption: ((option: string) => void) | undefined;
videoToggles?: ToggleOption[]; videoToggles: ToggleOption[];
} }
export interface FooterProps { export interface FooterProps {
@@ -99,35 +117,34 @@ export interface FooterProps {
vm: ViewModel<FooterSnapshot>; vm: ViewModel<FooterSnapshot>;
} }
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => { export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const { const asOverlay = useBehavior(vm.asOverlay$);
asOverlay, const showFooter = useBehavior(vm.showFooter$);
hideControls, const hideControls = useBehavior(vm.hideControls$);
layoutMode, const layoutMode = useBehavior(vm.layoutMode$);
setLayoutMode, const setLayoutMode = useBehavior(vm.setLayoutMode$);
openSettings, const openSettings = useBehavior(vm.openSettings$);
audioEnabled, const audioEnabled = useBehavior(vm.audioEnabled$);
videoEnabled, const videoEnabled = useBehavior(vm.videoEnabled$);
toggleAudio, const toggleAudio = useBehavior(vm.toggleAudio$);
toggleVideo, const toggleVideo = useBehavior(vm.toggleVideo$);
sharingScreen, const sharingScreen = useBehavior(vm.sharingScreen$);
toggleScreenSharing, const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
reactionIdentifier, const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
reactionData, const reactionData = useBehavior(vm.reactionData$);
audioOutputSwitcher, const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
hangup, const hangup = useBehavior(vm.hangup$);
debugTileLayout, const debugTileLayout = useBehavior(vm.debugTileLayout$);
tileStoreGeneration, const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
videoOptions, const videoOptions = useBehavior(vm.videoOptions$);
selectedVideo, const selectedVideo = useBehavior(vm.selectedVideo$);
audioOptions, const audioOptions = useBehavior(vm.audioOptions$);
selectedAudio, const selectedAudio = useBehavior(vm.selectedAudio$);
selectAudioButtonOption, const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
selectVideoButtonOption, const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
videoToggles, const videoToggles = useBehavior(vm.videoToggles$);
buttonSize, const buttonSize = useBehavior(vm.buttonSize$);
showSettingsButton, const showSettingsButton = useBehavior(vm.showSettingsButton$);
showLogo, const showLogo = useBehavior(vm.showLogo$);
} = useViewModel(vm);
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
@@ -267,8 +284,10 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
return ( return (
<div <div
ref={ref} ref={ref}
data-testid="footer-container"
className={classNames(styles.footer, { className={classNames(styles.footer, {
[styles.overlay]: asOverlay, [styles.overlay]: asOverlay,
[styles.hidden]: !showFooter,
})} })}
> >
<div className={styles.settingsLogoContainer}> <div className={styles.settingsLogoContainer}>
+5 -4
View File
@@ -47,6 +47,9 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
handsRaised$: constant({}), handsRaised$: constant({}),
reactions$: constant({}), reactions$: constant({}),
tileStoreGeneration$: constant(0), tileStoreGeneration$: constant(0),
showFooter$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel; } as unknown as CallViewModel;
} }
@@ -95,12 +98,11 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(layout), buildMinimalCallViewModel(layout),
mockMuteStates(), mockMuteStates(),
twoMicsAndOneCamMediaDevices, twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined, /* reactionIdentifier */ undefined,
); );
expect(vm.audioOptions$?.value).toEqual([]); expect(vm.audioOptions$.value).toEqual([]);
expect(vm.videoOptions$?.value).toEqual([]); expect(vm.videoOptions$.value).toEqual([]);
} }
it("are empty when both the platform is iOS", () => { it("are empty when both the platform is iOS", () => {
checkEmptyFor("ios", gridLayout); checkEmptyFor("ios", gridLayout);
@@ -117,7 +119,6 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout), buildMinimalCallViewModel(gridLayout),
mockMuteStates(), mockMuteStates(),
twoMicsAndOneCamMediaDevices, twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined, /* reactionIdentifier */ undefined,
); );
+32 -10
View File
@@ -159,8 +159,6 @@ function buildDeviceBehaviors(
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc. * @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles. * @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices. * @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined if the
* settings button should be hidden (e.g. when it is already shown in an app bar).
* @param reactionIdentifier - The local user's reaction identifier string, or * @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button). * undefined when reactions are not supported (hides the reaction button).
*/ */
@@ -169,7 +167,6 @@ export function createCallFooterViewModel(
callModel: CallViewModel, callModel: CallViewModel,
muteStates: MuteStates, muteStates: MuteStates,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
reactionIdentifier: string | undefined, reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> { ): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams(); const { showControls, header: headerStyle } = getUrlParams();
@@ -184,7 +181,8 @@ export function createCallFooterViewModel(
return { return {
...buildMuteBehaviors(scope, muteStates), ...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$), ...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls), hideControls$: constant(!showControls),
asOverlay$: scope.behavior( asOverlay$: scope.behavior(
callModel.windowMode$.pipe(map((mode) => mode === "flat")), callModel.windowMode$.pipe(map((mode) => mode === "flat")),
@@ -193,10 +191,14 @@ export function createCallFooterViewModel(
isPip$.pipe(map((pip) => (pip ? "md" : "lg") as "md" | "lg")), isPip$.pipe(map((pip) => (pip ? "md" : "lg") as "md" | "lg")),
), ),
showSettingsButton$: scope.behavior( showSettingsButton$: scope.behavior(
combineLatest([isPip$, callModel.showHeader$]).pipe( combineLatest([
isPip$,
callModel.showHeader$,
callModel.settingsOpen$,
]).pipe(
map( map(
([isPip, showHeader]) => ([isPip, showHeader, settingsOpen]) =>
openSettings !== undefined && settingsOpen !== undefined &&
!isPip && !isPip &&
showControls && showControls &&
!(headerStyle === HeaderStyle.AppBar && showHeader), !(headerStyle === HeaderStyle.AppBar && showHeader),
@@ -221,11 +223,11 @@ export function createCallFooterViewModel(
), ),
openSettings$: scope.behavior( openSettings$: scope.behavior(
callModel.showHeader$.pipe( combineLatest([callModel.showHeader$, callModel.setSettingsOpen$]).pipe(
map((showHeader) => map(([showHeader, setSettingsOpen]) =>
headerStyle === HeaderStyle.AppBar && showHeader headerStyle === HeaderStyle.AppBar && showHeader
? undefined ? undefined
: openSettings, : (): void => setSettingsOpen(true),
), ),
), ),
), ),
@@ -281,6 +283,26 @@ export function createLobbyFooterViewModel(
hangup, hangup,
debugTileLayout: false, debugTileLayout: false,
showSettingsButton: openSettings !== undefined, showSettingsButton: openSettings !== undefined,
showFooter: true,
toggleAudio: undefined,
toggleVideo: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
videoEnabled: undefined,
layoutMode: undefined,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
videoToggles: undefined,
}), }),
...buildMuteBehaviors(scope, muteStates), ...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)), ...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
+12 -9
View File
@@ -200,7 +200,7 @@ describe("InCallView", () => {
it("mobile landscape, is accessible when showHeader is false", () => { it("mobile landscape, is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode, // windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false. // which means showHeader$ emits false.
const { getAllByRole, queryAllByRole, vm } = createInCallView({ const { getAllByRole, getByRole, getByTestId, vm } = createInCallView({
withAppBar: true, withAppBar: true,
callViewModelOptions: { callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600) // Set windowMode$ to "flat" (height <= 600)
@@ -210,7 +210,12 @@ describe("InCallView", () => {
// In flat (landscape) mode the footer starts hidden until the user // In flat (landscape) mode the footer starts hidden until the user
// taps the screen, so no settings button should be accessible yet. // taps the screen, so no settings button should be accessible yet.
expect(queryAllByRole("button", { name: "Settings" })).toHaveLength(0);
expect(getByTestId("footer-container")).not.toBeVisible();
const buttons = getAllByRole("button", { name: "Settings" });
for (const b of buttons) {
expect(b).not.toBeVisible();
}
// Simulate a touch tap on the call view to reveal the footer. // Simulate a touch tap on the call view to reveal the footer.
// (PointerEvent is not available in JSDOM, so we call tapScreen() directly, // (PointerEvent is not available in JSDOM, so we call tapScreen() directly,
@@ -219,17 +224,15 @@ describe("InCallView", () => {
// When showHeader is false, hideSettingsButton is false, // When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer. // so the settings button is visible in the footer.
const settingsBtn = getAllByRole("button", { name: "Settings" }); const settingsBtn = getByRole("button", { name: "Settings" });
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the // There are two buttons in the bottom bar. One for the
// the narrow layout and another one for the wide layout. // the narrow layout and another one for the wide layout.
// Their visibility uses @media css queries, which cannot be tested in JSDOM, // Their visibility uses @media css queries, which we can test JSDOM (see `test.css.include` vitest config).
// but we can at least check that both buttons are rendered and have the correct classes. expect(settingsBtn).toHaveAttribute(
expect(settingsBtn.length).toBe(2);
expect(settingsBtn[0]).toHaveAttribute(
"data-testid", "data-testid",
"settings-bottom-left", "settings-bottom-left",
); );
expect(settingsBtn[0]).toBeVisible(); expect(settingsBtn).toBeVisible();
}); });
it("mobile portrait, is accessible when showHeader is true", () => { it("mobile portrait, is accessible when showHeader is true", () => {
+9 -19
View File
@@ -237,7 +237,8 @@ export const InCallView: FC<InCallViewProps> = ({
const windowMode = useBehavior(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$); const layout = useBehavior(vm.layout$);
const showHeader = useBehavior(vm.showHeader$); const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$); const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
@@ -284,28 +285,18 @@ export const InCallView: FC<InCallViewProps> = ({
); );
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]); const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
const openProfile = useMemo( const openProfile = useMemo(
() => () =>
// Profile settings are unavailable in widget mode // Profile settings are unavailable in widget mode
widget === null widget === null
? (): void => { ? (): void => {
setSettingsTab("profile"); setSettingsTab("profile");
setSettingsModalOpen(true); setSettingsOpen(true);
} }
: null, : null,
[setSettingsTab, setSettingsModalOpen], [setSettingsTab, setSettingsOpen],
); );
const [headerRef, headerBounds] = useMeasure(); const [headerRef, headerBounds] = useMeasure();
@@ -555,7 +546,6 @@ export const InCallView: FC<InCallViewProps> = ({
vm, vm,
muteStates, muteStates,
mediaDevices, mediaDevices,
openSettings,
supportsReactions supportsReactions
? `${client.getUserId()}:${client.getDeviceId()}` ? `${client.getUserId()}:${client.getDeviceId()}`
: undefined, : undefined,
@@ -564,19 +554,19 @@ export const InCallView: FC<InCallViewProps> = ({
return (): void => { return (): void => {
footerScope.end(); footerScope.end();
}; };
}, [client, mediaDevices, muteStates, openSettings, supportsReactions, vm]); }, [client, mediaDevices, muteStates, supportsReactions, vm]);
useAppBarSecondaryButton( useAppBarSecondaryButton(
<SettingsIconButton <SettingsIconButton
key="settings" key="settings"
onClick={openSettings} onClick={() => setSettingsOpen(true)}
data-testid="settings-app-bar" data-testid="settings-app-bar"
/>, />,
); );
// Only hide the settings button if we have an AppBar header and we are showing the header // Only hide the settings button if we have an AppBar header and we are showing the header
const footer = footerVm !== null && ( const footer = footerVm !== null && (
<>{showFooter && <CallFooter ref={footerRef} vm={footerVm} />}</> <CallFooter ref={footerRef} vm={footerVm} />
); );
const allConnections = useBehavior(vm.allConnections$); const allConnections = useBehavior(vm.allConnections$);
@@ -614,8 +604,8 @@ export const InCallView: FC<InCallViewProps> = ({
<SettingsModal <SettingsModal
client={client} client={client}
roomId={matrixRoom.roomId} roomId={matrixRoom.roomId}
open={settingsModalOpen} open={settingsOpen}
onDismiss={closeSettings} onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab} tab={settingsTab}
onTabChange={setSettingsTab} onTabChange={setSettingsTab}
livekitRooms={allConnections livekitRooms={allConnections
+1 -1
View File
@@ -46,7 +46,7 @@ export function mediaDeviceLabelToString(
labelText = labelText =
label.name === null label.name === null
? t("settings.devices.default") ? t("settings.devices.default")
: t("settings.devices.default") + " (" + label.name + ")"; : t("settings.devices.default_named", { name: label.name });
break; break;
case "speaker": case "speaker":
labelText = t("settings.devices.loudspeaker"); labelText = t("settings.devices.loudspeaker");
+14
View File
@@ -15,6 +15,7 @@ import {
} from "livekit-client"; } from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk"; import { type Room as MatrixRoom } from "matrix-js-sdk";
import { import {
BehaviorSubject,
catchError, catchError,
combineLatest, combineLatest,
distinctUntilChanged, distinctUntilChanged,
@@ -352,6 +353,9 @@ export interface CallViewModel {
showHeader$: Behavior<boolean>; showHeader$: Behavior<boolean>;
showFooter$: Behavior<boolean>; showFooter$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
// audio routing // audio routing
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
@@ -1332,6 +1336,7 @@ export function createCallViewModel$(
const showFooterUrlParams = !( const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false urlParams.header === HeaderStyle.None && urlParams.showControls === false
); );
// candidat to move into the FooterViewModel
const showFooterLayout$ = scope.behavior<boolean>( const showFooterLayout$ = scope.behavior<boolean>(
windowMode$.pipe( windowMode$.pipe(
switchMap((mode) => { switchMap((mode) => {
@@ -1386,11 +1391,18 @@ export function createCallViewModel$(
}), }),
), ),
); );
// candidat to move into the FooterViewModel
const showFooter$ = scope.behavior( const showFooter$ = scope.behavior(
showFooterLayout$.pipe( showFooterLayout$.pipe(
map((showFooter) => showFooter && showFooterUrlParams), map((showFooter) => showFooter && showFooterUrlParams),
), ),
); );
const settingsOpen$ = new BehaviorSubject(false);
const setSettingsOpen$ = constant((open: boolean) => {
settingsOpen$.next(open);
});
/** /**
* Whether audio is currently being output through the earpiece. * Whether audio is currently being output through the earpiece.
*/ */
@@ -1622,6 +1634,8 @@ export function createCallViewModel$(
showSpeakingIndicators$: showSpeakingIndicators$, showSpeakingIndicators$: showSpeakingIndicators$,
showHeader$: showHeader$, showHeader$: showHeader$,
showFooter$: showFooter$, showFooter$: showFooter$,
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
earpieceMode$: earpieceMode$, earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$, audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: localMembership.reconnecting$, reconnecting$: localMembership.reconnecting$,
+15 -13
View File
@@ -6,26 +6,14 @@ Please see LICENSE in the repository root for full details.
*/ */
import { BehaviorSubject } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { useState, useEffect } from "react";
import { useBehavior } from "../useBehavior";
import { type Behavior } from "./Behavior"; import { type Behavior } from "./Behavior";
export type ViewModel<Snapshot> = { export type ViewModel<Snapshot> = {
[K in keyof Snapshot as `${string & K}$`]: Behavior<Snapshot[K]>; [K in keyof Snapshot as `${string & K}$`]: Behavior<Snapshot[K]>;
}; };
export function useViewModel<Snapshot>(vm: ViewModel<Snapshot>): Snapshot {
const snapshot = {} as Snapshot;
for (const key in vm) {
const value$ = (vm as Record<string, Behavior<unknown>>)[key];
const snapshotKey = key.slice(0, -1) as keyof Snapshot;
// we allow using hooks in a loop here because we know the shape of the vm is static and won't change between renders, so the order of hooks calls will always be the same.
// eslint-disable-next-line react-hooks/rules-of-hooks
snapshot[snapshotKey] = useBehavior(value$) as Snapshot[keyof Snapshot];
}
return snapshot;
}
/** /**
* This allows to build a view model (or Partial view model) * This allows to build a view model (or Partial view model)
* with BehaviorSubjects. * with BehaviorSubjects.
@@ -45,3 +33,17 @@ export function createStaticViewModel<Snapshot>(
} }
return vm; return vm;
} }
export function useStaticViewModel<Snapshot>(
snapshot: Snapshot,
): ViewModel<Snapshot> {
const [vm] = useState(createStaticViewModel(snapshot));
useEffect(() => {
for (const key in snapshot) {
(vm as unknown as Record<string, BehaviorSubject<unknown>>)[
`${key}$`
].next(snapshot[key]);
}
}, [snapshot, vm]);
return vm;
}
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig((configEnv) =>
test: { test: {
name: "unit", name: "unit",
css: { css: {
include: /.+/,
modules: { modules: {
classNameStrategy: "non-scoped", classNameStrategy: "non-scoped",
}, },