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

View File

@@ -202,6 +202,7 @@
"camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone",

View File

@@ -26,6 +26,10 @@ Please see LICENSE in the repository root for full details.
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
/* 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. */

View File

@@ -13,7 +13,7 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import { createStaticViewModel } from "../state/ViewModel";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
@@ -39,7 +39,7 @@ function CallFooterStoryWrapper({
}: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode {
const vm = createStaticViewModel(vmSnapshot);
const vm = useStaticViewModel(vmSnapshot);
return (
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
@@ -82,6 +82,23 @@ export const Default: Story = {
toggleScreenSharing: fn(),
hangup: fn(),
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: {
layout: "fullscreen",

View File

@@ -34,63 +34,81 @@ import {
type MenuOptions,
type ToggleOption,
} from "./MediaMuteAndSwitchButton";
import { type ViewModel, useViewModel } from "../state/ViewModel";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
export interface AudioOutputSwitcher {
targetOutput: string;
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 */
toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */
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" */
hideControls?: boolean;
hideControls: boolean;
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay?: boolean;
asOverlay: boolean;
buttonSize: "md" | "lg";
showSettingsButton?: boolean;
showLayoutSwitcher?: boolean;
showLogo?: boolean;
showSettingsButton: boolean;
showLayoutSwitcher: boolean;
showLogo: boolean;
layoutMode?: GridMode;
/** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void;
layoutMode: GridMode | undefined;
sharingScreen?: boolean;
toggleScreenSharing?: () => void;
sharingScreen: boolean;
/** Also controls if the audio output button is visible */
audioOutputSwitcher?: AudioOutputSwitcher;
/** Also controls if the settings button is visible */
openSettings?: () => void;
/** Also controls if the hangup button is visible */
hangup?: () => void;
audioOutputSwitcher: AudioOutputSwitcher | undefined;
reactionIdentifier?: string;
reactionData?: ReactionData;
reactionIdentifier: string | undefined;
reactionData: ReactionData | undefined;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
debugTileLayout: boolean;
tileStoreGeneration: number | undefined;
/** 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 */
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioButtonOption?: (deviceId: string) => void;
selectVideoButtonOption?: (option: string) => void;
videoToggles?: ToggleOption[];
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
videoToggles: ToggleOption[];
}
export interface FooterProps {
@@ -99,35 +117,34 @@ export interface FooterProps {
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const {
asOverlay,
hideControls,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
videoOptions,
selectedVideo,
audioOptions,
selectedAudio,
selectAudioButtonOption,
selectVideoButtonOption,
videoToggles,
buttonSize,
showSettingsButton,
showLogo,
} = useViewModel(vm);
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const layoutMode = useBehavior(vm.layoutMode$);
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
const reactionData = useBehavior(vm.reactionData$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const videoToggles = useBehavior(vm.videoToggles$);
const buttonSize = useBehavior(vm.buttonSize$);
const showSettingsButton = useBehavior(vm.showSettingsButton$);
const showLogo = useBehavior(vm.showLogo$);
const buttons: JSX.Element[] = [];
@@ -267,8 +284,10 @@ export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
return (
<div
ref={ref}
data-testid="footer-container"
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: !showFooter,
})}
>
<div className={styles.settingsLogoContainer}>

View File

@@ -47,6 +47,9 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
handsRaised$: constant({}),
reactions$: constant({}),
tileStoreGeneration$: constant(0),
showFooter$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel;
}
@@ -95,12 +98,11 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([]);
expect(vm.videoOptions$?.value).toEqual([]);
expect(vm.audioOptions$.value).toEqual([]);
expect(vm.videoOptions$.value).toEqual([]);
}
it("are empty when both the platform is iOS", () => {
checkEmptyFor("ios", gridLayout);
@@ -117,7 +119,6 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* reactionIdentifier */ undefined,
);

View File

@@ -159,8 +159,6 @@ function buildDeviceBehaviors(
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @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
* undefined when reactions are not supported (hides the reaction button).
*/
@@ -169,7 +167,6 @@ export function createCallFooterViewModel(
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
@@ -184,7 +181,8 @@ export function createCallFooterViewModel(
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
asOverlay$: scope.behavior(
callModel.windowMode$.pipe(map((mode) => mode === "flat")),
@@ -193,10 +191,14 @@ export function createCallFooterViewModel(
isPip$.pipe(map((pip) => (pip ? "md" : "lg") as "md" | "lg")),
),
showSettingsButton$: scope.behavior(
combineLatest([isPip$, callModel.showHeader$]).pipe(
combineLatest([
isPip$,
callModel.showHeader$,
callModel.settingsOpen$,
]).pipe(
map(
([isPip, showHeader]) =>
openSettings !== undefined &&
([isPip, showHeader, settingsOpen]) =>
settingsOpen !== undefined &&
!isPip &&
showControls &&
!(headerStyle === HeaderStyle.AppBar && showHeader),
@@ -221,11 +223,11 @@ export function createCallFooterViewModel(
),
openSettings$: scope.behavior(
callModel.showHeader$.pipe(
map((showHeader) =>
combineLatest([callModel.showHeader$, callModel.setSettingsOpen$]).pipe(
map(([showHeader, setSettingsOpen]) =>
headerStyle === HeaderStyle.AppBar && showHeader
? undefined
: openSettings,
: (): void => setSettingsOpen(true),
),
),
),
@@ -281,6 +283,26 @@ export function createLobbyFooterViewModel(
hangup,
debugTileLayout: false,
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),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),

View File

@@ -200,7 +200,7 @@ describe("InCallView", () => {
it("mobile landscape, is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false.
const { getAllByRole, queryAllByRole, vm } = createInCallView({
const { getAllByRole, getByRole, getByTestId, vm } = createInCallView({
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600)
@@ -210,7 +210,12 @@ describe("InCallView", () => {
// In flat (landscape) mode the footer starts hidden until the user
// 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.
// (PointerEvent is not available in JSDOM, so we call tapScreen() directly,
@@ -219,17 +224,15 @@ describe("InCallView", () => {
// When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer.
const settingsBtn = getAllByRole("button", { name: "Settings" });
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
const settingsBtn = getByRole("button", { name: "Settings" });
// There are two buttons in the bottom bar. One for the
// the narrow layout and another one for the wide layout.
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
// but we can at least check that both buttons are rendered and have the correct classes.
expect(settingsBtn.length).toBe(2);
expect(settingsBtn[0]).toHaveAttribute(
// Their visibility uses @media css queries, which we can test JSDOM (see `test.css.include` vitest config).
expect(settingsBtn).toHaveAttribute(
"data-testid",
"settings-bottom-left",
);
expect(settingsBtn[0]).toBeVisible();
expect(settingsBtn).toBeVisible();
});
it("mobile portrait, is accessible when showHeader is true", () => {

View File

@@ -237,7 +237,8 @@ export const InCallView: FC<InCallViewProps> = ({
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$);
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 audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
@@ -284,28 +285,18 @@ export const InCallView: FC<InCallViewProps> = ({
);
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
const openProfile = useMemo(
() =>
// Profile settings are unavailable in widget mode
widget === null
? (): void => {
setSettingsTab("profile");
setSettingsModalOpen(true);
setSettingsOpen(true);
}
: null,
[setSettingsTab, setSettingsModalOpen],
[setSettingsTab, setSettingsOpen],
);
const [headerRef, headerBounds] = useMeasure();
@@ -555,7 +546,6 @@ export const InCallView: FC<InCallViewProps> = ({
vm,
muteStates,
mediaDevices,
openSettings,
supportsReactions
? `${client.getUserId()}:${client.getDeviceId()}`
: undefined,
@@ -564,19 +554,19 @@ export const InCallView: FC<InCallViewProps> = ({
return (): void => {
footerScope.end();
};
}, [client, mediaDevices, muteStates, openSettings, supportsReactions, vm]);
}, [client, mediaDevices, muteStates, supportsReactions, vm]);
useAppBarSecondaryButton(
<SettingsIconButton
key="settings"
onClick={openSettings}
onClick={() => setSettingsOpen(true)}
data-testid="settings-app-bar"
/>,
);
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = footerVm !== null && (
<>{showFooter && <CallFooter ref={footerRef} vm={footerVm} />}</>
<CallFooter ref={footerRef} vm={footerVm} />
);
const allConnections = useBehavior(vm.allConnections$);
@@ -614,8 +604,8 @@ export const InCallView: FC<InCallViewProps> = ({
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
open={settingsOpen}
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
livekitRooms={allConnections

View File

@@ -46,7 +46,7 @@ export function mediaDeviceLabelToString(
labelText =
label.name === null
? t("settings.devices.default")
: t("settings.devices.default") + " (" + label.name + ")";
: t("settings.devices.default_named", { name: label.name });
break;
case "speaker":
labelText = t("settings.devices.loudspeaker");

View File

@@ -15,6 +15,7 @@ import {
} from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
@@ -352,6 +353,9 @@ export interface CallViewModel {
showHeader$: Behavior<boolean>;
showFooter$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
// audio routing
/**
* Whether audio is currently being output through the earpiece.
@@ -1332,6 +1336,7 @@ export function createCallViewModel$(
const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false
);
// candidat to move into the FooterViewModel
const showFooterLayout$ = scope.behavior<boolean>(
windowMode$.pipe(
switchMap((mode) => {
@@ -1386,11 +1391,18 @@ export function createCallViewModel$(
}),
),
);
// candidat to move into the FooterViewModel
const showFooter$ = scope.behavior(
showFooterLayout$.pipe(
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.
*/
@@ -1622,6 +1634,8 @@ export function createCallViewModel$(
showSpeakingIndicators$: showSpeakingIndicators$,
showHeader$: showHeader$,
showFooter$: showFooter$,
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,
reconnecting$: localMembership.reconnecting$,

View File

@@ -6,26 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject } from "rxjs";
import { useState, useEffect } from "react";
import { useBehavior } from "../useBehavior";
import { type Behavior } from "./Behavior";
export type ViewModel<Snapshot> = {
[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)
* with BehaviorSubjects.
@@ -45,3 +33,17 @@ export function createStaticViewModel<Snapshot>(
}
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;
}

View File

@@ -19,6 +19,7 @@ export default defineConfig((configEnv) =>
test: {
name: "unit",
css: {
include: /.+/,
modules: {
classNameStrategy: "non-scoped",
},