Compare commits

..

2 Commits

Author SHA1 Message Date
Valere Fedronic
1538d8a455 Merge pull request #4142 from element-hq/valere/backport/rounded_corner_glitch
[backport] fix: FF mirrored self video lose rounding
2026-08-03 18:03:14 +02:00
Valere
31026755a4 fix: FF mirrored self video lose rounding 2026-08-03 14:06:24 +02:00
20 changed files with 2378 additions and 2205 deletions

View File

@@ -7,6 +7,10 @@ jobs:
vitest:
name: Run unit tests
runs-on: ubuntu-latest
container:
# Make sure to grab the latest version of the Playwright image
# https://playwright.dev/docs/docker#pull-the-image
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -22,18 +26,6 @@ jobs:
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright binaries
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Vitest
run: "pnpm run test:coverage"
- name: Upload to codecov
@@ -60,18 +52,8 @@ jobs:
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright binaries
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run backend components
run: |
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull

View File

@@ -40,11 +40,6 @@ jobs:
- name: Fix the owner of the downloaded files
run: "sudo chown runner:docker -R locales"
# Localazy doesn't write file contents in the same order that i18next-cli's
# extractor uses. We re-run the extractor after downloading to fix the order.
- name: i18n
run: pnpm i18n
- name: Formatting
run: pnpm format
@@ -58,7 +53,7 @@ jobs:
title: Localazy Download
commit-message: Translations updates
labels: |
PR-Task
T-Task
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"

View File

@@ -47,7 +47,7 @@ services:
- ecbackend
livekit:
image: livekit/livekit-server:v1.13.4
image: livekit/livekit-server:v1.10.1
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
@@ -70,7 +70,7 @@ services:
- ecbackend
livekit-1:
image: livekit/livekit-server:v1.13.4
image: livekit/livekit-server:v1.10.1
pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml

View File

@@ -13,7 +13,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:0183c0a7f4292252349ddcff2de92a83a4571b283da49cc54adb9efbf3d20695
image: ghcr.io/element-hq/element-web:develop@sha256:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:0183c0a7f4292252349ddcff2de92a83a4571b283da49cc54adb9efbf3d20695
image: ghcr.io/element-hq/element-web:develop@sha256:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a

View File

@@ -86,11 +86,10 @@ to implement
In the context of MatrixRTC, we suggest using a single hostname for backend
communication by implementing endpoint routing within a reverse proxy setup. For
the example above, this results in:
| Service | Endpoint | Example |
| --------------------------------------------------------------------------------- | -------------- | ------------------------------------ |
| Service | Endpoint | Example |
| -------- | ------- | ------- |
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
Using Nginx, you can achieve this by:

View File

@@ -54,7 +54,6 @@
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^10.0.0",
"@rolldown/plugin-babel": "^0.2.3",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@storybook/addon-docs": "^10.3.6",
@@ -81,8 +80,7 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"@vitest/ui": "4.1.9",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint-plugin-element-call": "link:eslint",
@@ -101,13 +99,13 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.61.0",
"oxfmt": "^0.56.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
"oxlint-tsgolint": "^0.23.0",
"pako": "^2.0.4",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "1.408.2",
"posthog-js": "1.374.0",
"qrcode": "^1.5.4",
"react": "19",
"react-dom": "19",

View File

@@ -36,7 +36,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
await valere.page.waitForTimeout(600);
// Switch to the other room, the call should go to PIP
await TestHelpers.expandRoomList(valere.page);
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
// We should see the PIP overlay

View File

@@ -53,7 +53,6 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
});
// Switch to the other room, the call should go to PIP
await TestHelpers.expandRoomList(valere.page);
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
// We should see the PIP overlay

View File

