merge: upstream v0.25.0 into lotus
Merge upstream element-hq/element-call tag v0.25.0 into the Lotus fork (previous base: v0.20.1; actual merge-base v0.20.1-rc.1). Every Lotus feature and all six io.lotus.* widget actions are preserved. Version bumped to 0.25.0-lotus.1. Conflict files and how each Lotus hunk was re-expressed: * src/state/CallViewModel/remoteMembers/ConnectionFactory.ts Upstream moved echoCancellation/noiseSuppression/autoGainControl from constructor params (fed by URL params) to persisted Settings (settings.ts) with a developer-settings UI. The cinny host still drives these per call via URL params (noiseSuppression=false / autoGainControl=false when the in-source ML denoiser is active, so the model gets a raw mic) - taking upstream verbatim would silently break the ML denoise tier. Re-wired as AND semantics in generateRoomOption(): a constraint is enabled only if BOTH the Setting and the URL param allow it. Params default to true, so with no params this is byte-for-byte upstream behaviour. Upstream's own echoCancellation / noiseSuppression URL params (still parsed but dead in v0.25.0) work again as a side effect. Lotus autoGainControl URL param kept in UrlParams.ts (auto-merged, unchanged). * src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts Took upstream (tests now drive via Settings). The lost Lotus coverage is restored in a NEW colocated file src/lotus/lotusAudioConstraints.test.ts (3 tests) so the upstream test file stays pristine. Verified the new test fails against pure-upstream ConnectionFactory and passes with the re-wiring. * src/state/CallViewModel/CallViewModel.ts Three small hunks: kept both the Lotus `userMedia$` interface member and upstream's new `keyRotationSuppressed$`; dropped the three Lotus audio constructor args (mechanism removed upstream, see above); kept both in the returned object. The [lotus #4] overrideSpotlight$ routing, manualSpotlightUserId$ and setManualSpotlight auto-merged; verified against upstream's changed ringingMedia$ (now single-or-null instead of array) - the merge correctly took upstream's outer branch and the inner screenShares$/spotlightSpeaker$ logic that lotusSpotlight.ts mirrors is unchanged upstream. * src/index.css Kept both: Lotus lotus-transparent / lotus-theme blocks and upstream's new body[data-background="gradient"]::before full-viewport gradient. The naive merge swallowed the closing brace of body.lotus-theme - restored. Added a rule hiding the new gradient pseudo-element under body.lotus-transparent, since it would otherwise paint over the transparent body and hide the host wallpaper. * src/components/CallFooterViewModel.tsx, src/components/CallFooter.stories.tsx No Lotus content - pure upstream-vs-upstream conflicts caused by the merge base being v0.20.1-rc.1. Took upstream (layoutMode -> layoutSwitchVm; setLayoutMode removed). No Lotus code uses setGridMode/layoutMode. Non-conflicting but reviewed: * src/widget.ts auto-merged cleanly. Upstream's removal of .well-known transport advertisement and the new RTC-transport capability request did not touch the action registration loop the LOTUS_TO_WIDGET_ACTIONS spread and widget.lazyActions ride on - nothing to re-wire. * src/room/InCallView.tsx, src/useAudioContext.tsx, src/useTheme.ts, src/tile/MediaView.tsx(+.module.css), src/UrlParams.ts(+test), all *.module.css and .gitea/workflows/ci.yml auto-merged; each diff against v0.25.0 was checked to equal the original Lotus hunk. * src/button/Button.module.css: the merge appended an exact duplicate of upstream's `.rotate`/`@keyframes spin` block (rc.1 merge-base artefact) - reset to upstream verbatim. * src/grid/OneOnOnePortraitLayout.module.css was renamed upstream to OneOnOneMobileLayout.module.css; git followed the rename and the Lotus safe-area PiP inset fix applies there (the --content-inset-* vars it uses still exist upstream). Tooling changes inherited from upstream that affect the fork: * eslint + prettier were replaced by oxlint + oxfmt (`pnpm lint:oxlint`, `pnpm format:check`). oxlint flagged 10 issues, all in src/lotus/*: 8x no-meaningless-void-operator (dropped the `void` before void-typed widget transport.reply / callbacks - no behaviour change), 1x consistent-type-imports (lotusWidget.ts: `import type`), and 2x unicorn/no-useless-spread in lotusAudioInject.ts which are FALSE POSITIVES - `[...activeClips]` is a required defensive copy because abort() deletes from the Set during iteration; suppressed with an explanatory eslint-disable-next-line. oxfmt reformatted 7 Lotus touched files (whitespace only). * packageManager bumped by upstream to pnpm@11.21.0, which requires Node >= 22.13 (uses node:sqlite). Node 20 cannot run it; pnpm 10.33 cannot read the new lockfile either (matrix-js-sdk is now a git dependency on develop, using a version-union pnpm 10 rejects). Fork CI already uses Node 24 (.node-version), so CI is unaffected. * matrix-js-sdk is now github:matrix-org/matrix-js-sdk#develop (pinned by commit in pnpm-lock.yaml). Lotus behaviour NOT preserved: none found. Verification (Node 24.11.1, pnpm 11.21.0): pnpm install --frozen-lockfile OK (lockfile taken from upstream unchanged, no regeneration needed); tsc clean; oxlint clean; oxfmt --check clean; knip exit 0 (2 config hints in upstream knip.ts only); vitest unit 84 files / 627 passed / 9 skipped; build:embedded OK, staged to embedded/web/dist (44M), all six io.lotus.* action strings present in the bundle. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -10,6 +10,7 @@ Please see LICENSE in the repository root for full details.
|
||||
--hover-space-margin: var(--cpd-space-1x);
|
||||
transition: outline-color ease 0.15s;
|
||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||
box-shadow: var(--draggable-shadow);
|
||||
}
|
||||
|
||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||
@@ -65,6 +66,11 @@ borders don't support gradients */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tile.outline {
|
||||
outline: var(--cpd-border-width-1) solid
|
||||
var(--cpd-color-border-interactive-secondary);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.tile:hover {
|
||||
outline: var(--cpd-border-width-2) solid
|
||||
|
||||
@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RemoteTrackPublication } from "livekit-client";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
type RemoteTrackPublication,
|
||||
} from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
@@ -17,6 +20,9 @@ import {
|
||||
mockRtcMembership,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
mockLocalMedia,
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
} from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
@@ -26,7 +32,6 @@ import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../state/media/RingingMediaViewModel";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
@@ -55,7 +60,7 @@ const callVm = {
|
||||
handsRaised$: constant({}),
|
||||
} as Partial<CallViewModel> as CallViewModel;
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
test("GridTile displays remote media", async () => {
|
||||
const vm = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
@@ -78,6 +83,42 @@ test("GridTile is accessible", async () => {
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
showOutline
|
||||
focusable
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Name should be visible
|
||||
screen.getByText("Alice");
|
||||
});
|
||||
|
||||
test("GridTile displays local media", async () => {
|
||||
const vm = mockLocalMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
mockLocalParticipant({
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
}),
|
||||
mockMediaDevices({}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
|
||||
<GridTile
|
||||
vm={new GridTileViewModel(constant(vm))}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
showOutline
|
||||
focusable
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
@@ -93,10 +134,8 @@ test("GridTile displays ringing media", async () => {
|
||||
>("ringing");
|
||||
const vm = createRingingMedia({
|
||||
pickupState$,
|
||||
muteStates: {
|
||||
video: { enabled$: constant(false) },
|
||||
} as unknown as MuteStates,
|
||||
id: "test",
|
||||
intent: "audio",
|
||||
userId: "@alice:example.org",
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
@@ -111,6 +150,8 @@ test("GridTile displays ringing media", async () => {
|
||||
targetHeight={200}
|
||||
showSpeakingIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
showOutline
|
||||
focusable
|
||||
/>
|
||||
</ReactionsSenderProvider>,
|
||||
|
||||
+99
-69
@@ -11,9 +11,9 @@ import {
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
@@ -29,15 +29,13 @@ import {
|
||||
UserProfileIcon,
|
||||
VolumeOffSolidIcon,
|
||||
SwitchCameraSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
VoiceCallSolidIcon,
|
||||
EndCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
MenuItem,
|
||||
ToggleMenuItem,
|
||||
Menu,
|
||||
Text,
|
||||
} from "@vector-im/compound-web";
|
||||
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 UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -68,17 +67,15 @@ interface TileProps {
|
||||
|
||||
interface RingingMediaTileProps extends TileProps {
|
||||
vm: RingingMediaViewModel;
|
||||
showStatus: boolean;
|
||||
}
|
||||
|
||||
const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
||||
vm,
|
||||
showStatus,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const pickupState = useBehavior(vm.pickupState$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
|
||||
return (
|
||||
<MediaView
|
||||
className={classNames(className, styles.tile)}
|
||||
@@ -86,15 +83,14 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
||||
userId={vm.userId}
|
||||
unencryptedWarning={false}
|
||||
status={
|
||||
pickupState === "ringing"
|
||||
? {
|
||||
text: t("video_tile.calling"),
|
||||
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon,
|
||||
}
|
||||
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
|
||||
showStatus && (
|
||||
<Text as="span" size="sm" weight="medium">
|
||||
<RingingStatus vm={vm} />
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit="cover"
|
||||
avatarStyle="translucent"
|
||||
videoEnabled={false}
|
||||
mirror={false}
|
||||
{...props}
|
||||
/>
|
||||
@@ -108,20 +104,22 @@ interface UserMediaTileProps extends TileProps {
|
||||
playbackMuted: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
focusUrl: string | undefined;
|
||||
}
|
||||
|
||||
const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
/**
|
||||
* A user media tile without a context menu.
|
||||
*/
|
||||
// The context menu is kept separate from this component for performance
|
||||
// reasons (c.f. UserMediaTile)
|
||||
const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
|
||||
ref,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
playbackMuted,
|
||||
waitingForMedia,
|
||||
primaryButton,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
menu,
|
||||
className,
|
||||
focusUrl,
|
||||
displayName,
|
||||
@@ -144,19 +142,11 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
const videoFit = useBehavior(vm.videoFit$);
|
||||
|
||||
const rtcBackendIdentity = vm.rtcBackendIdentity;
|
||||
const handRaised = useBehavior(vm.handRaised$);
|
||||
const reaction = useBehavior(vm.reaction$);
|
||||
|
||||
// Whenever bounds change, inform the viewModel
|
||||
useEffect(() => {
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
||||
}
|
||||
}, [targetWidth, targetHeight, vm]);
|
||||
|
||||
const AudioIcon = playbackMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
@@ -169,31 +159,32 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
: t("microphone_off");
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
{/*
|
||||
No additional menu item (used to be the manual fit to frame.
|
||||
Placeholder for future menu items that should be placed here.
|
||||
*/}
|
||||
{menuEnd}
|
||||
</>
|
||||
const menuTrigger = useMemo(
|
||||
() => (
|
||||
<button
|
||||
aria-label={t("common.options")}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
),
|
||||
[t, focusable],
|
||||
);
|
||||
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
const raisedHandOnClick = useMemo(
|
||||
() => (vm.local ? (): void => void toggleRaisedHand() : undefined),
|
||||
[vm.local, toggleRaisedHand],
|
||||
);
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
const tile = (
|
||||
return (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video}
|
||||
userId={vm.userId}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={videoFit}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
@@ -216,14 +207,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button
|
||||
aria-label={t("common.options")}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
trigger={menuTrigger}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
@@ -236,6 +220,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
waitingForMedia={waitingForMedia}
|
||||
focusUrl={focusUrl}
|
||||
setVideoAspectRatio={vm.setVideoAspectRatio}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
rtcBackendIdentity={rtcBackendIdentity}
|
||||
@@ -244,9 +229,37 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A user media tile enhanced with a context menu.
|
||||
*/
|
||||
const UserMediaTile: FC<
|
||||
UserMediaTileProps & { menuStart?: ReactNode; menuEnd?: ReactNode }
|
||||
> = ({ menuStart, menuEnd, ...props }) => {
|
||||
const menu = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{menuStart}
|
||||
{/*
|
||||
No additional menu item (used to be the manual fit to frame.
|
||||
Placeholder for future menu items that should be placed here.
|
||||
*/}
|
||||
{menuEnd}
|
||||
</>
|
||||
),
|
||||
[menuStart, menuEnd],
|
||||
);
|
||||
|
||||
// ContextMenu is expensive to render, so we avoid subscribing to any
|
||||
// frequently-changing behaviors here and instead keep them isolated in the
|
||||
// UserMediaTileInner component
|
||||
return (
|
||||
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
<ContextMenu
|
||||
title={props.displayName}
|
||||
trigger={<UserMediaTileInner {...props} menu={menu} />}
|
||||
hasAccessibleAlternative
|
||||
>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -282,6 +295,29 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
[vm, latestAlwaysShow],
|
||||
);
|
||||
|
||||
const menuStart = useMemo(
|
||||
() => (
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
/>
|
||||
),
|
||||
[t, alwaysShow, onSelectAlwaysShow],
|
||||
);
|
||||
const menuEnd = useMemo(
|
||||
() =>
|
||||
onOpenProfile && (
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
),
|
||||
[t, onOpenProfile],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
@@ -300,23 +336,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
/>
|
||||
}
|
||||
menuEnd={
|
||||
onOpenProfile && (
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
menuStart={menuStart}
|
||||
menuEnd={menuEnd}
|
||||
focusable={focusable}
|
||||
focusUrl={focusUrl}
|
||||
{...props}
|
||||
@@ -400,6 +421,8 @@ interface GridTileProps {
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
showSpeakingIndicators: boolean;
|
||||
showNameTags: boolean;
|
||||
showRingingStatus: boolean;
|
||||
showOutline: boolean;
|
||||
focusable: boolean;
|
||||
}
|
||||
|
||||
@@ -407,7 +430,10 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
showRingingStatus,
|
||||
showOutline,
|
||||
onOpenProfile,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -423,6 +449,8 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
vm={media}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
showStatus={showRingingStatus}
|
||||
className={classNames(className, { [styles.outline]: showOutline })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -435,6 +463,7 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
className={classNames(className, { [styles.outline]: showOutline })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -446,6 +475,7 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
showSpeakingIndicators={showSpeakingIndicators}
|
||||
displayName={displayName}
|
||||
mxcAvatarUrl={mxcAvatarUrl}
|
||||
className={classNames(className, { [styles.outline]: showOutline })}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,8 +27,15 @@ Please see LICENSE in the repository root for full details.
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.media[data-video-enabled="false"] video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.media.mirror video {
|
||||
transform: scaleX(-1);
|
||||
/* In FF if you add a transform: scale/translate/matrix filter on an element,
|
||||
it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.media[data-video-fit="cover"] video {
|
||||
@@ -41,24 +48,76 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.bg {
|
||||
grid-area: content;
|
||||
background-color: var(--video-tile-background);
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
border-radius: inherit;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.media[data-background="solid"] .bg {
|
||||
background-color: var(--video-tile-background);
|
||||
}
|
||||
|
||||
.waves {
|
||||
transition: opacity ease 0.3s;
|
||||
}
|
||||
|
||||
.waves[data-visible="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.waves[data-visible="false"] {
|
||||
opacity: 0;
|
||||
|
||||
@media not (prefers-reduced-motion) {
|
||||
.wave {
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wave {
|
||||
border: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-300);
|
||||
transition: transform ease 0.2s;
|
||||
}
|
||||
|
||||
.wave,
|
||||
.speakingBorder {
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.speakingBorder {
|
||||
background:
|
||||
radial-gradient(#0467dd, #0bc491),
|
||||
linear-gradient(0deg, #0467dd 0%, #0bc491 100%);
|
||||
background-blend-mode: overlay, normal;
|
||||
outline: var(--cpd-border-width-4) solid var(--cpd-color-bg-canvas-default);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: var(--cpd-border-width-2);
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
opacity: 100%;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.translucent {
|
||||
.avatar[data-style="translucent"] {
|
||||
opacity: 50%;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* [lotus #6] Profile decoration overlaid on the tile avatar. Shares the
|
||||
@@ -87,6 +146,35 @@ unconditionally select the container so we can use cqmin units */
|
||||
inline-size: 50cqmin;
|
||||
block-size: 50cqmin;
|
||||
}
|
||||
|
||||
.waves + .avatar {
|
||||
/* Make the avatar slightly smaller to accommodate sound waves, if present */
|
||||
inline-size: 38cqmin;
|
||||
block-size: 38cqmin;
|
||||
}
|
||||
|
||||
.wave:nth-child(1) {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-5x) + 3 * var(--cpd-space-10x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-5x) + 3 * var(--cpd-space-10x));
|
||||
background: var(--cpd-color-alpha-gray-200);
|
||||
}
|
||||
|
||||
.wave:nth-child(2) {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-5x) + 2 * var(--cpd-space-10x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-5x) + 2 * var(--cpd-space-10x));
|
||||
background: var(--cpd-color-alpha-gray-300);
|
||||
}
|
||||
|
||||
.wave:nth-child(3) {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-5x) + var(--cpd-space-10x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-5x) + var(--cpd-space-10x));
|
||||
background: var(--cpd-color-alpha-gray-400);
|
||||
}
|
||||
|
||||
.speakingBorder {
|
||||
inline-size: calc(38cqmin + var(--cpd-space-3x));
|
||||
block-size: calc(38cqmin + var(--cpd-space-3x));
|
||||
}
|
||||
}
|
||||
|
||||
.avatar > img {
|
||||
@@ -139,18 +227,18 @@ unconditionally select the container so we can use cqmin units */
|
||||
.status {
|
||||
grid-area: status;
|
||||
color: var(--cpd-color-text-primary);
|
||||
display: flex;
|
||||
flex-wrap: none;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
margin-block-start: calc(var(--cpd-space-3x) - var(--fg-inset));
|
||||
margin-inline-start: calc(var(--cpd-space-4x) - var(--fg-inset));
|
||||
}
|
||||
|
||||
.status svg {
|
||||
color: var(--cpd-color-icon-tertiary);
|
||||
svg {
|
||||
color: var(--cpd-color-icon-tertiary);
|
||||
vertical-align: text-bottom;
|
||||
margin-inline-end: 3px;
|
||||
block-size: 1.2em;
|
||||
inline-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
type TrackReference,
|
||||
type TrackReferencePlaceholder,
|
||||
} from "@livekit/components-core";
|
||||
import { LocalTrackPublication, Track } from "livekit-client";
|
||||
import { TrackInfo } from "@livekit/protocol";
|
||||
import { type LocalTrackPublication, Track } from "livekit-client";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
import { MediaView } from "./MediaView";
|
||||
@@ -28,16 +27,12 @@ describe("MediaView", () => {
|
||||
};
|
||||
const trackReference: TrackReference = {
|
||||
...trackReferencePlaceholder,
|
||||
publication: new LocalTrackPublication(
|
||||
Track.Kind.Video,
|
||||
new TrackInfo({ sid: "id", name: "name" }),
|
||||
),
|
||||
publication: {} as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
};
|
||||
|
||||
const baseProps: ComponentProps<typeof MediaView> = {
|
||||
displayName: "some name",
|
||||
videoEnabled: true,
|
||||
videoFit: "contain",
|
||||
targetWidth: 300,
|
||||
targetHeight: 200,
|
||||
mirror: false,
|
||||
@@ -129,30 +124,4 @@ describe("MediaView", () => {
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoEnabled", () => {
|
||||
test("just video is visible", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} videoEnabled={true} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("video")).toBeVisible();
|
||||
expect(screen.queryAllByRole("img", { name: "some name" }).length).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("just avatar is visible", () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<MediaView {...baseProps} videoEnabled={false} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByTestId("video")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+78
-32
@@ -11,8 +11,8 @@ import {
|
||||
type FC,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
type SVGAttributes,
|
||||
type SyntheticEvent,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { ReactionIndicator } from "../reactions/ReactionIndicator";
|
||||
import { RTCConnectionStats } from "../RTCConnectionStats";
|
||||
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
||||
import { autoVideoFit } from "../utils/videoFit";
|
||||
|
||||
interface Props extends ComponentProps<typeof animated.div> {
|
||||
className?: string;
|
||||
@@ -39,16 +41,23 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoFit: "cover" | "contain";
|
||||
/**
|
||||
* How to fit the video content inside the tile. When undefined, MediaView
|
||||
* chooses a smart default based on the aspect ratios of the tile and video.
|
||||
*/
|
||||
videoFit?: "cover" | "contain";
|
||||
mirror: boolean;
|
||||
soundWaves?: boolean;
|
||||
userId: string;
|
||||
videoEnabled: boolean;
|
||||
unencryptedWarning: boolean;
|
||||
status?: { text: string; Icon: ComponentType<SVGAttributes<SVGElement>> };
|
||||
status?: ReactNode;
|
||||
showNameTags: boolean;
|
||||
nameTagLeadingIcon?: ReactNode;
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
avatarStyle?: "solid" | "translucent";
|
||||
background?: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
raisedHandTime?: Date;
|
||||
@@ -58,8 +67,15 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
rtcBackendIdentity?: string;
|
||||
// The focus url, mainly for debugging purposes
|
||||
/**
|
||||
* The focus url, mainly for debugging purposes.
|
||||
*/
|
||||
focusUrl?: string;
|
||||
/**
|
||||
* Called whenever the aspect ratio of the video content becomes known or
|
||||
* otherwise changes.
|
||||
*/
|
||||
setVideoAspectRatio?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export const MediaView: FC<Props> = ({
|
||||
@@ -71,6 +87,7 @@ export const MediaView: FC<Props> = ({
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
soundWaves,
|
||||
userId,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
@@ -78,6 +95,8 @@ export const MediaView: FC<Props> = ({
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
avatarStyle = "solid",
|
||||
background = "solid",
|
||||
focusable,
|
||||
primaryButton,
|
||||
status,
|
||||
@@ -89,6 +108,7 @@ export const MediaView: FC<Props> = ({
|
||||
videoStreamStats,
|
||||
rtcBackendIdentity,
|
||||
focusUrl,
|
||||
setVideoAspectRatio: setTheirVideoAspectRatio,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -96,7 +116,26 @@ export const MediaView: FC<Props> = ({
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
|
||||
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
const avatarSize = Math.round(
|
||||
Math.min(targetWidth, targetHeight) *
|
||||
(soundWaves === undefined ? 0.5 : 0.38),
|
||||
);
|
||||
|
||||
const [videoAspectRatio, setOurVideoAspectRatio] = useState<number>(NaN);
|
||||
const tileAspectRatio = targetWidth / targetHeight;
|
||||
|
||||
// Propagate video dimensions
|
||||
const setVideoAspectRatio = (ratio: number) => {
|
||||
setOurVideoAspectRatio(ratio);
|
||||
setTheirVideoAspectRatio?.(ratio);
|
||||
};
|
||||
const videoRef = (el: HTMLVideoElement | null) => {
|
||||
if (el !== null) setVideoAspectRatio(el.videoWidth / el.videoHeight);
|
||||
};
|
||||
const onResize = (ev: SyntheticEvent<HTMLVideoElement>) =>
|
||||
setVideoAspectRatio(
|
||||
ev.currentTarget.videoWidth / ev.currentTarget.videoHeight,
|
||||
);
|
||||
|
||||
const warnings = unencryptedWarning && (
|
||||
<Tooltip
|
||||
@@ -123,41 +162,55 @@ export const MediaView: FC<Props> = ({
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-fit={videoFit}
|
||||
data-video-enabled={video && videoEnabled}
|
||||
data-video-fit={
|
||||
videoFit ?? autoVideoFit(videoAspectRatio, tileAspectRatio)
|
||||
}
|
||||
data-background={background}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
{soundWaves !== undefined && (
|
||||
<div className={styles.waves} data-visible={soundWaves}>
|
||||
<div className={styles.wave} />
|
||||
<div className={styles.wave} />
|
||||
<div className={styles.wave} />
|
||||
<div className={styles.speakingBorder} />
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
id={userId}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={mxcAvatarUrl}
|
||||
className={classNames(styles.avatar, {
|
||||
// When the avatar is overlaid with a status, make it translucent
|
||||
// for readability
|
||||
[styles.translucent]: status,
|
||||
})}
|
||||
data-style={avatarStyle}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
/>
|
||||
{decoration && !(video && videoEnabled) && (
|
||||
// [lotus #6] Profile decoration overlay, shown only when the avatar
|
||||
// is visible (i.e. not when live video is showing). Pushed by the
|
||||
// host via io.lotus.decorations; undefined unless opted in.
|
||||
<img
|
||||
className={styles.lotusDecoration}
|
||||
src={decoration}
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{decoration &&
|
||||
!(video && videoEnabled) && (
|
||||
// [lotus #6] Profile decoration overlay, shown only when the avatar
|
||||
// is visible (i.e. not when live video is showing). Pushed by the
|
||||
// host via io.lotus.decorations; undefined unless opted in.
|
||||
<img
|
||||
className={styles.lotusDecoration}
|
||||
src={decoration}
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
// Set the placeholder to a small transparent image. (On Android web
|
||||
// views the default poster image is particularly ugly.)
|
||||
poster={videoPlaceholder}
|
||||
ref={videoRef}
|
||||
onResize={onResize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -193,14 +246,7 @@ export const MediaView: FC<Props> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{status && (
|
||||
<div className={styles.status}>
|
||||
<status.Icon width={16} height={16} aria-hidden />
|
||||
<Text as="span" size="sm" weight="medium">
|
||||
{status.text}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={styles.status}>{status}</div>}
|
||||
{/* TODO: Bring this back once encryption status is less broken */}
|
||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||
<div className={styles.status}>
|
||||
|
||||
@@ -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")}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ Please see LICENSE in the repository root for full details.
|
||||
inline-size: 100%;
|
||||
display: flex;
|
||||
border-radius: var(--cpd-space-6x);
|
||||
box-shadow: var(--draggable-shadow);
|
||||
contain: strict;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
@@ -22,7 +23,7 @@ Please see LICENSE in the repository root for full details.
|
||||
scroll-behavior: smooth; */
|
||||
}
|
||||
|
||||
.tile.maximised .contents {
|
||||
.tile[data-maximised="true"] .contents {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ Please see LICENSE in the repository root for full details.
|
||||
--media-view-fg-inset: 10px;
|
||||
}
|
||||
|
||||
.maximised .item {
|
||||
.tile[data-maximised="true"] .item {
|
||||
/* Ensure that foreground elements lie within the safe area */
|
||||
--media-view-fg-inset: calc(var(--call-view-safe-area-inset-top, 0px) + 10px)
|
||||
calc(env(safe-area-inset-right) + 10px)
|
||||
@@ -190,7 +191,7 @@ Please see LICENSE in the repository root for full details.
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maximised .indicators {
|
||||
.tile[data-maximised="true"] .indicators {
|
||||
inset-block-end: calc(-1 * var(--cpd-space-4x) - 2px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ import {
|
||||
createRingingMedia,
|
||||
type RingingMediaViewModel,
|
||||
} from "../state/media/RingingMediaViewModel";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
public disconnect(): void {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("SpotlightTile is accessible", async () => {
|
||||
@@ -59,13 +59,20 @@ test("SpotlightTile is accessible", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm1, vm2]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm1, vm2]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable={true}
|
||||
/>,
|
||||
);
|
||||
@@ -101,13 +108,20 @@ test("Screen share volume UI is shown when screen share has audio", async () =>
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
@@ -131,13 +145,20 @@ test("Screen share volume UI is hidden when screen share has no audio", async ()
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
@@ -156,10 +177,8 @@ test("SpotlightTile displays ringing media", async () => {
|
||||
>("ringing");
|
||||
const vm = createRingingMedia({
|
||||
pickupState$,
|
||||
muteStates: {
|
||||
video: { enabled$: constant(false) },
|
||||
} as unknown as MuteStates,
|
||||
id: "test",
|
||||
intent: "audio",
|
||||
userId: "@alice:example.org",
|
||||
displayName$: constant("Alice"),
|
||||
mxcAvatarUrl$: constant(undefined),
|
||||
@@ -168,13 +187,20 @@ test("SpotlightTile displays ringing media", async () => {
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
vm={
|
||||
new SpotlightTileViewModel(
|
||||
constant([vm]),
|
||||
constant(false),
|
||||
constant("solid"),
|
||||
)
|
||||
}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
showNameTags
|
||||
showRingingStatus
|
||||
focusable={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
+42
-30
@@ -24,9 +24,6 @@ import {
|
||||
VolumeOnIcon,
|
||||
VolumeOffSolidIcon,
|
||||
VolumeOnSolidIcon,
|
||||
VideoCallSolidIcon,
|
||||
VoiceCallSolidIcon,
|
||||
EndCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type Observable, map } from "rxjs";
|
||||
@@ -34,7 +31,7 @@ import { useObservableRef } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
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 FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||
@@ -56,6 +53,7 @@ import { type MediaViewModel } from "../state/media/MediaViewModel";
|
||||
import { Slider } from "../Slider";
|
||||
import { platform } from "../Platform";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -67,8 +65,10 @@ interface SpotlightItemBaseProps {
|
||||
displayName: string;
|
||||
mxcAvatarUrl: string | undefined;
|
||||
showNameTags: boolean;
|
||||
background: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
"aria-hidden"?: boolean;
|
||||
setVideoAspectRatio?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
@@ -78,8 +78,8 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
||||
videoFit: "contain" | "cover";
|
||||
videoEnabled: boolean;
|
||||
soundWaves: boolean | undefined;
|
||||
}
|
||||
|
||||
interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps {
|
||||
@@ -120,20 +120,14 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
targetHeight,
|
||||
...props
|
||||
}) => {
|
||||
const videoFit = useBehavior(vm.videoFit$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
|
||||
// Whenever target bounds change, inform the viewModel
|
||||
useEffect(() => {
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
||||
}
|
||||
}, [targetWidth, targetHeight, vm]);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
videoFit,
|
||||
setVideoAspectRatio: vm.setVideoAspectRatio,
|
||||
videoEnabled,
|
||||
soundWaves: props.background === "transparent" ? speaking : undefined,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
...props,
|
||||
@@ -204,30 +198,27 @@ const SpotlightMemberMediaItem: FC<SpotlightMemberMediaItemProps> = ({
|
||||
|
||||
interface SpotlightRingingMediaItemProps extends SpotlightItemBaseProps {
|
||||
vm: RingingMediaViewModel;
|
||||
showStatus: boolean;
|
||||
}
|
||||
|
||||
const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
|
||||
vm,
|
||||
showStatus,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const pickupState = useBehavior(vm.pickupState$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
|
||||
return (
|
||||
<MediaView
|
||||
video={undefined}
|
||||
unencryptedWarning={false}
|
||||
status={
|
||||
pickupState === "ringing"
|
||||
? {
|
||||
text: t("video_tile.calling"),
|
||||
Icon: videoEnabled ? VideoCallSolidIcon : VoiceCallSolidIcon,
|
||||
}
|
||||
: { text: t("video_tile.call_ended"), Icon: EndCallIcon }
|
||||
showStatus && (
|
||||
<Text as="span" size="md" weight="medium">
|
||||
<RingingStatus vm={vm} />
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
avatarStyle="translucent"
|
||||
videoEnabled={false}
|
||||
videoFit="cover"
|
||||
mirror={false}
|
||||
{...props}
|
||||
/>
|
||||
@@ -246,12 +237,15 @@ interface SpotlightItemProps {
|
||||
*/
|
||||
targetHeight: number;
|
||||
showNameTags: boolean;
|
||||
showRingingStatus: boolean;
|
||||
background: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
intersectionObserver$: Observable<IntersectionObserver>;
|
||||
/**
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
snap: boolean;
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
@@ -261,9 +255,12 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showNameTags,
|
||||
showRingingStatus,
|
||||
background,
|
||||
focusable,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
className,
|
||||
"aria-hidden": ariaHidden,
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -290,19 +287,24 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
className: classNames(className, styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
userId: vm.userId,
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
showNameTags,
|
||||
background,
|
||||
focusable,
|
||||
"aria-hidden": ariaHidden,
|
||||
};
|
||||
|
||||
return vm.type === "ringing" ? (
|
||||
<SpotlightRingingMediaItem vm={vm} {...baseProps} />
|
||||
<SpotlightRingingMediaItem
|
||||
vm={vm}
|
||||
showStatus={showRingingStatus}
|
||||
{...baseProps}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightMemberMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
@@ -386,8 +388,13 @@ interface Props {
|
||||
targetHeight: number;
|
||||
showIndicators: boolean;
|
||||
showNameTags: boolean;
|
||||
showRingingStatus: boolean;
|
||||
focusable: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* CSS class of the individual spotlight items.
|
||||
*/
|
||||
itemClassName?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
}
|
||||
|
||||
@@ -400,14 +407,17 @@ export const SpotlightTile: FC<Props> = ({
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
showNameTags,
|
||||
showRingingStatus,
|
||||
focusable = true,
|
||||
className,
|
||||
itemClassName,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useBehavior(vm.maximised$);
|
||||
const background = useBehavior(vm.background$);
|
||||
const media = useBehavior(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
const latestMedia = useLatest(media);
|
||||
@@ -488,9 +498,8 @@ export const SpotlightTile: FC<Props> = ({
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
className={classNames(className, styles.tile)}
|
||||
data-maximised={maximised}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
@@ -510,7 +519,9 @@ export const SpotlightTile: FC<Props> = ({
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
showRingingStatus={showRingingStatus}
|
||||
showNameTags={showNameTags}
|
||||
background={background}
|
||||
focusable={focusable}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// This is how we get the container to scroll to the right media
|
||||
@@ -518,6 +529,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
className={itemClassName}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,22 +6,22 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import { TileAvatar } from "./TileAvatar";
|
||||
|
||||
describe("TileAvatar", () => {
|
||||
it("should show loading spinner when loading", () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={true} />,
|
||||
);
|
||||
expect(container.querySelector(".loading")).toBeInTheDocument();
|
||||
screen.getByLabelText("Loading…");
|
||||
});
|
||||
|
||||
it("should not show loading spinner when not loading", () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={false} />,
|
||||
);
|
||||
expect(container.querySelector(".loading")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Loading…")).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type FC } from "react";
|
||||
import { InlineSpinner } from "@vector-im/compound-web";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./TileAvatar.module.css";
|
||||
import { Avatar, type Props as AvatarProps } from "../Avatar";
|
||||
@@ -17,11 +18,12 @@ interface Props extends AvatarProps {
|
||||
}
|
||||
|
||||
export const TileAvatar: FC<Props> = ({ size, loading, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{loading && (
|
||||
<div className={styles.loading}>
|
||||
<InlineSpinner size={size / 3} />
|
||||
<InlineSpinner size={size / 3} aria-label={t("common.loading")} />
|
||||
</div>
|
||||
)}
|
||||
<Avatar size={size} {...props} />
|
||||
|
||||
Reference in New Issue
Block a user