mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-23 20:59:21 +00:00
Compare commits
28 Commits
v0.18.0
...
robin/butt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d60b2c29 | ||
|
|
4d69565312 | ||
|
|
fa844446b6 | ||
|
|
6d14f1d06f | ||
|
|
5cf5227eaa | ||
|
|
78240c2ec8 | ||
|
|
bf8bf80417 | ||
|
|
7da9bca08c | ||
|
|
748c8e6d0d | ||
|
|
40ef16a55f | ||
|
|
07ad8374a9 | ||
|
|
a0d5c79999 | ||
|
|
3e171d9639 | ||
|
|
413329cd26 | ||
|
|
6b8f6e9405 | ||
|
|
c376d9e5c1 | ||
|
|
f09e2c8b55 | ||
|
|
dbdcde2489 | ||
|
|
636f737cd3 | ||
|
|
93c9225e8a | ||
|
|
582d1d8495 | ||
|
|
eb60473510 | ||
|
|
c35b960627 | ||
|
|
df2f503a05 | ||
|
|
c20b206ab2 | ||
|
|
5ceb140d13 | ||
|
|
ed4517703f | ||
|
|
bd3e91738e |
9
.github/workflows/blocked.yaml
vendored
9
.github/workflows/blocked.yaml
vendored
@@ -1,7 +1,16 @@
|
||||
name: Prevent blocked
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This workflow does not checkout code or use secrets.
|
||||
# It only reads labels to set a failure status on the PR.
|
||||
pull_request_target:
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Required to fail the check on the PR
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
|
||||
4
.github/workflows/build-element-call.yaml
vendored
4
.github/workflows/build-element-call.yaml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
- name: Build Element Call
|
||||
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
|
||||
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
@@ -54,6 +54,8 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
PACKAGE: ${{ inputs.package }}
|
||||
BUILD_MODE: ${{ inputs.build_mode }}
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
8
.github/workflows/changelog-label.yml
vendored
8
.github/workflows/changelog-label.yml
vendored
@@ -1,8 +1,16 @@
|
||||
name: PR changelog label
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# This is safe because we do not use actions/checkout or execute untrusted code.
|
||||
# Using pull_request_target is necessary to allow status writes for PRs from forks.
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
11
.github/workflows/pr-deploy.yaml
vendored
11
.github/workflows/pr-deploy.yaml
vendored
@@ -1,5 +1,7 @@
|
||||
name: Deploy previews for PRs
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This is now restricted to internal PRs only using the 'if' condition below.
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
types:
|
||||
@@ -7,7 +9,14 @@ on:
|
||||
|
||||
jobs:
|
||||
prdetails:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
# Logic:
|
||||
# 1. Build must be successful
|
||||
# 2. Event must be a pull_request
|
||||
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
|
||||
14
.github/workflows/publish-embedded-packages.yaml
vendored
14
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -22,8 +22,18 @@ jobs:
|
||||
TAG: ${{ steps.tag.outputs.TAG }}
|
||||
steps:
|
||||
- name: Calculate VERSION
|
||||
# We should only use the hard coded test value for a dry run
|
||||
run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV"
|
||||
# Safely store dynamic values in environment variables
|
||||
# to prevent shell injection (template-injection)
|
||||
run: |
|
||||
# The logic is executed within the shell using the env variables
|
||||
if [ "$EVENT_NAME" = "release" ]; then
|
||||
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
- id: dry_run
|
||||
name: Set DRY_RUN
|
||||
# We perform a dry run for all events except releases.
|
||||
|
||||
2
.github/workflows/zizmor.yml
vendored
2
.github/workflows/zizmor.yml
vendored
@@ -20,4 +20,4 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
|
||||
13
package.json
13
package.json
@@ -79,7 +79,7 @@
|
||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
@@ -128,17 +128,22 @@
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@livekit/components-core/rxjs": "^7.8.1",
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
// Check the button toolbar
|
||||
// await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible();
|
||||
// await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible();
|
||||
// await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible();
|
||||
// await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "End call" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
).toBeVisible();
|
||||
|
||||
// Tab order should jump directly from header to footer, skipping media tiles
|
||||
await page.getByRole("button", { name: "Mute microphone" }).focus();
|
||||
await page.getByRole("switch", { name: "Mute microphone" }).focus();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Mute microphone" }),
|
||||
page.getByRole("switch", { name: "Mute microphone" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
|
||||
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
|
||||
// Most critically, we should be able to press the hangup button
|
||||
await page.getByRole("button", { name: "End call" }).click();
|
||||
});
|
||||
|
||||
@@ -55,13 +55,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// No lobby, should start with video on
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// video should be on
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// We should see 5 video tiles everywhere now
|
||||
@@ -101,13 +98,15 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const florianMuteButton = florianFrame.getByTestId("incall_videomute");
|
||||
await florianMuteButton.click();
|
||||
const florianVideoButton = florianFrame.getByRole("switch", {
|
||||
name: /video/,
|
||||
});
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Stop video");
|
||||
await expect(florianVideoButton).toBeChecked();
|
||||
await florianVideoButton.click();
|
||||
// Now the button should indicate we can start video
|
||||
await expect(florianMuteButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Start video$/,
|
||||
);
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Start video");
|
||||
await expect(florianVideoButton).not.toBeChecked();
|
||||
|
||||
// wait a bit for the state to propagate
|
||||
await valere.page.waitForTimeout(3000);
|
||||
|
||||
@@ -47,14 +47,17 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
|
||||
{
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const hangupBtn = iFrame.getByRole("button", { name: "End call" });
|
||||
const audioBtn = iFrame.getByTestId("incall_mute");
|
||||
const videoBtn = iFrame.getByTestId("incall_videomute");
|
||||
await expect(hangupBtn).toBeVisible();
|
||||
const audioBtn = iFrame.getByRole("switch", { name: /microphone/ });
|
||||
const videoBtn = iFrame.getByRole("switch", { name: /video/ });
|
||||
await expect(
|
||||
iFrame.getByRole("button", { name: "End call" }),
|
||||
).toBeVisible();
|
||||
await expect(audioBtn).toBeVisible();
|
||||
await expect(videoBtn).toBeVisible();
|
||||
await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/);
|
||||
await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
await expect(audioBtn).toHaveAccessibleName("Mute microphone");
|
||||
await expect(audioBtn).toBeChecked();
|
||||
await expect(videoBtn).toHaveAccessibleName("Stop video");
|
||||
await expect(videoBtn).toBeChecked();
|
||||
|
||||
await videoBtn.click();
|
||||
await audioBtn.click();
|
||||
@@ -62,7 +65,9 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
// stop hovering on any of the buttons
|
||||
await iFrame.getByTestId("videoTile").hover();
|
||||
|
||||
await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/);
|
||||
await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/);
|
||||
await expect(audioBtn).toHaveAccessibleName("Unmute microphone");
|
||||
await expect(audioBtn).toBeChecked();
|
||||
await expect(videoBtn).toHaveAccessibleName("Start video");
|
||||
await expect(videoBtn).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,16 +40,14 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
{
|
||||
const frame = timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const frame = timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// check that the video is on
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
}
|
||||
// check that the video is on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
|
||||
@@ -51,34 +51,36 @@ widgetTest(
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Start video",
|
||||
checked: false,
|
||||
}),
|
||||
).toBeVisible();
|
||||
// audio should be on for the voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Start video",
|
||||
checked: false,
|
||||
}),
|
||||
).toBeVisible();
|
||||
// audio should be on for the voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
@@ -90,10 +92,7 @@ widgetTest(
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
@@ -142,34 +141,30 @@ widgetTest(
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a video call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
// audio should be on too
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a video call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
// audio should be on too
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
@@ -181,10 +176,7 @@ widgetTest(
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
|
||||
@@ -22,24 +22,25 @@ import {
|
||||
import styles from "./Button.module.css";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
|
||||
const label = muted
|
||||
? t("unmute_microphone_button_label")
|
||||
: t("mute_microphone_button_label");
|
||||
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("mute_microphone_button_label")
|
||||
: t("unmute_microphone_button_label");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -47,24 +48,25 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
};
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon;
|
||||
const label = muted
|
||||
? t("start_video_button_label")
|
||||
: t("stop_video_button_label");
|
||||
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("stop_video_button_label")
|
||||
: t("start_video_button_label");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -91,6 +93,8 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
iconOnly
|
||||
Icon={ShareScreenSolidIcon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -112,7 +116,6 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
aria-label={t("hangup_button_label")}
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
|
||||
@@ -30,7 +30,13 @@ import {
|
||||
} from "../utils/test";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
||||
|
||||
|
||||
@@ -645,7 +645,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
muted={!audioEnabled}
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
data-testid="incall_mute"
|
||||
@@ -653,7 +653,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
muted={!videoEnabled}
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
data-testid="incall_videomute"
|
||||
|
||||
@@ -230,12 +230,12 @@ export const LobbyView: FC<Props> = ({
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
<div className={inCallStyles.buttons}>
|
||||
<MicButton
|
||||
muted={!audioEnabled}
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
/>
|
||||
<VideoButton
|
||||
muted={!videoEnabled}
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
/>
|
||||
|
||||
@@ -285,14 +285,14 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-label="Unmute microphone"
|
||||
aria-labelledby="_r_8_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="primary"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="button"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
@@ -309,14 +309,14 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-label="Start video"
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="primary"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
role="button"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
@@ -354,7 +354,6 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="End call"
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
||||
data-kind="primary"
|
||||
|
||||
@@ -5,13 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
import { customLivekitUrl as customLivekitUrlSetting } from "./settings";
|
||||
// Mock url params hook to avoid environment-dependent snapshot churn.
|
||||
vi.mock("../UrlParams", () => ({
|
||||
useUrlParams: (): { mocked: boolean; answer: number } => ({
|
||||
@@ -20,6 +23,14 @@ vi.mock("../UrlParams", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// IMPORTANT: mock the same specifier used by DeveloperSettingsTab
|
||||
vi.mock("../livekit/openIDSFU", () => ({
|
||||
getSFUConfigWithOpenID: vi.fn().mockResolvedValue({
|
||||
url: "mock-url",
|
||||
jwt: "mock-jwt",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Provide a minimal mock of a Livekit Room structure used by the component.
|
||||
function createMockLivekitRoom(
|
||||
wsUrl: string,
|
||||
@@ -86,6 +97,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
const { container } = render(
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId={"#room:example.org"}
|
||||
livekitRooms={livekitRooms}
|
||||
env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv}
|
||||
/>,
|
||||
@@ -99,4 +111,141 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
describe("custom livekit url", () => {
|
||||
afterEach(() => {
|
||||
customLivekitUrlSetting.setValue(null);
|
||||
});
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
|
||||
getCrypto: () => ({ getVersion: (): string => "x" }),
|
||||
getUserId: () => "@u:hs",
|
||||
getDeviceId: () => "DEVICE",
|
||||
} as unknown as MatrixClient;
|
||||
it("will not update custom livekit url without roomId", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.invalid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will not update custom livekit url without text in input", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will not update custom livekit url when pressing cancel", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.invalid");
|
||||
|
||||
const cancelButton = screen.getByRole("button", {
|
||||
name: "Reset overwrite",
|
||||
});
|
||||
await user.click(cancelButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will update custom livekit url", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.valid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"wss://example.livekit.valid",
|
||||
"#testRoom",
|
||||
);
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(
|
||||
"wss://example.livekit.valid",
|
||||
);
|
||||
});
|
||||
it("will show error on invalid url", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.valid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
(getSFUConfigWithOpenID as Mock).mockImplementation(() => {
|
||||
throw new Error("Invalid URL");
|
||||
});
|
||||
await user.click(saveButton);
|
||||
expect(
|
||||
screen.getByText("invalid URL (did not update)"),
|
||||
).toBeInTheDocument();
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
EditInPlace,
|
||||
ErrorMessage,
|
||||
Root as Form,
|
||||
Heading,
|
||||
HelpMessage,
|
||||
@@ -45,9 +46,11 @@ import {
|
||||
} from "./settings";
|
||||
import styles from "./DeveloperSettingsTab.module.css";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
livekitRooms?: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
@@ -60,6 +63,7 @@ interface Props {
|
||||
export const DeveloperSettingsTab: FC<Props> = ({
|
||||
client,
|
||||
livekitRooms,
|
||||
roomId,
|
||||
env,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -97,6 +101,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
alwaysShowIphoneEarpieceSetting,
|
||||
);
|
||||
|
||||
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
|
||||
useState<string | null>(null);
|
||||
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
|
||||
customLivekitUrlSetting,
|
||||
);
|
||||
@@ -234,14 +240,36 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
savingLabel={t("developer_mode.custom_livekit_url.saving")}
|
||||
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")}
|
||||
onSave={useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
setCustomLivekitUrl(
|
||||
customLivekitUrlTextBuffer === ""
|
||||
? null
|
||||
: customLivekitUrlTextBuffer,
|
||||
);
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
if (
|
||||
roomId === undefined ||
|
||||
customLivekitUrlTextBuffer === "" ||
|
||||
customLivekitUrlTextBuffer === null
|
||||
) {
|
||||
setCustomLivekitUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
|
||||
if (userId === null || deviceId === null) {
|
||||
throw new Error("Invalid user or device ID");
|
||||
}
|
||||
await getSFUConfigWithOpenID(
|
||||
client,
|
||||
{ userId, deviceId, memberId: "" },
|
||||
customLivekitUrlTextBuffer,
|
||||
roomId,
|
||||
);
|
||||
setCustomLivekitUrlUpdateError(null);
|
||||
setCustomLivekitUrl(customLivekitUrlTextBuffer);
|
||||
} catch {
|
||||
setCustomLivekitUrlUpdateError("invalid URL (did not update)");
|
||||
}
|
||||
},
|
||||
[setCustomLivekitUrl, customLivekitUrlTextBuffer],
|
||||
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
|
||||
)}
|
||||
value={customLivekitUrlTextBuffer ?? ""}
|
||||
onChange={useCallback(
|
||||
@@ -256,7 +284,12 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
},
|
||||
[setCustomLivekitUrl],
|
||||
)}
|
||||
/>
|
||||
serverInvalid={customLivekitUrlUpdateError !== null}
|
||||
>
|
||||
{customLivekitUrlUpdateError !== null && (
|
||||
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
|
||||
)}
|
||||
</EditInPlace>
|
||||
<Heading as="h3" type="body" weight="semibold" size="lg">
|
||||
{t("developer_mode.matrixRTCMode.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
env={import.meta.env}
|
||||
client={client}
|
||||
livekitRooms={livekitRooms}
|
||||
roomId={roomId}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -34,15 +34,20 @@ export class Setting<T> {
|
||||
|
||||
this._value$ = new BehaviorSubject(initialValue);
|
||||
this.value$ = this._value$;
|
||||
this._lastUpdateReason$ = new BehaviorSubject<string | null>(null);
|
||||
this.lastUpdateReason$ = this._lastUpdateReason$;
|
||||
}
|
||||
|
||||
private readonly key: string;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
||||
public readonly value$: Behavior<T>;
|
||||
public readonly lastUpdateReason$: Behavior<string | null>;
|
||||
|
||||
public readonly setValue = (value: T): void => {
|
||||
public readonly setValue = (value: T, reason?: string): void => {
|
||||
this._value$.next(value);
|
||||
this._lastUpdateReason$.next(reason ?? null);
|
||||
localStorage.setItem(this.key, JSON.stringify(value));
|
||||
};
|
||||
public readonly getValue = (): T => {
|
||||
|
||||
@@ -90,11 +90,18 @@ export const testAudioContext = {
|
||||
createStereoPanner: vi.fn().mockReturnValue(panNode),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
|
||||
const TestAudioContext = vi.fn(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let user: UserEvent;
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
|
||||
vi.stubGlobal("AudioContext", TestAudioContext);
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
|
||||
@@ -443,10 +443,10 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
public asMockedSession(): MockedObject<MatrixRTCSession> {
|
||||
const session = this as unknown as MockedObject<MatrixRTCSession>;
|
||||
|
||||
vi.mocked(session).reemitEncryptionKeys = vi
|
||||
session.reemitEncryptionKeys = vi
|
||||
.fn<() => void>()
|
||||
.mockReturnValue(undefined);
|
||||
vi.mocked(session).getOldestMembership = vi
|
||||
session.getOldestMembership = vi
|
||||
.fn<() => CallMembership | undefined>()
|
||||
.mockReturnValue(this.memberships[0]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user