@@ -346,12 +346,6 @@ export class TestHelpers {
}
}
public static async expandRoomList(page: Page): Promise<void> {
await page
.getByRole("separator", { name: "Click or drag to expand" })
.click();
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page

4299
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,15 +16,12 @@ import {
HeaderStyle,
getUrlParams,
} from "../src/UrlParams";
import { mockConfig } from "./utils/test";
const ROOM_NAME = "roomNameHere";
const ROOM_ID = "!d45f138fsd";
const ORIGIN = "https://call.element.io";
const HOMESERVER = "localhost";
mockConfig();
describe("UrlParams", () => {
describe("handles URL with /room/", () => {
it("and nothing else", () => {

View File

@@ -183,6 +183,12 @@ export interface ConfigOptions {
// Overrides members from ConfigOptions that are always provided by the
// default config and are therefore non-optional.
export interface ResolvedConfigOptions extends ConfigOptions {
default_server_config: {
["m.homeserver"]: {
base_url: string;
server_name: string;
};
};
sync_disconnect_grace_period_ms: number;
ssla: string;
matrix_rtc_session: {
@@ -196,6 +202,12 @@ export interface ResolvedConfigOptions extends ConfigOptions {
}
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
default_server_config: {
["m.homeserver"]: {
base_url: "http://localhost:8008",
server_name: "localhost",
},
},
features: {
feature_use_device_session_member_events: true,
},

View File

@@ -15,7 +15,8 @@ Please see LICENSE in the repository root for full details.
@import url("@fontsource/inconsolata/700.css");
@import url("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
:root {

View File

@@ -99,7 +99,7 @@ function renderTestComponent(
),
} as unknown as Room;
if ((explicitTracks?.length ?? 0) > 0) {
if (explicitTracks?.length ?? 0 > 0) {
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
const participant =
liveKitParticipants.find((p) => p.identity === participantId) ??

View File

@@ -5,10 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalTrackPublication,
type RemoteTrackPublication,
} from "livekit-client";
import { type RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
@@ -20,9 +17,6 @@ import {
mockRtcMembership,
mockRemoteMedia,
mockRemoteParticipant,
mockLocalMedia,
mockLocalParticipant,
mockMediaDevices,
} from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
@@ -60,7 +54,7 @@ const callVm = {
handsRaised$: constant({}),
} as Partial<CallViewModel> as CallViewModel;
test("GridTile displays remote media", async () => {
test("GridTile is accessible", async () => {
const vm = mockRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{
@@ -94,40 +88,6 @@ test("GridTile displays remote media", async () => {
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>,
);
expect(await axe(container)).toHaveNoViolations();
// Name should be visible
screen.getByText("Alice");
});
test("GridTile displays ringing media", async () => {
const pickupState$ = new BehaviorSubject<
RingingMediaViewModel["pickupState$"]["value"]

View File

@@ -14,7 +14,6 @@ import {
useEffect,
useRef,
useState,
useMemo,
} from "react";
import { type animated } from "@react-spring/web";
import classNames from "classnames";
@@ -106,22 +105,20 @@ interface UserMediaTileProps extends TileProps {
playbackMuted: boolean;
waitingForMedia?: boolean;
primaryButton?: ReactNode;
menuStart?: ReactNode;
menuEnd?: ReactNode;
focusUrl: string | undefined;
}
/**
* 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 }> = ({
const UserMediaTile: FC<UserMediaTileProps> = ({
ref,
vm,
showSpeakingIndicators,
playbackMuted,
waitingForMedia,
primaryButton,
menu,
menuStart,
menuEnd,
className,
focusUrl,
displayName,
@@ -169,26 +166,24 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
: t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false);
const menuTrigger = useMemo(
() => (
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
),
[t, focusable],
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 raisedHandOnClick = useMemo(
() => (vm.local ? (): void => void toggleRaisedHand() : undefined),
[vm.local, toggleRaisedHand],
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
return (
const tile = (
<MediaView
ref={ref}
video={video}
@@ -218,7 +213,14 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={menuTrigger}
trigger={
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
@@ -239,37 +241,9 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
{...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={props.displayName}
trigger={<UserMediaTileInner {...props} menu={menu} />}
hasAccessibleAlternative
>
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
@@ -305,29 +279,6 @@ 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}
@@ -346,8 +297,23 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
</button>
)
}
menuStart={menuStart}
menuEnd={menuEnd}
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}
/>
)
}
focusable={focusable}
focusUrl={focusUrl}
{...props}

View File

@@ -32,7 +32,6 @@ import {
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
public unobserve(): void {}
public disconnect(): void {}
} as unknown as typeof IntersectionObserver;
test("SpotlightTile is accessible", async () => {

View File

@@ -28,10 +28,6 @@ test("useTypedEventEmitterState reacts to events", async () => {
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler
// https://github.com/react/react/issues/34901
"use no memo";
const value = useTypedEventEmitterState(
emitter,
"change",
@@ -55,10 +51,6 @@ test("useTypedEventEmitterState reacts to changes made by an effect mounted on t
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler
// https://github.com/react/react/issues/34901
"use no memo";
useEffect(() => emitter.setState(2), []);
const value = useTypedEventEmitterState(
emitter,
@@ -77,10 +69,6 @@ test("useTypedEventEmitterState reacts to changes in getState", async () => {
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler
// https://github.com/react/react/issues/34901
"use no memo";
const [fn, setFn] = useState(() => emitter.getState);
const value = useTypedEventEmitterState(emitter, "change", fn);
return (

View File

@@ -439,12 +439,6 @@ export function mockConfig(
const spy = vi.spyOn(Config, "get").mockReturnValue({
...DEFAULT_CONFIG,
...config,
default_server_config: {
["m.homeserver"]: {
base_url: "http://localhost:8008",
server_name: "localhost",
},
},
});
// simulate loading the config
vi.spyOn(Config, "init").mockResolvedValue(void 0);

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import {
loadEnv,
type PluginOption,
PluginOption,
searchForWorkspaceRoot,
type ConfigEnv,
type UserConfig,
@@ -19,8 +19,8 @@ import { codecovVitePlugin } from "@codecov/vite-plugin";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import wasm from "vite-plugin-wasm";
import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import react from "@vitejs/plugin-react";
import { realpathSync } from "fs";
import * as fs from "node:fs";
@@ -29,9 +29,6 @@ export const vitePluginsConfig = ({
}: Pick<ConfigEnv, "mode">): UserConfig => {
const env = loadEnv(mode, process.cwd());
const plugins: PluginOption[] = [
babel({
presets: [reactCompilerPreset()],
}),
react(),
wasm(),
nodePolyfills({