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
+106
View File
@@ -16,6 +16,7 @@ import {
} from "react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { ConnectionError } from "livekit-client";
import { MatrixError } from "matrix-js-sdk";
import {
@@ -28,7 +29,10 @@ import {
type ElementCallError,
FailToGetOpenIdToken,
InsufficientCapacityError,
LivekitConnectionError,
MatrixRTCTransportMissingError,
PeerConnectionTimeoutError,
StickyEventsRequiredError,
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
@@ -56,6 +60,12 @@ test.each([
expectedDescription:
"The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
},
{
error: new StickyEventsRequiredError(),
expectedTitle: "Homeserver does not support Matrix 2.0 calls",
expectedDescription:
"This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
},
])(
"should report correct error for $expectedTitle",
async ({ error, expectedTitle, expectedDescription }) => {
@@ -320,3 +330,99 @@ test("should not show technical details when error has no matrix error cause", a
// Technical details should not be present (ConnectionLostError has no cause)
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
});
describe("LiveKit ConnectionError variants", () => {
test.each([
{
name: "notAllowed",
error: ConnectionError.notAllowed("Permission denied by server", 403),
expectedReason: "NotAllowed",
},
{
name: "timeout",
error: ConnectionError.timeout("Connection timed out"),
expectedReason: "Timeout",
},
{
name: "serverUnreachable",
error: ConnectionError.serverUnreachable("Server is unreachable", 503),
expectedReason: "ServerUnreachable",
},
{
name: "serviceNotFound",
error: ConnectionError.serviceNotFound(
"RTC service not found",
"v0-rtc" as const,
),
expectedReason: "ServiceNotFound",
},
{
name: "internal",
error: ConnectionError.internal("Internal server error", {
status: 500,
statusText: "Internal Server Error",
}),
expectedReason: "InternalError",
},
])(
"should display LiveKit $name error correctly",
async ({ error, expectedReason }) => {
const TestComponent = (): ReactNode => {
throw new LivekitConnectionError(error);
};
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
// Check title
await screen.findByText("Failed to connect to Livekit server");
// Check that reason is displayed in the description
expect(screen.getByText(/Reason:/i)).toBeInTheDocument();
expect(screen.getByText(expectedReason)).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
},
);
test("should link to troubleshoot guide when timeout error", async () => {
const error = new PeerConnectionTimeoutError();
const TestComponent = (): ReactNode => {
throw error;
};
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection timeout");
// Verify the link is present and has correct href
const link = screen.getByText("troubleshooting guide");
expect(link).toHaveAttribute(
"href",
"https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/#sfu-connectivity-troubleshooting",
);
// Snapshot the complete rendered error
expect(asFragment()).toMatchSnapshot();
});
});
+20 -1
View File
@@ -92,7 +92,26 @@ const ErrorPage: FC<ErrorPageProps> = ({
widget={widget}
>
<p>
{error.localisedMessage ?? (
{error.localisedMessageKey ? (
<Trans
// @ts-expect-error - Dynamic i18nKey from error object
i18nKey={error.localisedMessageKey}
values={error.localisedMessageValues}
components={[
<a
href={String(error.localisedMessageValues?.linkUrl || "#")}
target="_blank"
rel="noopener noreferrer"
>
{/* Content injected by Trans component */}
</a>,
<b />,
<code />,
]}
/>
) : error.localisedMessage ? (
error.localisedMessage
) : (
<Trans
i18nKey="error.unexpected_ec_error"
components={[<b />, <code />]}
+76 -48
View File
@@ -19,7 +19,12 @@ import {
vitest,
} from "vitest";
import { render, waitFor, screen, act } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import {
type MatrixClient,
JoinRule,
type RoomState,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
@@ -46,6 +51,7 @@ import {
MockRTCSession,
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCTransportMissingError } from "../utils/errors";
@@ -130,6 +136,9 @@ beforeEach(() => {
function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
options: {
withErrorBoundary?: boolean;
} = {},
): {
rtcSession: MatrixRTCSession;
getByText: ReturnType<typeof render>["getByText"];
@@ -166,24 +175,36 @@ function createGroupCallView(
video: { enabled: false },
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
} as unknown as MuteStates;
const groupCallView = (
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}
// TODO-MULTI-SFU: Make joined and setJoined work
joined={true}
setJoined={function (value: boolean): void {}}
/>
);
const { getByText } = render(
<BrowserRouter>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}
// TODO-MULTI-SFU: Make joined and setJoined work
joined={true}
setJoined={function (value: boolean): void {}}
/>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary
recoveryActionHandler={vi.fn()}
widget={null}
>
{groupCallView}
</GroupCallErrorBoundary>
) : (
groupCallView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
@@ -251,7 +272,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test("Should close widget when all other left and have time to play a sound", async () => {
test("Should close widget when all other left and play a sound", async () => {
const user = userEvent.setup();
let widgetClosedCalled = false;
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
@@ -289,8 +310,6 @@ test("Should close widget when all other left and have time to play a sound", as
expect(widgetClosedCalled).toBeFalsy();
resolvePlaySound.resolve();
// Expect the leave sound to be played but silent (volumeOverwrite = 0)
// The allOthersLeft effect should already play a leave sound for the last user in the call.
expect(playSound).toHaveBeenCalledWith("left", 0);
await widgetClosedPromise;
await flushPromises();
@@ -298,37 +317,6 @@ test("Should close widget when all other left and have time to play a sound", as
expect(widgetStopMock).toHaveBeenCalledOnce();
}, 80000);
test("Should close widget when all other left", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
await widgetClosedCalled.promise;
await flushPromises();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup();
@@ -394,6 +382,46 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
screen.getByText("Call is not supported");
});
test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEventsRequiredError screen", async () => {
// Mirror the shape the SDK emits: the MembershipManager scheduler wraps
// the original UnsupportedStickyEventsEndpointError in a generic Error
// but preserves the original on `.cause`.
const stickyError = new UnsupportedStickyEventsEndpointError(
"Server does not support the sticky events",
"sendStickyEvent",
);
const wrappedError = new Error(
"The MembershipManager shut down because of the end condition: " +
String(stickyError),
{ cause: stickyError },
);
const { rtcSession } = createGroupCallView(null, true, {
withErrorBoundary: true,
});
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, wrappedError),
);
await screen.findByText("Homeserver does not support Matrix 2.0 calls");
});
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
const { rtcSession } = createGroupCallView(null, true, {
withErrorBoundary: true,
});
await act(() =>
rtcSession.emit(
MatrixRTCSessionEvent.MembershipManagerError,
new Error("something else broke"),
),
);
await screen.findByText("Connection lost");
});
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
+28 -7
View File
@@ -13,7 +13,12 @@ import {
useMemo,
useState,
} from "react";
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
import {
type MatrixClient,
JoinRule,
type Room,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
Room as LivekitRoom,
isE2EESupported as isE2EESupportedBrowser,
@@ -67,6 +72,7 @@ import {
ConnectionLostError,
E2EENotSupportedError,
ElementCallError,
StickyEventsRequiredError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
@@ -162,8 +168,22 @@ export const GroupCallView: FC<Props> = ({
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => setExternalError(new ConnectionLostError()),
(error) => {
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
// not advertise MSC4354 (sticky events), the SDK throws an
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
// scheduler wraps it and exposes the original via `.cause`.
if (
error instanceof Error &&
error.cause instanceof UnsupportedStickyEventsEndpointError
) {
setExternalError(new StickyEventsRequiredError());
} else {
setExternalError(new ConnectionLostError());
}
},
);
useEffect(() => {
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
@@ -533,11 +553,12 @@ export const GroupCallView: FC<Props> = ({
});
}
}}
onError={
(/**error*/) => {
if (rtcSession.isJoined()) onLeft("error");
}
}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
// We need it here explicitly in case rtcSession.isJoined is false.
void widget?.api.setAlwaysOnScreen(false);
}}
>
{body}
</GroupCallErrorBoundary>
+25 -9
View File
@@ -14,6 +14,23 @@ Please see LICENSE in the repository root for full details.
overflow-y: auto;
}
/* Normally the footer uses a transparent background to allow our expressive
page gradients to shine through. However, we sometimes need to visually separate
it from the content underneath. If the call layout is overflowing, or if the
spotlight tile is maximised and displaying video, apply a gradient background. */
.overflowing > .footer,
.fixedGrid:has(
> .tile[data-maximised="true"]
.spotlightItem[data-background="transparent"][data-video-enabled="true"][aria-hidden="false"]
)
~ .footer {
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.header {
position: sticky;
flex-shrink: 0;
@@ -82,19 +99,18 @@ Please see LICENSE in the repository root for full details.
/* Disable pointer events so the overlay doesn't block interaction with
elements behind it */
pointer-events: none;
}
.fixedGrid > :not(:first-child),
.scrollingGrid > :not(:first-child) {
pointer-events: initial;
> :not(:first-child) {
pointer-events: initial;
}
.tile {
position: absolute;
inset-block-start: 0;
}
}
.tile {
position: absolute;
inset-block-start: 0;
}
.tile.maximised {
position: relative;
flex-grow: 1;
}
+59 -67
View File
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
afterEach,
beforeEach,
describe,
expect,
@@ -15,12 +14,7 @@ import {
type MockedFunction,
vi,
} from "vitest";
import {
render,
type RenderResult,
getByRole,
screen,
} from "@testing-library/react";
import { render, type RenderResult } from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
@@ -28,7 +22,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import userEvent from "@testing-library/user-event";
import { InCallView } from "./InCallView";
import { ActiveCall, InCallView } from "./InCallView";
import {
mockLivekitRoom,
mockLocalParticipant,
@@ -39,7 +33,10 @@ import {
type MockRTCSession,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import {
getBasicCallViewModelEnvironment,
getBasicRTCSession,
} from "../utils/test-viewmodel";
import {
type CallViewModel,
type CallViewModelOptions,
@@ -50,8 +47,9 @@ import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
import { type MatrixInfo } from "./VideoPreview";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { initializeWidget } from "../widget";
initializeWidget();
@@ -85,6 +83,17 @@ const remoteParticipant = mockRemoteParticipant({
identity: "@alice:example.org:AAAAAA",
});
const matrixInfo = {
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: { kind: E2eeType.NONE },
} satisfies MatrixInfo;
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
beforeEach(() => {
@@ -129,12 +138,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
remoteParticipants$: of([remoteParticipant]),
},
);
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
args.callViewModelOptions,
);
const { vm, footerVm, developerSettingsVm, rtcSession } =
getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
args.callViewModelOptions,
);
rtcSession.joined = true;
const room = rtcSession.room;
@@ -147,18 +157,8 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
muteStates={muteState}
vm={vm}
footerVm={footerVm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
developerSettingsVm={developerSettingsVm}
matrixInfo={matrixInfo}
matrixRoom={room}
onShareClick={null}
/>
@@ -195,45 +195,6 @@ describe("InCallView", () => {
});
});
describe("settings button with AppBar header", () => {
beforeEach(() => {
// getUrlParams() reads window.location directly rather than from the
// React Router context, so MemoryRouter alone is not enough to make
// it see "header=app_bar". Push the real URL so both paths agree.
window.history.pushState({}, "", "?header=app_bar");
});
afterEach(() => {
window.history.pushState({}, "", "/");
});
it("mobile portrait, is visible in the header", () => {
createInCallView({
withAppBar: true,
callViewModelOptions: {
// Narrow like a mobile phone in portrait orientation
windowSize$: constant({ width: 400, height: 700 }),
},
});
getByRole(screen.getByRole("banner"), "button", {
name: "Settings",
});
});
it("mobile landscape, is not visible anywhere", () => {
const { queryByRole } = createInCallView({
withAppBar: true,
callViewModelOptions: {
// Flat like a mobile phone in landscape orientation
windowSize$: constant({ width: 700, height: 400 }),
},
});
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
});
});
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();
@@ -272,3 +233,34 @@ describe("InCallView", () => {
});
});
});
describe("ActiveCall", () => {
it("creates the view models and renders the call", async () => {
const mediaDevices = mockMediaDevices({});
const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]);
const { findByTestId } = render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<TooltipProvider>
<RoomContext value={mockLivekitRoom({ localParticipant })}>
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
matrixRoom={matrixRoom}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
e2eeSystem={{ kind: E2eeType.NONE }}
onLeft={(): void => {}}
/>
</RoomContext>
</TooltipProvider>
</ProcessorProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
// Rendering at all proves ActiveCall created all of its view models
expect(await findByTestId("incall_leave")).toBeVisible();
});
});
+59 -51
View File
@@ -51,14 +51,13 @@ import {
createCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import { type CallLayoutOutputs } from "../grid/CallLayout";
import { makeOneOnOneLandscapeLayout } from "../grid/OneOnOneLandscapeLayout";
import { makeOneOnOnePortraitLayout } from "../grid/OneOnOnePortraitLayout";
import { makeOneOnOneDesktopLayout } from "../grid/OneOnOneDesktopLayout";
import { makeOneOnOneMobileLayout } from "../grid/OneOnOneMobileLayout";
import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
@@ -76,22 +75,26 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import {
useAppBarHidden,
useAppBarSecondaryButton,
useAppBarSubtitle,
} from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { constant } from "../state/Behavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
import { RingingStatus } from "../tile/RingingStatus.tsx";
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
declare module "react" {
interface CSSProperties {
@@ -100,11 +103,9 @@ declare module "react" {
}
}
const logger = rootLogger.getChild("[InCallView]");
export interface ActiveCallProps extends Omit<
InCallViewProps,
"vm" | "livekitRoom" | "connState" | "footerVm"
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
> {
e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
@@ -118,11 +119,14 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
const [developerSettingsVm, setDeveloperSettingsVm] =
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => {
logger.info("START CALL VIEW SCOPE");
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
@@ -177,6 +181,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
);
setFooterVm(footerVm);
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
return (): void => {
scope.end();
@@ -196,10 +201,16 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
if (vm === null) return null;
if (footerVm === null) return null;
if (developerSettingsVm === null) return null;
return (
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView {...props} vm={vm} footerVm={footerVm} />
<InCallView
{...props}
vm={vm}
footerVm={footerVm}
developerSettingsVm={developerSettingsVm}
/>
</ReactionsSenderProvider>
);
};
@@ -208,6 +219,7 @@ export interface InCallViewProps {
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
@@ -219,11 +231,13 @@ export const InCallView: FC<InCallViewProps> = ({
client,
vm,
footerVm,
developerSettingsVm,
matrixInfo,
matrixRoom,
muteStates,
onShareClick,
}) => {
const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
@@ -247,20 +261,6 @@ export const InCallView: FC<InCallViewProps> = ({
const { showControls, header: headerStyle } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$);
// Preload a waiting and decline sounds
const pickupPhaseSoundCache = useInitial(async () => {
return prefetchSounds({
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const pickupPhaseAudio = useAudioContext({
sounds: pickupPhaseSoundCache,
latencyHint: "interactive",
muted: muteAllAudio,
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -273,14 +273,16 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const ringing = useBehavior(vm.ringing$);
const ringingVm = useBehavior(vm.ringingVm$);
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const overflowing = useBehavior(vm.overflowing$);
const showNameTags = useBehavior(vm.showNameTags$);
const showHeader = useBehavior(vm.showHeader$);
const showModals = useBehavior(vm.showModals$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$);
@@ -316,22 +318,6 @@ export const InCallView: FC<InCallViewProps> = ({
throw fatalCallError;
}
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
const audio = latestPickupPhaseAudio.current;
if (ringing && audio) {
const endSound = audio.playSoundLooping(
"waiting",
audio.soundDuration["waiting"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [ringing, latestPickupPhaseAudio]);
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
// so this still only fires for taps.
@@ -393,6 +379,11 @@ export const InCallView: FC<InCallViewProps> = ({
);
useAppBarHidden(!showHeader);
useAppBarSubtitle(
ringingVm && vm.ringingStatusLocation === "app_bar" && (
<RingingStatus vm={ringingVm} />
),
);
let header: ReactNode = null;
switch (headerStyle) {
@@ -487,6 +478,12 @@ export const InCallView: FC<InCallViewProps> = ({
);
const showSpeakingIndicators = useBehavior(vm.showSpeakingIndicators$);
const showNameTags = useBehavior(vm.showNameTags$);
const showRingingStatus = vm.ringingStatusLocation === "tile";
const showOutline = useBehavior(
model instanceof GridTileViewModel
? model.showOutline$
: constant(false),
);
return model instanceof GridTileViewModel ? (
<GridTile
@@ -499,6 +496,8 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
showSpeakingIndicators={showSpeakingIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
showOutline={showOutline}
focusable={!contentObscured}
/>
) : (
@@ -511,8 +510,10 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={targetHeight}
showIndicators={showSpotlightIndicators}
showNameTags={showNameTags}
showRingingStatus={showRingingStatus}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
itemClassName={styles.spotlightItem}
style={style}
/>
);
@@ -527,8 +528,8 @@ export const InCallView: FC<InCallViewProps> = ({
"spotlight-landscape": makeSpotlightLandscapeLayout(inputs),
"spotlight-portrait": makeSpotlightPortraitLayout(inputs),
"spotlight-expanded": makeSpotlightExpandedLayout(inputs),
"one-on-one-landscape": makeOneOnOneLandscapeLayout(inputs),
"one-on-one-portrait": makeOneOnOnePortraitLayout(inputs),
"one-on-one-desktop": makeOneOnOneDesktopLayout(inputs),
"one-on-one-mobile": makeOneOnOneMobileLayout(inputs),
};
}, [gridBoundsObservable$]);
@@ -537,7 +538,9 @@ export const InCallView: FC<InCallViewProps> = ({
if (layout.type === "pip") {
return (
<SpotlightTile
className={classNames(styles.tile, styles.maximised)}
className={styles.tile}
itemClassName={styles.spotlightItem}
data-maximised
vm={layout.spotlight}
expanded
onToggleExpanded={null}
@@ -545,6 +548,7 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight={gridBounds.height}
showIndicators={false}
showNameTags={showNameTags}
showRingingStatus={vm.ringingStatusLocation === "tile"}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
@@ -628,7 +632,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = footerVm !== null && (
<CallFooter ref={footerRef} vm={footerVm} />
<CallFooter className={styles.footer} ref={footerRef} vm={footerVm} />
);
const allConnections = useBehavior(vm.allConnections$);
@@ -637,7 +641,9 @@ export const InCallView: FC<InCallViewProps> = ({
// and the footer is also viewable by moving focus into it, so this is fine.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={styles.inRoom}
className={classNames(styles.inRoom, {
[styles.overflowing]: overflowing,
})}
ref={containerRef}
onPointerUp={onViewPointerUp}
onPointerMove={onPointerMove}
@@ -656,11 +662,12 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
{reconnectingToast}
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
{layout.type !== "pip" && (
{showModals && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
@@ -670,6 +677,7 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections
.getConnections()
.map((connectionItem) => ({
+44
View File
@@ -0,0 +1,44 @@
/*
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 { useId, type FC } from "react";
import {
SpotlightViewIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { useBehavior } from "../useBehavior";
import { useTranslation } from "react-i18next";
import { Switch } from "@vector-im/compound-web";
interface Props {
vm: LayoutSwitchViewModel;
className?: string;
}
export const LayoutSwitch: FC<Props> = ({ vm, className }) => {
const { t } = useTranslation();
const layout = useBehavior(vm.layout$);
const name = useId();
return (
<Switch<"spotlight", "grid">
name={name}
aria-label={t("layout_switch_label")}
leftLabel={t("layout_spotlight_label")}
leftValue="spotlight"
leftIcon={SpotlightViewIcon}
rightLabel={t("layout_grid_label")}
rightValue="grid"
rightIcon={GridIcon}
className={className}
value={layout}
onChange={vm.setLayout}
/>
);
};
+81 -15
View File
@@ -11,6 +11,10 @@ import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import {
ArrowLeftIcon,
ChevronLeftIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
@@ -20,6 +24,7 @@ import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import lobbyStyles from "./LobbyView.module.css";
import headerStyles from "../Header.module.css";
import { AppBar } from "../AppBar";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
@@ -47,6 +52,13 @@ const mockClient = {
getDeviceId: () => "DEVICE",
} as Partial<MatrixClient> as MatrixClient;
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
const matrixInfo = {
userId: "@user:example.org",
displayName: "Test User",
@@ -60,25 +72,32 @@ const matrixInfo = {
function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {},
withAppBar = false,
platform = "android",
): ReturnType<typeof render> {
platformMock.mockReturnValue(platform);
const mediaDevices = mockMediaDevices({});
const muteStates = mockMuteStates();
const hideHeader = withAppBar ? true : false;
const lobbyView = (
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={hideHeader}
participantCount={3}
onShareClick={null}
{...props}
/>
);
return render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<TooltipProvider>
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={false}
participantCount={3}
onShareClick={null}
{...props}
/>
{withAppBar && <AppBar>{lobbyView}</AppBar>}
{!withAppBar && lobbyView}
</TooltipProvider>
</MediaDevicesContext>
</BrowserRouter>,
@@ -97,9 +116,10 @@ describe("LobbyView", () => {
it("renders without header", () => {
const { container } = renderLobbyView({ hideHeader: true });
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeFalsy();
const els = container.getElementsByClassName(headerStyles.header);
for (const el of els) {
expect(el).not.toBeVisible();
}
});
it("renders with waiting for invite state", () => {
@@ -108,4 +128,50 @@ describe("LobbyView", () => {
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
it("renders with AppBar android", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
true,
"android",
);
getByRole("banner");
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon
const { container: iconContainer } = render(<ArrowLeftIcon />);
const expectedSvgPath = iconContainer
.querySelector("path")!
.getAttribute("d");
const primaryButtonSvgPath = container
.querySelector("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations();
});
it("renders with AppBar ios", async () => {
const { container, getByRole } = renderLobbyView(
{
waitingForInvite: true,
},
true,
"ios",
);
getByRole("banner");
// Check that the primary button uses ArrowLeftIcon (the back/return icon),
// not the default CollapseIcon
const { container: iconContainer } = render(<ChevronLeftIcon />);
const expectedSvgPath = iconContainer
.querySelector("path")!
.getAttribute("d");
const primaryButtonSvgPath = container
.querySelector("path")
?.getAttribute("d");
expect(primaryButtonSvgPath).toBe(expectedSvgPath);
expect(container).toMatchSnapshot();
expect(await axe(container)).toHaveNoViolations();
});
});
+3 -1
View File
@@ -51,6 +51,7 @@ import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
interface Props {
client: MatrixClient;
@@ -85,8 +86,9 @@ export const LobbyView: FC<Props> = ({
}, []);
const { t } = useTranslation();
usePageTitle(matrixInfo.roomName);
usePageTitle(matrixInfo.roomName);
useAppBarPrimaryButtonIconKind("back");
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
+59
View File
@@ -0,0 +1,59 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type MockedFunction, test, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { useAudioContext } from "../useAudioContext";
import { createRingingMedia } from "../state/media/RingingMediaViewModel";
import { alice, aliceId } from "../utils/test-fixtures";
import { constant } from "../state/Behavior";
import { RingingAudioRenderer } from "./RingingAudioRenderer";
import { prefetchSounds } from "../soundUtils";
vi.mock("../useAudioContext");
vi.mock("../soundUtils");
test("ringtone plays on loop while ringing", () => {
(prefetchSounds as MockedFunction<typeof prefetchSounds>).mockResolvedValue({
sound: new ArrayBuffer(0),
});
const endSoundLooping = vi.fn().mockReturnValue(Promise.resolve());
const playSoundLooping = vi.fn().mockReturnValue(endSoundLooping);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound: vi.fn(),
playSoundLooping,
soundDuration: {},
});
const pickupState$ = new BehaviorSubject<"ringing" | "timeout" | "decline">(
"ringing",
);
const vm = createRingingMedia({
id: aliceId,
userId: alice.userId,
displayName$: constant("Alice"),
mxcAvatarUrl$: constant(undefined),
intent: "audio",
pickupState$,
});
// Begin ringing
render(<RingingAudioRenderer vm={vm} muted={false} />);
expect(playSoundLooping).toHaveBeenCalledExactlyOnceWith(
"ringtone",
expect.any(Number),
);
expect(endSoundLooping).not.toHaveBeenCalled();
vi.clearAllMocks();
// End ringing
act(() => pickupState$.next("decline"));
expect(playSoundLooping).not.toHaveBeenCalled();
expect(endSoundLooping).toHaveBeenCalledExactlyOnceWith();
});
+72
View File
@@ -0,0 +1,72 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, type FC } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { useBehavior } from "../useBehavior";
import { useInitial } from "../useInitial";
import { prefetchSounds } from "../soundUtils";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
import { type UseAudioContext, useAudioContext } from "../useAudioContext";
import { useLatest } from "../useLatest";
interface RingingAudioRendererProps {
vm: RingingMediaViewModel | null;
muted: boolean;
}
export const RingingAudioRenderer: FC<RingingAudioRendererProps> = ({
vm,
muted,
}) => {
// Preload a waiting and decline sounds
const sounds = useInitial(async () => {
return prefetchSounds({
ringtone: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const audio = useAudioContext({
sounds,
latencyHint: "interactive",
muted,
});
return vm && <ActiveRingingAudioRenderer vm={vm} audio={audio} />;
};
interface ActiveRingingAudioRendererProps {
vm: RingingMediaViewModel;
audio: UseAudioContext<"ringtone"> | null;
}
const ActiveRingingAudioRenderer: FC<ActiveRingingAudioRendererProps> = ({
vm,
audio,
}) => {
const audio_ = useLatest(audio);
const pickupState = useBehavior(vm.pickupState$);
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
if (pickupState === "ringing" && audio_.current) {
const endSound = audio_.current.playSoundLooping(
"ringtone",
audio_.current.soundDuration["ringtone"] ?? 1,
);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}
}, [pickupState, audio_]);
return null;
};
+5 -6
View File
@@ -10,6 +10,8 @@ Please see LICENSE in the repository root for full details.
margin-right: var(--content-inset-right);
min-block-size: 0;
block-size: 50vh;
aspect-ratio: 16 / 9;
max-width: 100%;
border-radius: var(--cpd-space-4x);
position: relative;
overflow: hidden;
@@ -20,6 +22,9 @@ Please see LICENSE in the repository root for full details.
height: 100%;
object-fit: cover;
background-color: var(--video-tile-background);
/* 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;
}
video.mirror {
@@ -66,12 +71,6 @@ video.mirror {
);
}
@media (min-aspect-ratio: 1 / 1) {
.preview > video {
aspect-ratio: 16 / 9;
}
}
@media (max-width: 550px) {
.preview {
margin-inline: 0;
+4
View File
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
import { TileAvatar } from "../tile/TileAvatar";
import styles from "./VideoPreview.module.css";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import videoPlaceholder from "../graphics/video-placeholder.gif";
export type MatrixInfo = {
userId: string;
@@ -74,6 +75,9 @@ export const VideoPreview: FC<Props> = ({
// There's no reason for this to be focusable
tabIndex={-1}
disablePictureInPicture
// Set the placeholder to a small transparent image. (On Android web
// views the default poster image is particularly ugly.)
poster={videoPlaceholder}
/>
{(!videoEnabled || cameraIsStarting) && (
<>
File diff suppressed because it is too large Load Diff
+35 -33
View File
@@ -3,28 +3,28 @@
exports[`InCallView > rendering > renders 1`] = `
<div>
<div
class="inRoom"
class="_inRoom_4e7ff8"
>
<header
class="header header"
class="_header_e4b327 _header_4e7ff8"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<div
class="roomHeaderInfo"
class="_roomHeaderInfo_e4b327"
data-size="lg"
>
<span
aria-label=""
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
class="_avatar_va14e_8 _roomAvatar_e4b327 _avatar-imageless_va14e_55"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 56px;"
/>
<div
class="nameLine"
class="_nameLine_e4b327"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
@@ -35,7 +35,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<svg
aria-labelledby="_r_0_"
class="lock"
class="_lock_edc97d"
data-encrypted="false"
fill="currentColor"
height="16"
@@ -50,7 +50,7 @@ exports[`InCallView > rendering > renders 1`] = `
</span>
</div>
<div
class="participantsLine"
class="_participantsLine_e4b327"
>
<svg
aria-label="Participants"
@@ -80,21 +80,21 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="scrollingGrid grid"
class="_scrollingGrid_4e7ff8 _grid_b0d1cd"
>
<div
class="layer"
class="_layer_185815"
>
<div
class="container slot"
class="_container_185815 _slot_b0d1cd"
data-id="1"
>
<div
class="slot local slot"
class="_slot_185815 _local_185815 _slot_b0d1cd"
data-block-alignment="start"
data-id="0"
data-inline-alignment="end"
@@ -103,22 +103,22 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
<div
class="fixedGrid grid"
class="_fixedGrid_4e7ff8 _grid_b0d1cd"
style="inset-block-start: NaNpx;"
>
<div />
</div>
<div
class="bg animate"
class="_bg_2f5303 _animate_2f5303"
data-state="closed"
/>
<div
aria-hidden="true"
class="overlay"
class="_overlay_eb6724"
data-show="false"
>
<div
class="_big-icon_1ssbv_8 icon"
class="_big-icon_1ssbv_8 _icon_eb6724"
data-kind="primary"
data-size="lg"
>
@@ -157,22 +157,22 @@ exports[`InCallView > rendering > renders 1`] = `
Back to Speaker Mode
</button>
<div
class="spacer"
class="_spacer_eb6724"
/>
</div>
<div
class="container"
class="_container_8084b5"
/>
<div
class="footer"
class="_footer_4e7ff8 _footer_20b7b4"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_8_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
@@ -198,7 +198,7 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</button>
<div
class="logo"
class="_logo_20b7b4"
>
<svg
aria-hidden="true"
@@ -303,11 +303,11 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
</div>
<div
class="buttons"
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_d_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
@@ -382,7 +382,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_s_"
class="_button_1nw83_8 raiseHand _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
role="button"
@@ -405,7 +405,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-labelledby="_r_14_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
@@ -428,12 +428,12 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
<fieldset
aria-label="Layout"
class="_toggle_13rnk_9 layout"
class="_toggle_1t5ha_9 _layout_20b7b4"
data-size="lg"
>
<input
aria-labelledby="_r_19_"
name="layoutMode"
aria-labelledby="_r_1a_"
name="_r_19_"
type="radio"
value="spotlight"
/>
@@ -446,13 +446,15 @@ exports[`InCallView > rendering > renders 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 5h14v8h-5a1 1 0 0 0-1 1v5H5zm10 14v-4h4v4zM5 21h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2"
clip-rule="evenodd"
d="M20 6H4v12h16zM4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"
fill-rule="evenodd"
/>
</svg>
<input
aria-labelledby="_r_1e_"
aria-labelledby="_r_1f_"
checked=""
name="layoutMode"
name="_r_19_"
type="radio"
value="grid"
/>
+489 -24
View File
@@ -1,23 +1,487 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LobbyView > renders with AppBar android 1`] = `
<div>
<div
class="_bar_221541"
>
<header>
<button
aria-labelledby="_r_36_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
/>
</svg>
</div>
</button>
<div
class="_secondaryButton_221541"
/>
</header>
</div>
<div
class="_inRoom_4e7ff8"
>
<div
class="_content_f9ee84"
>
<div
class="_preview_dd2178"
>
<video
disablepictureinpicture=""
playsinline=""
poster="/src/graphics/video-placeholder.gif"
tabindex="-1"
/>
<div
class="_avatarContainer_dd2178"
>
<div>
<span
aria-label="@user:example.org"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="6"
data-type="round"
role="img"
style="--cpd-avatar-size: NaNpx;"
>
T
</span>
</div>
</div>
<div
class="_buttonBar_dd2178"
>
<button
aria-disabled="true"
class="_button_1nw83_8 _join_f9ee84 _wait_f9ee84"
data-kind="primary"
data-size="md"
data-testid="lobby_joinCall"
role="button"
tabindex="0"
>
Join call
</button>
</div>
</div>
<a
class="_link_13esb_8"
data-kind="primary"
data-size="md"
href="/"
rel="noreferrer noopener"
>
Back to recents
</a>
</div>
<div
class="_footer_20b7b4"
data-testid="footer-container"
>
<div
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_3c_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
/>
</svg>
</div>
</button>
</div>
<div
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_3h_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3m_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_3r_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
/>
</svg>
</button>
<button
aria-labelledby="_r_40_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
exports[`LobbyView > renders with AppBar ios 1`] = `
<div>
<div
class="_bar_221541"
>
<header>
<button
aria-labelledby="_r_4a_"
class="_icon-button_1215g_8 _primaryButton_221541"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
<div
class="_secondaryButton_221541"
/>
</header>
</div>
<div
class="_inRoom_4e7ff8"
>
<div
class="_content_f9ee84"
>
<div
class="_preview_dd2178"
>
<video
disablepictureinpicture=""
playsinline=""
poster="/src/graphics/video-placeholder.gif"
tabindex="-1"
/>
<div
class="_avatarContainer_dd2178"
>
<div>
<span
aria-label="@user:example.org"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="6"
data-type="round"
role="img"
style="--cpd-avatar-size: NaNpx;"
>
T
</span>
</div>
</div>
<div
class="_buttonBar_dd2178"
>
<button
aria-disabled="true"
class="_button_1nw83_8 _join_f9ee84 _wait_f9ee84"
data-kind="primary"
data-size="md"
data-testid="lobby_joinCall"
role="button"
tabindex="0"
>
Join call
</button>
</div>
</div>
<a
class="_link_13esb_8"
data-kind="primary"
data-size="md"
href="/"
rel="noreferrer noopener"
>
Back to recents
</a>
</div>
<div
class="_footer_20b7b4"
data-testid="footer-container"
>
<div
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_4g_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
</div>
<div
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_4l_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4q_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_4v_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
/>
</svg>
</button>
<button
aria-labelledby="_r_54_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
exports[`LobbyView > renders with header and participant count 1`] = `
<div>
<div
class="inRoom"
class="_inRoom_4e7ff8"
>
<header
class="header"
class="_header_e4b327"
>
<div
class="nav leftNav"
class="_nav_e4b327 _leftNav_e4b327"
>
<div
class="roomHeaderInfo"
class="_roomHeaderInfo_e4b327"
data-size="lg"
>
<span
aria-label="!room:example.org"
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
class="_avatar_va14e_8 _roomAvatar_e4b327 _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
@@ -26,7 +490,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
T
</span>
<div
class="nameLine"
class="_nameLine_e4b327"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
@@ -39,7 +503,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
>
<svg
aria-labelledby="_r_0_"
class="lock"
class="_lock_edc97d"
data-encrypted="false"
fill="currentColor"
height="16"
@@ -54,7 +518,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</span>
</div>
<div
class="participantsLine"
class="_participantsLine_e4b327"
>
<svg
aria-label="Participants"
@@ -84,22 +548,23 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<div
class="nav rightNav"
class="_nav_e4b327 _rightNav_e4b327"
/>
</header>
<div
class="content"
class="_content_f9ee84"
>
<div
class="preview"
class="_preview_dd2178"
>
<video
disablepictureinpicture=""
playsinline=""
poster="/src/graphics/video-placeholder.gif"
tabindex="-1"
/>
<div
class="avatarContainer"
class="_avatarContainer_dd2178"
>
<div>
<span
@@ -115,10 +580,10 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<div
class="buttonBar"
class="_buttonBar_dd2178"
>
<button
class="_button_1nw83_8 join"
class="_button_1nw83_8 _join_f9ee84"
data-kind="primary"
data-size="lg"
data-testid="lobby_joinCall"
@@ -130,7 +595,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<a
class="_link_k9ljz_8"
class="_link_13esb_8"
data-kind="primary"
data-size="md"
href="/"
@@ -140,15 +605,15 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</a>
</div>
<div
class="footer"
class="_footer_20b7b4"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
@@ -168,13 +633,13 @@ exports[`LobbyView > renders with header and participant count 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
/>
</svg>
</div>
</button>
<div
class="logo"
class="_logo_20b7b4"
>
<svg
aria-hidden="true"
@@ -279,11 +744,11 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</div>
</div>
<div
class="buttons"
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_b_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
@@ -299,7 +764,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
d="M12 20q-.825 0-1.412-.587A1.93 1.93 0 0 1 10 18q0-.824.588-1.413A1.93 1.93 0 0 1 12 16q.825 0 1.412.587Q14 17.176 14 18t-.588 1.413A1.93 1.93 0 0 1 12 20m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m0-6q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 6q0-.824.588-1.412A1.93 1.93 0 0 1 12 4q.825 0 1.412.588Q14 5.175 14 6q0 .824-.588 1.412A1.93 1.93 0 0 1 12 8"
/>
</svg>
</button>
@@ -355,7 +820,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
</button>
<button
aria-labelledby="_r_q_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
+1 -1
View File
@@ -280,7 +280,7 @@ export const useLoadGroupCall = (
);
}
if (
roomSummary === undefined ||
roomSummary?.join_rule === undefined ||
roomSummary.join_rule === JoinRule.Public
) {
room = await client.joinRoom(roomId, {