review
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user