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:
Lotus CI
2026-09-12 11:35:57 -04:00
co-authored by Claude Fable 5.1
243 changed files with 13311 additions and 10280 deletions
+6
View File
@@ -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
+47 -6
View File
@@ -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
View File
@@ -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}
/>
);
+98 -10
View File
@@ -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 {
+2 -33
View File
@@ -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
View File
@@ -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}>
+41
View 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")}
</>
);
};
+4 -3
View File
@@ -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;
}
+34 -8
View File
@@ -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
View File
@@ -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}
/>
))}
+5 -5
View File
@@ -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);
});
});
+3 -1
View File
@@ -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} />