mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-05 19:59:20 +00:00
Merge pull request #4046 from element-hq/header-subtitle
Move ringing status indicator to header on mobile
This commit is contained in:
@@ -1,6 +1,12 @@
|
|||||||
.bar {
|
.bar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: var(--call-view-header-footer-layer);
|
||||||
|
padding-left: var(--content-inset-left);
|
||||||
|
padding-right: var(--content-inset-right);
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pseudo-element for the gradient background */
|
/* Pseudo-element for the gradient background */
|
||||||
@@ -9,8 +15,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
/* Extend the gradient beyond the bottom of the header for readability */
|
/* Extend the gradient beyond the bottom of the header for readability */
|
||||||
inset-block: -24px;
|
inset-block: 0 -16px;
|
||||||
z-index: var(--call-view-header-footer-layer);
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
0deg,
|
0deg,
|
||||||
rgba(0, 0, 0, 0) 0%,
|
rgba(0, 0, 0, 0) 0%,
|
||||||
@@ -18,21 +23,122 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bar.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Switch to position: absolute so the bar takes up no space in the layout
|
||||||
|
when hidden. */
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 0;
|
||||||
|
inset-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:has(:focus-visible) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: initial;
|
||||||
|
}
|
||||||
|
|
||||||
.bar > header {
|
.bar > header {
|
||||||
|
flex-shrink: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
inset-block-start: 0;
|
inset-block-start: 0;
|
||||||
block-size: 64px;
|
display: grid;
|
||||||
z-index: var(--call-view-header-footer-layer);
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
grid-template-rows:
|
||||||
|
var(--cpd-space-3x) minmax(var(--cpd-space-10x), auto)
|
||||||
|
var(--cpd-space-3x);
|
||||||
|
grid-template-areas:
|
||||||
|
". . ."
|
||||||
|
"primaryButton title secondaryButton"
|
||||||
|
". . .";
|
||||||
|
place-items: center;
|
||||||
|
column-gap: var(--cpd-space-2x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar svg path {
|
.bar:has(.subtitle) > header {
|
||||||
fill: var(--cpd-color-icon-primary);
|
grid-template-rows:
|
||||||
|
var(--cpd-space-3x) minmax(var(--cpd-space-10x), auto) var(--cpd-space-5x)
|
||||||
|
minmax(var(--cpd-space-8x), auto);
|
||||||
|
grid-template-areas:
|
||||||
|
". . ."
|
||||||
|
"primaryButton title secondaryButton"
|
||||||
|
". . ."
|
||||||
|
"subtitle subtitle subtitle";
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar > header > h1 {
|
.primaryButton {
|
||||||
|
grid-area: primaryButton;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
grid-area: title;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
grid-area: subtitle;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--cpd-color-icon-tertiary);
|
||||||
|
margin-inline-end: var(--cpd-space-2x);
|
||||||
|
block-size: 1.2em;
|
||||||
|
inline-size: 1.2em;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondaryButton {
|
||||||
|
grid-area: secondaryButton;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton,
|
||||||
|
.secondaryButton {
|
||||||
|
svg * {
|
||||||
|
color: var(--cpd-color-icon-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-platform="ios"] {
|
||||||
|
.bar > header {
|
||||||
|
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
|
||||||
|
grid-template-areas: "primaryButton title secondaryButton";
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:has(.subtitle) > header {
|
||||||
|
grid-template-rows:
|
||||||
|
minmax(var(--cpd-space-6x), auto) minmax(var(--cpd-space-5x), auto)
|
||||||
|
var(--cpd-space-4x);
|
||||||
|
grid-template-areas:
|
||||||
|
"primaryButton title secondaryButton"
|
||||||
|
"primaryButton subtitle secondaryButton";
|
||||||
|
|
||||||
|
.title {
|
||||||
|
align-self: end;
|
||||||
|
/* Nudge the title and subtitle even closer together to replicate native
|
||||||
|
iOS styles */
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,21 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { type FC, type ReactNode } from "react";
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import { AppBar } from "./AppBar";
|
import { AppBar, useAppBarSubtitle, useAppBarTitle } from "./AppBar";
|
||||||
|
|
||||||
|
const content = <p>This is the content.</p>;
|
||||||
|
|
||||||
|
function snapshotAppBar(content: ReactNode): void {
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<AppBar>{content}</AppBar>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
describe("AppBar", () => {
|
describe("AppBar", () => {
|
||||||
it("renders", () => {
|
it("renders", () => snapshotAppBar(content));
|
||||||
const { container } = render(
|
|
||||||
<TooltipProvider>
|
it("renders with title and subtitle", () => {
|
||||||
<AppBar>
|
const TestComponent: FC = () => {
|
||||||
<p>This is the content.</p>
|
useAppBarTitle("Title");
|
||||||
</AppBar>
|
useAppBarSubtitle("Subtitle");
|
||||||
</TooltipProvider>,
|
return content;
|
||||||
);
|
};
|
||||||
expect(container).toMatchSnapshot();
|
snapshotAppBar(<TestComponent />);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
import classNames from "classnames";
|
||||||
|
import { Heading, IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
@@ -25,12 +26,12 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { Header, LeftNav, RightNav } from "./Header";
|
|
||||||
import { platform } from "./Platform";
|
import { platform } from "./Platform";
|
||||||
import styles from "./AppBar.module.css";
|
import styles from "./AppBar.module.css";
|
||||||
|
|
||||||
interface AppBarContext {
|
interface AppBarContext {
|
||||||
setTitle: (value: string) => void;
|
setTitle: (value: string) => void;
|
||||||
|
setSubtitle: (value: ReactNode) => void;
|
||||||
setSecondaryButton: (value: ReactNode) => void;
|
setSecondaryButton: (value: ReactNode) => void;
|
||||||
setPrimaryButtonIconKind: (value: "back" | "minimise") => void;
|
setPrimaryButtonIconKind: (value: "back" | "minimise") => void;
|
||||||
setHidden: (value: boolean) => void;
|
setHidden: (value: boolean) => void;
|
||||||
@@ -54,6 +55,7 @@ export const AppBar: FC<Props> = ({ children }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [title, setTitle] = useState<string>("");
|
const [title, setTitle] = useState<string>("");
|
||||||
|
const [subtitle, setSubtitle] = useState<ReactNode>(undefined);
|
||||||
const [hidden, setHidden] = useState<boolean>(false);
|
const [hidden, setHidden] = useState<boolean>(false);
|
||||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
|
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
|
||||||
null,
|
null,
|
||||||
@@ -65,53 +67,64 @@ export const AppBar: FC<Props> = ({ children }) => {
|
|||||||
const context = useMemo(
|
const context = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
setTitle,
|
setTitle,
|
||||||
|
setSubtitle,
|
||||||
setSecondaryButton,
|
setSecondaryButton,
|
||||||
setHidden,
|
setHidden,
|
||||||
setPrimaryButtonIconKind,
|
setPrimaryButtonIconKind,
|
||||||
}),
|
}),
|
||||||
[setTitle, setHidden, setSecondaryButton, setPrimaryButtonIconKind],
|
[
|
||||||
|
setTitle,
|
||||||
|
setSubtitle,
|
||||||
|
setHidden,
|
||||||
|
setSecondaryButton,
|
||||||
|
setPrimaryButtonIconKind,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon;
|
const BackIcon = platform === "android" ? ArrowLeftIcon : ChevronLeftIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
{/* Wrap the header in a div due to annoying z-index issues with the
|
||||||
style={{ display: hidden ? "none" : "block" }}
|
gradient background */}
|
||||||
className={styles.bar}
|
<div className={classNames(styles.bar, { [styles.hidden]: hidden })}>
|
||||||
>
|
<header>
|
||||||
<Header
|
<Tooltip label={t("common.back")}>
|
||||||
// App bar is mainly seen in the call view, which has its own
|
<IconButton
|
||||||
// 'reconnecting' toast
|
className={styles.primaryButton}
|
||||||
disconnectedBanner={false}
|
// We render the back button (PrimaryButtonIcon) the same size as the native os.
|
||||||
>
|
// We render the minimise icon (default) smaller as per designs.
|
||||||
<LeftNav>
|
size={primaryButtonIcon === "back" ? "32px" : "24px"}
|
||||||
<Tooltip label={t("common.back")}>
|
onClick={onBackClick}
|
||||||
<IconButton
|
>
|
||||||
// We render the back button (PrimaryButtonIcon) the same size as the native os.
|
{primaryButtonIcon === "back" ? (
|
||||||
// We render the minimise icon (default) smaller as per designs.
|
<BackIcon aria-hidden />
|
||||||
size={primaryButtonIcon === "back" ? "32px" : "24px"}
|
) : (
|
||||||
onClick={onBackClick}
|
<CollapseIcon aria-hidden />
|
||||||
>
|
)}
|
||||||
{primaryButtonIcon === "back" ? (
|
</IconButton>
|
||||||
<BackIcon aria-hidden />
|
</Tooltip>
|
||||||
) : (
|
|
||||||
<CollapseIcon aria-hidden />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</LeftNav>
|
|
||||||
{title && (
|
{title && (
|
||||||
<Heading
|
<Heading
|
||||||
|
className={styles.title}
|
||||||
type="body"
|
type="body"
|
||||||
size="lg"
|
size={platform === "ios" ? "md" : "lg"}
|
||||||
weight={platform === "android" ? "medium" : "semibold"}
|
weight={platform === "ios" ? "semibold" : "medium"}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</Heading>
|
</Heading>
|
||||||
)}
|
)}
|
||||||
<RightNav>{secondaryButton}</RightNav>
|
{subtitle && (
|
||||||
</Header>
|
<Text
|
||||||
|
className={styles.subtitle}
|
||||||
|
as="span"
|
||||||
|
size={platform === "ios" ? "sm" : "lg"}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<div className={styles.secondaryButton}>{secondaryButton}</div>
|
||||||
|
</header>
|
||||||
</div>
|
</div>
|
||||||
<AppBarContext value={context}>{children}</AppBarContext>
|
<AppBarContext value={context}>{children}</AppBarContext>
|
||||||
</>
|
</>
|
||||||
@@ -132,6 +145,20 @@ export function useAppBarTitle(title: string): void {
|
|||||||
}, [title, setTitle]);
|
}, [title, setTitle]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook which sets the subtitle to be shown in the app bar, if present. It
|
||||||
|
* is an error to call this hook from multiple sites in the same component tree.
|
||||||
|
*/
|
||||||
|
export function useAppBarSubtitle(subtitle: ReactNode): void {
|
||||||
|
const setSubtitle = use(AppBarContext)?.setSubtitle;
|
||||||
|
useEffect(() => {
|
||||||
|
if (setSubtitle !== undefined) {
|
||||||
|
setSubtitle(subtitle);
|
||||||
|
return (): void => setSubtitle("");
|
||||||
|
}
|
||||||
|
}, [subtitle, setSubtitle]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React hook which sets the primary button icon kind. Can only be "minimise" or "back"
|
* React hook which sets the primary button icon kind. Can only be "minimise" or "back"
|
||||||
* It is an error to call this hook from multiple sites in the same component tree.
|
* It is an error to call this hook from multiple sites in the same component tree.
|
||||||
|
|||||||
@@ -4,43 +4,89 @@ exports[`AppBar > renders 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="bar"
|
class="bar"
|
||||||
style="display: block;"
|
|
||||||
>
|
>
|
||||||
<header
|
<header>
|
||||||
class="header"
|
<button
|
||||||
>
|
aria-labelledby="_r_0_"
|
||||||
<div
|
class="_icon-button_1215g_8 primaryButton"
|
||||||
class="nav leftNav"
|
data-kind="primary"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
aria-labelledby="_r_0_"
|
class="_indicator-icon_147l5_17"
|
||||||
class="_icon-button_1215g_8"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
data-kind="primary"
|
|
||||||
role="button"
|
|
||||||
style="--cpd-icon-button-size: 24px;"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
class="_indicator-icon_147l5_17"
|
aria-hidden="true"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
aria-hidden="true"
|
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
|
||||||
fill="currentColor"
|
/>
|
||||||
height="1em"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
width="1em"
|
</button>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="nav rightNav"
|
class="secondaryButton"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
This is the content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`AppBar > renders with title and subtitle 1`] = `
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="bar"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<button
|
||||||
|
aria-labelledby="_r_6_"
|
||||||
|
class="_icon-button_1215g_8 primaryButton"
|
||||||
|
data-kind="primary"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 24px;"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_indicator-icon_147l5_17"
|
||||||
|
style="--cpd-icon-button-size: 100%;"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<h1
|
||||||
|
class="_typography_6v6n8_153 _font-body-lg-medium_6v6n8_79 title"
|
||||||
|
>
|
||||||
|
Title
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69 subtitle"
|
||||||
|
>
|
||||||
|
Subtitle
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="secondaryButton"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -173,9 +173,7 @@ export function createCallFooterViewModel(
|
|||||||
callModel.setSettingsOpen$,
|
callModel.setSettingsOpen$,
|
||||||
]).pipe(
|
]).pipe(
|
||||||
map(([isPip, showHeader, setSettingsOpen]) =>
|
map(([isPip, showHeader, setSettingsOpen]) =>
|
||||||
!isPip &&
|
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
|
||||||
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
|
|
||||||
showControls
|
|
||||||
? (): void => setSettingsOpen(true)
|
? (): void => setSettingsOpen(true)
|
||||||
: undefined,
|
: undefined,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
afterEach,
|
|
||||||
beforeEach,
|
beforeEach,
|
||||||
describe,
|
describe,
|
||||||
expect,
|
expect,
|
||||||
@@ -15,12 +14,7 @@ import {
|
|||||||
type MockedFunction,
|
type MockedFunction,
|
||||||
vi,
|
vi,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import {
|
import { render, type RenderResult } from "@testing-library/react";
|
||||||
render,
|
|
||||||
type RenderResult,
|
|
||||||
getByRole,
|
|
||||||
screen,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { type LocalParticipant } from "livekit-client";
|
import { type LocalParticipant } from "livekit-client";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
@@ -50,7 +44,6 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
|||||||
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||||
import { constant } from "../state/Behavior";
|
|
||||||
import { AppBar } from "../AppBar";
|
import { AppBar } from "../AppBar";
|
||||||
import { initializeWidget } from "../widget";
|
import { initializeWidget } from "../widget";
|
||||||
|
|
||||||
@@ -195,45 +188,6 @@ describe("InCallView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings button with AppBar header", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// getUrlParams() reads window.location directly rather than from the
|
|
||||||
// React Router context, so MemoryRouter alone is not enough to make
|
|
||||||
// it see "header=app_bar". Push the real URL so both paths agree.
|
|
||||||
window.history.pushState({}, "", "?header=app_bar");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.history.pushState({}, "", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mobile portrait, is visible in the header", () => {
|
|
||||||
createInCallView({
|
|
||||||
withAppBar: true,
|
|
||||||
callViewModelOptions: {
|
|
||||||
// Narrow like a mobile phone in portrait orientation
|
|
||||||
windowSize$: constant({ width: 400, height: 700 }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
getByRole(screen.getByRole("banner"), "button", {
|
|
||||||
name: "Settings",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mobile landscape, is not visible anywhere", () => {
|
|
||||||
const { queryByRole } = createInCallView({
|
|
||||||
withAppBar: true,
|
|
||||||
callViewModelOptions: {
|
|
||||||
// Flat like a mobile phone in landscape orientation
|
|
||||||
windowSize$: constant({ width: 700, height: 400 }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("audioOutputSwitcher", () => {
|
describe("audioOutputSwitcher", () => {
|
||||||
it("is visible and can be clicked", async () => {
|
it("is visible and can be clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
createCallViewModel$,
|
createCallViewModel$,
|
||||||
} from "../state/CallViewModel/CallViewModel.ts";
|
} from "../state/CallViewModel/CallViewModel.ts";
|
||||||
import { Grid, type TileProps } from "../grid/Grid";
|
import { Grid, type TileProps } from "../grid/Grid";
|
||||||
import { useInitial } from "../useInitial";
|
|
||||||
import { SpotlightTile } from "../tile/SpotlightTile";
|
import { SpotlightTile } from "../tile/SpotlightTile";
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
@@ -69,22 +68,23 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
|||||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
import {
|
||||||
|
useAppBarHidden,
|
||||||
|
useAppBarSecondaryButton,
|
||||||
|
useAppBarSubtitle,
|
||||||
|
} from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
import { Toast } from "../Toast.tsx";
|
import { Toast } from "../Toast.tsx";
|
||||||
import overlayStyles from "../Overlay.module.css";
|
import overlayStyles from "../Overlay.module.css";
|
||||||
import { prefetchSounds } from "../soundUtils";
|
|
||||||
import { useAudioContext } from "../useAudioContext";
|
|
||||||
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
|
||||||
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
|
||||||
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
|
||||||
import { type Layout } from "../state/layout-types.ts";
|
import { type Layout } from "../state/layout-types.ts";
|
||||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||||
import { useLatest } from "../useLatest.ts";
|
|
||||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||||
import { SettingsIconButton } from "../button/Button.tsx";
|
import { SettingsIconButton } from "../button/Button.tsx";
|
||||||
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
||||||
import { type ViewModel } from "../state/ViewModel.ts";
|
import { type ViewModel } from "../state/ViewModel.ts";
|
||||||
|
import { RingingStatus } from "../tile/RingingStatus.tsx";
|
||||||
|
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
|
||||||
|
|
||||||
declare module "react" {
|
declare module "react" {
|
||||||
interface CSSProperties {
|
interface CSSProperties {
|
||||||
@@ -240,20 +240,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const { showControls, header: headerStyle } = useUrlParams();
|
const { showControls, header: headerStyle } = useUrlParams();
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
|
|
||||||
// Preload a waiting and decline sounds
|
|
||||||
const pickupPhaseSoundCache = useInitial(async () => {
|
|
||||||
return prefetchSounds({
|
|
||||||
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const pickupPhaseAudio = useAudioContext({
|
|
||||||
sounds: pickupPhaseSoundCache,
|
|
||||||
latencyHint: "interactive",
|
|
||||||
muted: muteAllAudio,
|
|
||||||
});
|
|
||||||
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
|
|
||||||
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
const toggleAudio = useBehavior(muteStates.audio.toggle$);
|
||||||
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
const toggleVideo = useBehavior(muteStates.video.toggle$);
|
||||||
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
|
||||||
@@ -266,7 +252,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ringingIntent = useBehavior(vm.ringingIntent$);
|
const ringingVm = useBehavior(vm.ringingVm$);
|
||||||
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||||
const participantCount = useBehavior(vm.participantCount$);
|
const participantCount = useBehavior(vm.participantCount$);
|
||||||
const reconnecting = useBehavior(vm.reconnecting$);
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
@@ -286,22 +272,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
throw fatalCallError;
|
throw fatalCallError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// While ringing, loop the ringtone
|
|
||||||
useEffect((): void | (() => void) => {
|
|
||||||
const audio = latestPickupPhaseAudio.current;
|
|
||||||
if (ringingIntent !== null && audio) {
|
|
||||||
const endSound = audio.playSoundLooping(
|
|
||||||
"waiting",
|
|
||||||
audio.soundDuration["waiting"] ?? 1,
|
|
||||||
);
|
|
||||||
return () => {
|
|
||||||
void endSound().catch((e) => {
|
|
||||||
logger.error("Failed to stop ringing sound", e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [ringingIntent, latestPickupPhaseAudio]);
|
|
||||||
|
|
||||||
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
|
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
|
||||||
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
|
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
|
||||||
// so this still only fires for taps.
|
// so this still only fires for taps.
|
||||||
@@ -363,6 +333,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useAppBarHidden(!showHeader);
|
useAppBarHidden(!showHeader);
|
||||||
|
useAppBarSubtitle(
|
||||||
|
ringingVm && vm.ringingStatusLocation === "app_bar" && (
|
||||||
|
<RingingStatus vm={ringingVm} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let header: ReactNode = null;
|
let header: ReactNode = null;
|
||||||
switch (headerStyle) {
|
switch (headerStyle) {
|
||||||
@@ -457,6 +432,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
);
|
);
|
||||||
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
|
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
|
||||||
const showNameTags = useBehavior(vm.showNameTags$);
|
const showNameTags = useBehavior(vm.showNameTags$);
|
||||||
|
const showRingingStatus = vm.ringingStatusLocation === "tile";
|
||||||
|
|
||||||
return model instanceof GridTileViewModel ? (
|
return model instanceof GridTileViewModel ? (
|
||||||
<GridTile
|
<GridTile
|
||||||
@@ -469,6 +445,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
style={style}
|
style={style}
|
||||||
showSpeakingIndicators={showSpeakingIndicators}
|
showSpeakingIndicators={showSpeakingIndicators}
|
||||||
showNameTags={showNameTags}
|
showNameTags={showNameTags}
|
||||||
|
showRingingStatus={showRingingStatus}
|
||||||
focusable={!contentObscured}
|
focusable={!contentObscured}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -481,6 +458,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
showIndicators={showSpotlightIndicators}
|
showIndicators={showSpotlightIndicators}
|
||||||
showNameTags={showNameTags}
|
showNameTags={showNameTags}
|
||||||
|
showRingingStatus={showRingingStatus}
|
||||||
focusable={!contentObscured}
|
focusable={!contentObscured}
|
||||||
className={classNames(className, styles.tile)}
|
className={classNames(className, styles.tile)}
|
||||||
style={style}
|
style={style}
|
||||||
@@ -515,6 +493,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
targetHeight={gridBounds.height}
|
targetHeight={gridBounds.height}
|
||||||
showIndicators={false}
|
showIndicators={false}
|
||||||
showNameTags={showNameTags}
|
showNameTags={showNameTags}
|
||||||
|
showRingingStatus={vm.ringingStatusLocation === "tile"}
|
||||||
focusable={!contentObscured}
|
focusable={!contentObscured}
|
||||||
aria-hidden={contentObscured}
|
aria-hidden={contentObscured}
|
||||||
/>
|
/>
|
||||||
@@ -626,6 +605,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{renderContent()}
|
{renderContent()}
|
||||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||||
|
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
|
||||||
{reconnectingToast}
|
{reconnectingToast}
|
||||||
{earpieceOverlay}
|
{earpieceOverlay}
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
|
|||||||
@@ -130,16 +130,14 @@ describe("LobbyView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders with AppBar android", async () => {
|
it("renders with AppBar android", async () => {
|
||||||
const { container } = renderLobbyView(
|
const { container, getByRole } = renderLobbyView(
|
||||||
{
|
{
|
||||||
waitingForInvite: true,
|
waitingForInvite: true,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
"android",
|
"android",
|
||||||
);
|
);
|
||||||
expect(
|
getByRole("banner");
|
||||||
container.getElementsByClassName(headerStyles.header).length,
|
|
||||||
).toBeTruthy();
|
|
||||||
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
|
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
|
||||||
// not the default CollapseIcon
|
// not the default CollapseIcon
|
||||||
const { container: iconContainer } = render(<ArrowLeftIcon />);
|
const { container: iconContainer } = render(<ArrowLeftIcon />);
|
||||||
@@ -147,7 +145,7 @@ describe("LobbyView", () => {
|
|||||||
.querySelector("path")!
|
.querySelector("path")!
|
||||||
.getAttribute("d");
|
.getAttribute("d");
|
||||||
const primaryButtonSvgPath = container
|
const primaryButtonSvgPath = container
|
||||||
.querySelector(".leftNav button")
|
.querySelector(".primaryButton")
|
||||||
?.querySelector("path")
|
?.querySelector("path")
|
||||||
?.getAttribute("d");
|
?.getAttribute("d");
|
||||||
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
|
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
|
||||||
@@ -156,16 +154,14 @@ describe("LobbyView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders with AppBar ios", async () => {
|
it("renders with AppBar ios", async () => {
|
||||||
const { container } = renderLobbyView(
|
const { container, getByRole } = renderLobbyView(
|
||||||
{
|
{
|
||||||
waitingForInvite: true,
|
waitingForInvite: true,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
"ios",
|
"ios",
|
||||||
);
|
);
|
||||||
expect(
|
getByRole("banner");
|
||||||
container.getElementsByClassName(headerStyles.header).length,
|
|
||||||
).toBeTruthy();
|
|
||||||
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
|
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
|
||||||
// not the default CollapseIcon
|
// not the default CollapseIcon
|
||||||
const { container: iconContainer } = render(<ChevronLeftIcon />);
|
const { container: iconContainer } = render(<ChevronLeftIcon />);
|
||||||
@@ -173,7 +169,7 @@ describe("LobbyView", () => {
|
|||||||
.querySelector("path")!
|
.querySelector("path")!
|
||||||
.getAttribute("d");
|
.getAttribute("d");
|
||||||
const primaryButtonSvgPath = container
|
const primaryButtonSvgPath = container
|
||||||
.querySelector(".leftNav button")
|
.querySelector(".primaryButton")
|
||||||
?.querySelector("path")
|
?.querySelector("path")
|
||||||
?.getAttribute("d");
|
?.getAttribute("d");
|
||||||
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
|
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
|
||||||
|
|||||||
59
src/room/RingingAudioRenderer.test.tsx
Normal file
59
src/room/RingingAudioRenderer.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, type MockedFunction, test, vi } from "vitest";
|
||||||
|
import { act, render } from "@testing-library/react";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
|
||||||
|
import { useAudioContext } from "../useAudioContext";
|
||||||
|
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
|
||||||
|
import { alice, aliceId } from "../utils/test-fixtures";
|
||||||
|
import { constant } from "../state/Behavior";
|
||||||
|
import { RingingAudioRenderer } from "./RingingAudioRenderer";
|
||||||
|
import { prefetchSounds } from "../soundUtils";
|
||||||
|
|
||||||
|
vi.mock("../useAudioContext");
|
||||||
|
vi.mock("../soundUtils");
|
||||||
|
|
||||||
|
test("ringtone plays on loop while ringing", () => {
|
||||||
|
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
|
||||||
|
sound: new ArrayBuffer(0),
|
||||||
|
});
|
||||||
|
const endSoundLooping = vi.fn().mockReturnValue(Promise.resolve());
|
||||||
|
const playSoundLooping = vi.fn().mockReturnValue(endSoundLooping);
|
||||||
|
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||||
|
playSound: vi.fn(),
|
||||||
|
playSoundLooping,
|
||||||
|
soundDuration: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickupState$ = new BehaviorSubject<"ringing" | "timeout" | "decline">(
|
||||||
|
"ringing",
|
||||||
|
);
|
||||||
|
const vm = createRingingMedia({
|
||||||
|
id: aliceId,
|
||||||
|
userId: alice.userId,
|
||||||
|
displayName$: constant("Alice"),
|
||||||
|
mxcAvatarUrl$: constant(undefined),
|
||||||
|
intent: "audio",
|
||||||
|
pickupState$,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Begin ringing
|
||||||
|
render(<RingingAudioRenderer vm={vm} muted={false} />);
|
||||||
|
expect(playSoundLooping).toHaveBeenCalledExactlyOnceWith(
|
||||||
|
"ringtone",
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
expect(endSoundLooping).not.toHaveBeenCalled();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// End ringing
|
||||||
|
act(() => pickupState$.next("decline"));
|
||||||
|
expect(playSoundLooping).not.toHaveBeenCalled();
|
||||||
|
expect(endSoundLooping).toHaveBeenCalledExactlyOnceWith();
|
||||||
|
});
|
||||||
72
src/room/RingingAudioRenderer.tsx
Normal file
72
src/room/RingingAudioRenderer.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, type FC } from "react";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||||
|
import { useBehavior } from "../useBehavior";
|
||||||
|
import { useInitial } from "../useInitial";
|
||||||
|
import { prefetchSounds } from "../soundUtils";
|
||||||
|
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||||
|
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
||||||
|
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
|
||||||
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
|
interface RingingAudioRendererProps {
|
||||||
|
vm: RingingMediaViewModel | null;
|
||||||
|
muted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RingingAudioRenderer: FC<RingingAudioRendererProps> = ({
|
||||||
|
vm,
|
||||||
|
muted,
|
||||||
|
}) => {
|
||||||
|
// Preload a waiting and decline sounds
|
||||||
|
const sounds = useInitial(async () => {
|
||||||
|
return prefetchSounds({
|
||||||
|
ringtone: { mp3: ringtoneMp3, ogg: ringtoneOgg },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const audio = useAudioContext({
|
||||||
|
sounds,
|
||||||
|
latencyHint: "interactive",
|
||||||
|
muted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return vm && <ActiveRingingAudioRenderer vm={vm} audio={audio} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ActiveRingingAudioRendererProps {
|
||||||
|
vm: RingingMediaViewModel;
|
||||||
|
audio: UseAudioContext<"ringtone"> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveRingingAudioRenderer: FC<ActiveRingingAudioRendererProps> = ({
|
||||||
|
vm,
|
||||||
|
audio,
|
||||||
|
}) => {
|
||||||
|
const audio_ = useLatest(audio);
|
||||||
|
const pickupState = useBehavior(vm.pickupState$);
|
||||||
|
|
||||||
|
// While ringing, loop the ringtone
|
||||||
|
useEffect((): void | (() => void) => {
|
||||||
|
if (pickupState === "ringing" && audio_.current) {
|
||||||
|
const endSound = audio_.current.playSoundLooping(
|
||||||
|
"ringtone",
|
||||||
|
audio_.current.soundDuration["ringtone"] ?? 1,
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
void endSound().catch((e) => {
|
||||||
|
logger.error("Failed to stop ringing sound", e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [pickupState, audio_]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -4,43 +4,36 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="bar"
|
class="bar"
|
||||||
style="display: block;"
|
|
||||||
>
|
>
|
||||||
<header
|
<header>
|
||||||
class="header"
|
<button
|
||||||
>
|
aria-labelledby="_r_36_"
|
||||||
<div
|
class="_icon-button_1215g_8 primaryButton"
|
||||||
class="nav leftNav"
|
data-kind="primary"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 32px;"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
aria-labelledby="_r_36_"
|
class="_indicator-icon_147l5_17"
|
||||||
class="_icon-button_1215g_8"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
data-kind="primary"
|
|
||||||
role="button"
|
|
||||||
style="--cpd-icon-button-size: 32px;"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
class="_indicator-icon_147l5_17"
|
aria-hidden="true"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
aria-hidden="true"
|
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
|
||||||
fill="currentColor"
|
/>
|
||||||
height="1em"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
width="1em"
|
</button>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="nav rightNav"
|
class="secondaryButton"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,43 +235,36 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="bar"
|
class="bar"
|
||||||
style="display: block;"
|
|
||||||
>
|
>
|
||||||
<header
|
<header>
|
||||||
class="header"
|
<button
|
||||||
>
|
aria-labelledby="_r_4a_"
|
||||||
<div
|
class="_icon-button_1215g_8 primaryButton"
|
||||||
class="nav leftNav"
|
data-kind="primary"
|
||||||
|
role="button"
|
||||||
|
style="--cpd-icon-button-size: 32px;"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
aria-labelledby="_r_4a_"
|
class="_indicator-icon_147l5_17"
|
||||||
class="_icon-button_1215g_8"
|
style="--cpd-icon-button-size: 100%;"
|
||||||
data-kind="primary"
|
|
||||||
role="button"
|
|
||||||
style="--cpd-icon-button-size: 32px;"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
class="_indicator-icon_147l5_17"
|
aria-hidden="true"
|
||||||
style="--cpd-icon-button-size: 100%;"
|
fill="currentColor"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
aria-hidden="true"
|
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||||
fill="currentColor"
|
/>
|
||||||
height="1em"
|
</svg>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
width="1em"
|
</button>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
class="nav rightNav"
|
class="secondaryButton"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1420,10 +1420,13 @@ describe.each([
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should ring for 30ms and then time out
|
expectObservable(vm.ringingVm$).toBe("(ab)", {
|
||||||
expectObservable(vm.ringingIntent$).toBe("(ab) 26ms a", {
|
|
||||||
a: null,
|
a: null,
|
||||||
b: "audio",
|
b: expect.objectContaining({
|
||||||
|
type: "ringing",
|
||||||
|
userId: alice.userId,
|
||||||
|
intent: "audio",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
// Layout should show placeholder media for the participant we're
|
// Layout should show placeholder media for the participant we're
|
||||||
// ringing the entire time (even once timed out)
|
// ringing the entire time (even once timed out)
|
||||||
@@ -1463,9 +1466,13 @@ describe.each([
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Should ring until Alice joins
|
// Should ring until Alice joins
|
||||||
expectObservable(vm.ringingIntent$).toBe("(ab) 17ms a", {
|
expectObservable(vm.ringingVm$).toBe("(ab) 17ms a", {
|
||||||
a: null,
|
a: null,
|
||||||
b: "audio",
|
b: expect.objectContaining({
|
||||||
|
type: "ringing",
|
||||||
|
userId: alice.userId,
|
||||||
|
intent: "audio",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
// Layout should show placeholder media for the participant we're
|
// Layout should show placeholder media for the participant we're
|
||||||
// ringing the entire time
|
// ringing the entire time
|
||||||
|
|||||||
@@ -39,12 +39,10 @@ import {
|
|||||||
throttleTime,
|
throttleTime,
|
||||||
timer,
|
timer,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
concat,
|
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
MembershipManagerEvent,
|
MembershipManagerEvent,
|
||||||
type RTCCallIntent,
|
|
||||||
type LivekitTransportConfig,
|
type LivekitTransportConfig,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
@@ -231,9 +229,13 @@ export interface CallViewModel {
|
|||||||
// lifecycle
|
// lifecycle
|
||||||
autoLeave$: Observable<AutoLeaveReason>;
|
autoLeave$: Observable<AutoLeaveReason>;
|
||||||
/**
|
/**
|
||||||
* Whether we are ringing a call recipient. Contains the ringing intent if so.
|
* View model for info relating to ringing, timing out, calling back, etc.
|
||||||
*/
|
*/
|
||||||
ringingIntent$: Behavior<RTCCallIntent | null>;
|
ringingVm$: Behavior<RingingMediaViewModel | null>;
|
||||||
|
/**
|
||||||
|
* Which visual element the ringing status should be shown in.
|
||||||
|
*/
|
||||||
|
ringingStatusLocation: "app_bar" | "tile";
|
||||||
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
||||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
||||||
* - by ending the scope
|
* - by ending the scope
|
||||||
@@ -1702,15 +1704,9 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoLeave$: autoLeave$,
|
autoLeave$: autoLeave$,
|
||||||
ringingIntent$: scope.behavior(
|
ringingVm$: ringingMedia$,
|
||||||
ringAttempts$.pipe(
|
ringingStatusLocation:
|
||||||
switchMap(({ intent, outcome$ }) =>
|
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
|
||||||
// Hold the intent as the value until the ring attempt completes
|
|
||||||
concat(of(intent), NEVER.pipe(takeUntil(outcome$)), of(null)),
|
|
||||||
),
|
|
||||||
startWith<RTCCallIntent | null>(null),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
leave$: leave$,
|
leave$: leave$,
|
||||||
hangup: (): void => userHangup$.next(),
|
hangup: (): void => userHangup$.next(),
|
||||||
join: localMembership.requestJoinAndPublish,
|
join: localMembership.requestJoinAndPublish,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ test("GridTile is accessible", async () => {
|
|||||||
targetHeight={200}
|
targetHeight={200}
|
||||||
showSpeakingIndicators
|
showSpeakingIndicators
|
||||||
showNameTags
|
showNameTags
|
||||||
|
showRingingStatus
|
||||||
focusable
|
focusable
|
||||||
/>
|
/>
|
||||||
</ReactionsSenderProvider>,
|
</ReactionsSenderProvider>,
|
||||||
@@ -108,6 +109,7 @@ test("GridTile displays ringing media", async () => {
|
|||||||
targetHeight={200}
|
targetHeight={200}
|
||||||
showSpeakingIndicators
|
showSpeakingIndicators
|
||||||
showNameTags
|
showNameTags
|
||||||
|
showRingingStatus
|
||||||
focusable
|
focusable
|
||||||
/>
|
/>
|
||||||
</ReactionsSenderProvider>,
|
</ReactionsSenderProvider>,
|
||||||
|
|||||||
@@ -29,15 +29,13 @@ import {
|
|||||||
UserProfileIcon,
|
UserProfileIcon,
|
||||||
VolumeOffSolidIcon,
|
VolumeOffSolidIcon,
|
||||||
SwitchCameraSolidIcon,
|
SwitchCameraSolidIcon,
|
||||||
VideoCallSolidIcon,
|
|
||||||
VoiceCallSolidIcon,
|
|
||||||
EndCallIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
ToggleMenuItem,
|
ToggleMenuItem,
|
||||||
Menu,
|
Menu,
|
||||||
|
Text,
|
||||||
} from "@vector-im/compound-web";
|
} from "@vector-im/compound-web";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
|
|
||||||
@@ -53,6 +51,7 @@ import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewM
|
|||||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||||
|
import { RingingStatus } from "./RingingStatus";
|
||||||
|
|
||||||
interface TileProps {
|
interface TileProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
@@ -68,16 +67,15 @@ interface TileProps {
|
|||||||
|
|
||||||
interface RingingMediaTileProps extends TileProps {
|
interface RingingMediaTileProps extends TileProps {
|
||||||
vm: RingingMediaViewModel;
|
vm: RingingMediaViewModel;
|
||||||
|
showStatus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
||||||
vm,
|
vm,
|
||||||
|
showStatus,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const pickupState = useBehavior(vm.pickupState$);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaView
|
<MediaView
|
||||||
className={classNames(className, styles.tile)}
|
className={classNames(className, styles.tile)}
|
||||||
@@ -85,14 +83,13 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
|||||||
userId={vm.userId}
|
userId={vm.userId}
|
||||||
unencryptedWarning={false}
|
unencryptedWarning={false}
|
||||||
status={
|
status={
|
||||||
pickupState === "ringing"
|
showStatus && (
|
||||||
? {
|
<Text as="span" size="sm" weight="medium">
|
||||||
text: t("video_tile.calling"),
|
<RingingStatus vm={vm} />
|
||||||
Icon:
|
</Text>
|
||||||
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon,
|
)
|
||||||
}
|
|
||||||
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
|
|
||||||
}
|
}
|
||||||
|
avatarStyle="translucent"
|
||||||
videoEnabled={false}
|
videoEnabled={false}
|
||||||
videoFit="cover"
|
videoFit="cover"
|
||||||
mirror={false}
|
mirror={false}
|
||||||
@@ -400,6 +397,7 @@ interface GridTileProps {
|
|||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
showSpeakingIndicators: boolean;
|
showSpeakingIndicators: boolean;
|
||||||
showNameTags: boolean;
|
showNameTags: boolean;
|
||||||
|
showRingingStatus: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +405,7 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
ref: theirRef,
|
ref: theirRef,
|
||||||
vm,
|
vm,
|
||||||
showSpeakingIndicators,
|
showSpeakingIndicators,
|
||||||
|
showRingingStatus,
|
||||||
onOpenProfile,
|
onOpenProfile,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
@@ -423,6 +422,7 @@ export const GridTile: FC<GridTileProps> = ({
|
|||||||
vm={media}
|
vm={media}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
mxcAvatarUrl={mxcAvatarUrl}
|
mxcAvatarUrl={mxcAvatarUrl}
|
||||||
|
showStatus={showRingingStatus}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -54,11 +54,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
opacity: 100%;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.translucent {
|
.avatar[data-style="translucent"] {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
mix-blend-mode: multiply;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CSS makes us put a condition here, even though all we want to do is
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
@@ -121,18 +122,18 @@ unconditionally select the container so we can use cqmin units */
|
|||||||
.status {
|
.status {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
color: var(--cpd-color-text-primary);
|
color: var(--cpd-color-text-primary);
|
||||||
display: flex;
|
|
||||||
flex-wrap: none;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset));
|
margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset));
|
||||||
margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset));
|
margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset));
|
||||||
}
|
|
||||||
|
|
||||||
.status svg {
|
svg {
|
||||||
color: var(--cpd-color-icon-tertiary);
|
color: var(--cpd-color-icon-tertiary);
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin-inline-end: 3px;
|
||||||
|
block-size: 1.2em;
|
||||||
|
inline-size: 1.2em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions {
|
.reactions {
|
||||||
|
|||||||
@@ -7,13 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import {
|
import { type FC, type ComponentProps, type ReactNode } from "react";
|
||||||
type FC,
|
|
||||||
type ComponentProps,
|
|
||||||
type ReactNode,
|
|
||||||
type ComponentType,
|
|
||||||
type SVGAttributes,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { VideoTrack } from "@livekit/components-react";
|
import { VideoTrack } from "@livekit/components-react";
|
||||||
@@ -43,11 +37,12 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
userId: string;
|
userId: string;
|
||||||
videoEnabled: boolean;
|
videoEnabled: boolean;
|
||||||
unencryptedWarning: boolean;
|
unencryptedWarning: boolean;
|
||||||
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
status?: ReactNode;
|
||||||
showNameTags: boolean;
|
showNameTags: boolean;
|
||||||
nameTagLeadingIcon?: ReactNode;
|
nameTagLeadingIcon?: ReactNode;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
mxcAvatarUrl: string | undefined;
|
mxcAvatarUrl: string | undefined;
|
||||||
|
avatarStyle?: "solid" | "translucent";
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
raisedHandTime?: Date;
|
raisedHandTime?: Date;
|
||||||
@@ -77,6 +72,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
nameTagLeadingIcon,
|
nameTagLeadingIcon,
|
||||||
displayName,
|
displayName,
|
||||||
mxcAvatarUrl,
|
mxcAvatarUrl,
|
||||||
|
avatarStyle = "solid",
|
||||||
focusable,
|
focusable,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
status,
|
status,
|
||||||
@@ -130,11 +126,8 @@ export const MediaView: FC<Props> = ({
|
|||||||
name={displayName}
|
name={displayName}
|
||||||
size={avatarSize}
|
size={avatarSize}
|
||||||
src={mxcAvatarUrl}
|
src={mxcAvatarUrl}
|
||||||
className={classNames(styles.avatar, {
|
data-style={avatarStyle}
|
||||||
// When the avatar is overlaid with a status, make it translucent
|
className={styles.avatar}
|
||||||
// for readability
|
|
||||||
[styles.translucent]: status,
|
|
||||||
})}
|
|
||||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||||
/>
|
/>
|
||||||
{video?.publication !== undefined && (
|
{video?.publication !== undefined && (
|
||||||
@@ -180,14 +173,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status && (
|
{status && <div className={styles.status}>{status}</div>}
|
||||||
<div className={styles.status}>
|
|
||||||
<status.Icon width={16} height={16} aria-hidden />
|
|
||||||
<Text as="span" size="sm" weight="medium">
|
|
||||||
{status.text}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* TODO: Bring this back once encryption status is less broken */}
|
{/* TODO: Bring this back once encryption status is less broken */}
|
||||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
|
|||||||
41
src/tile/RingingStatus.tsx
Normal file
41
src/tile/RingingStatus.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type FC } from "react";
|
||||||
|
import {
|
||||||
|
VideoCallSolidIcon,
|
||||||
|
VoiceCallSolidIcon,
|
||||||
|
EndCallIcon,
|
||||||
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||||
|
import { useBehavior } from "../useBehavior";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vm: RingingMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RingingStatus: FC<Props> = ({ vm }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const pickupState = useBehavior(vm.pickupState$);
|
||||||
|
const Icon =
|
||||||
|
pickupState === "ringing"
|
||||||
|
? vm.intent === "video"
|
||||||
|
? VideoCallSolidIcon
|
||||||
|
: VoiceCallSolidIcon
|
||||||
|
: EndCallIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Icon aria-hidden />
|
||||||
|
{pickupState === "ringing"
|
||||||
|
? t("video_tile.calling")
|
||||||
|
: t("video_tile.call_ended")}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -65,6 +65,7 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
showNameTags
|
showNameTags
|
||||||
|
showRingingStatus
|
||||||
focusable={true}
|
focusable={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -107,6 +108,7 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
|
|||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
showNameTags
|
showNameTags
|
||||||
|
showRingingStatus
|
||||||
focusable
|
focusable
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
@@ -137,6 +139,7 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
|||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
showNameTags
|
showNameTags
|
||||||
|
showRingingStatus
|
||||||
focusable
|
focusable
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -172,6 +175,7 @@ test("SpotlightTile displays ringing media", async () => {
|
|||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
showIndicators
|
showIndicators
|
||||||
showNameTags
|
showNameTags
|
||||||
|
showRingingStatus
|
||||||
focusable={true}
|
focusable={true}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ import {
|
|||||||
VolumeOnIcon,
|
VolumeOnIcon,
|
||||||
VolumeOffSolidIcon,
|
VolumeOffSolidIcon,
|
||||||
VolumeOnSolidIcon,
|
VolumeOnSolidIcon,
|
||||||
VideoCallSolidIcon,
|
|
||||||
VoiceCallSolidIcon,
|
|
||||||
EndCallIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
import { animated } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import { type Observable, map } from "rxjs";
|
import { type Observable, map } from "rxjs";
|
||||||
@@ -34,7 +31,7 @@ import { useObservableRef } from "observable-hooks";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||||
import { Menu, MenuItem } from "@vector-im/compound-web";
|
import { Menu, MenuItem, Text } from "@vector-im/compound-web";
|
||||||
|
|
||||||
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
||||||
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||||
@@ -56,6 +53,7 @@ import { type MediaViewModel } from "../state/media/MediaViewModel";
|
|||||||
import { Slider } from "../Slider";
|
import { Slider } from "../Slider";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||||
|
import { RingingStatus } from "./RingingStatus";
|
||||||
|
|
||||||
interface SpotlightItemBaseProps {
|
interface SpotlightItemBaseProps {
|
||||||
ref?: Ref<HTMLDivElement>;
|
ref?: Ref<HTMLDivElement>;
|
||||||
@@ -204,28 +202,26 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
|
|||||||
|
|
||||||
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
|
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
|
||||||
vm: RingingMediaViewModel;
|
vm: RingingMediaViewModel;
|
||||||
|
showStatus: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
|
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
|
||||||
vm,
|
vm,
|
||||||
|
showStatus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const pickupState = useBehavior(vm.pickupState$);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MediaView
|
<MediaView
|
||||||
video={undefined}
|
video={undefined}
|
||||||
unencryptedWarning={false}
|
unencryptedWarning={false}
|
||||||
status={
|
status={
|
||||||
pickupState === "ringing"
|
showStatus && (
|
||||||
? {
|
<Text as="span" size="md" weight="medium">
|
||||||
text: t("video_tile.calling"),
|
<RingingStatus vm={vm} />
|
||||||
Icon:
|
</Text>
|
||||||
vm.intent === "video" ? VideoCallSolidIcon : VoiceCallSolidIcon,
|
)
|
||||||
}
|
|
||||||
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
|
|
||||||
}
|
}
|
||||||
|
avatarStyle="translucent"
|
||||||
videoEnabled={false}
|
videoEnabled={false}
|
||||||
videoFit="cover"
|
videoFit="cover"
|
||||||
mirror={false}
|
mirror={false}
|
||||||
@@ -246,6 +242,7 @@ interface SpotlightItemProps {
|
|||||||
*/
|
*/
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
showNameTags: boolean;
|
showNameTags: boolean;
|
||||||
|
showRingingStatus: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
intersectionObserver$: Observable<IntersectionObserver>;
|
intersectionObserver$: Observable<IntersectionObserver>;
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +258,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
targetWidth,
|
targetWidth,
|
||||||
targetHeight,
|
targetHeight,
|
||||||
showNameTags,
|
showNameTags,
|
||||||
|
showRingingStatus,
|
||||||
focusable,
|
focusable,
|
||||||
intersectionObserver$,
|
intersectionObserver$,
|
||||||
snap,
|
snap,
|
||||||
@@ -302,7 +300,11 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return vm.type === "ringing" ? (
|
return vm.type === "ringing" ? (
|
||||||
<SpotlightRingingMediaItem vm={vm} {...baseProps} />
|
<SpotlightRingingMediaItem
|
||||||
|
vm={vm}
|
||||||
|
showStatus={showRingingStatus}
|
||||||
|
{...baseProps}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
|
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
@@ -386,6 +388,7 @@ interface Props {
|
|||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
showIndicators: boolean;
|
showIndicators: boolean;
|
||||||
showNameTags: boolean;
|
showNameTags: boolean;
|
||||||
|
showRingingStatus: boolean;
|
||||||
focusable: boolean;
|
focusable: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: ComponentProps<typeof animated.div>["style"];
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
@@ -400,6 +403,7 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
targetHeight,
|
targetHeight,
|
||||||
showIndicators,
|
showIndicators,
|
||||||
showNameTags,
|
showNameTags,
|
||||||
|
showRingingStatus,
|
||||||
focusable = true,
|
focusable = true,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
@@ -510,6 +514,7 @@ export const SpotlightTile: FC<Props> = ({
|
|||||||
vm={vm}
|
vm={vm}
|
||||||
targetWidth={targetWidth}
|
targetWidth={targetWidth}
|
||||||
targetHeight={targetHeight}
|
targetHeight={targetHeight}
|
||||||
|
showRingingStatus={showRingingStatus}
|
||||||
showNameTags={showNameTags}
|
showNameTags={showNameTags}
|
||||||
focusable={focusable}
|
focusable={focusable}
|
||||||
intersectionObserver$={intersectionObserver$}
|
intersectionObserver$={intersectionObserver$}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ interface Props<S extends string> {
|
|||||||
muted?: boolean;
|
muted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseAudioContext<S extends string> {
|
export interface UseAudioContext<S extends string> {
|
||||||
playSound(soundName: S, volumeOverwrite?: number): Promise<void>;
|
playSound(soundName: S, volumeOverwrite?: number): Promise<void>;
|
||||||
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
|
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user