mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-31 07:00:26 +00:00
Merge remote-tracking branch 'origin/livekit' into hs/new-reactions-design
This commit is contained in:
1
.github/workflows/build.yaml
vendored
1
.github/workflows/build.yaml
vendored
@@ -23,3 +23,4 @@ jobs:
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
3
.github/workflows/element-call.yaml
vendored
3
.github/workflows/element-call.yaml
vendored
@@ -14,6 +14,8 @@ on:
|
||||
required: true
|
||||
SENTRY_AUTH_TOKEN:
|
||||
required: true
|
||||
CODECOV_TOKEN:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -36,6 +38,7 @@ jobs:
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Upload Artifact
|
||||
|
||||
@@ -106,7 +106,7 @@ rc_message:
|
||||
|
||||
MSC3266 allows to request a room summary of rooms you are not joined. The
|
||||
summary contains the room join rules. We need that to decide if the user gets
|
||||
prompted with the option to knock ("ask to join"), a cannot join error or the
|
||||
prompted with the option to knock ("Request to join call"), a cannot join error or the
|
||||
join view.
|
||||
|
||||
Element Call requires a Livekit SFU alongside a [Livekit JWT
|
||||
|
||||
13
package.json
13
package.json
@@ -24,6 +24,9 @@
|
||||
"@babel/preset-env": "^7.22.20",
|
||||
"@babel/preset-react": "^7.22.15",
|
||||
"@babel/preset-typescript": "^7.23.0",
|
||||
"@codecov/vite-plugin": "^1.3.0",
|
||||
"@fontsource/inconsolata": "^5.1.0",
|
||||
"@fontsource/inter": "^5.1.0",
|
||||
"@formatjs/intl-durationformat": "^0.6.1",
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.11.0",
|
||||
@@ -48,7 +51,7 @@
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/grecaptcha": "^3.0.9",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "^4.14.199",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -59,7 +62,7 @@
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^1.9.1",
|
||||
"@vector-im/compound-web": "element-hq/compound-web#46cf2d94d9c9b6d25e80ef0e785f3a929ed040ea",
|
||||
"@vector-im/compound-web": "^7.2.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.0.1",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
@@ -85,10 +88,10 @@
|
||||
"jsdom": "^25.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"livekit-client": "^2.5.7",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6971e7bebaad643c233e5057da7a0d42441c0789",
|
||||
"matrix-widget-api": "^1.8.2",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"pako": "^2.0.4",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -81,8 +81,8 @@
|
||||
"call_ended_heading": "Anruf beendet",
|
||||
"failed_heading": "Beitreten fehlgeschlagen",
|
||||
"failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt",
|
||||
"knock_reject_body": "Die Mitglieder des Raums haben Deine Beitrittsanfrage abgelehnt.",
|
||||
"knock_reject_heading": "Beitritt nicht erlaubt",
|
||||
"knock_reject_body": "Die Teilnahmeanfrage wurde abgelehnt.",
|
||||
"knock_reject_heading": "Zugriff verweigert",
|
||||
"reason": "Grund"
|
||||
},
|
||||
"hangup_button_label": "Anruf beenden",
|
||||
@@ -100,11 +100,11 @@
|
||||
"layout_grid_label": "Raster",
|
||||
"layout_spotlight_label": "Fokus",
|
||||
"lobby": {
|
||||
"ask_to_join": "Beitritt anfragen",
|
||||
"ask_to_join": "Teilnahmeanfrage senden",
|
||||
"join_as_guest": "Als Gast beitreten",
|
||||
"join_button": "Anruf beitreten",
|
||||
"leave_button": "Zurück zu kürzlichen Anrufen",
|
||||
"waiting_for_invite": "Anfrage gesendet"
|
||||
"waiting_for_invite": "Anfrage gesendet! Warten auf Teilnahmefreigabe …"
|
||||
},
|
||||
"log_in": "Anmelden",
|
||||
"logging_in": "Anmelden …",
|
||||
@@ -115,7 +115,7 @@
|
||||
"matrix_id": "Matrix-ID: {{id}}",
|
||||
"microphone_off": "Mikrofon aus",
|
||||
"microphone_on": "Mikrofon an",
|
||||
"mute_microphone_button_label": "Mikrofon deaktivieren",
|
||||
"mute_microphone_button_label": "Mikrofon stumm schalten",
|
||||
"participant_count_one": "{{count, number}}",
|
||||
"participant_count_other": "{{count, number}}",
|
||||
"qr_code": "QR-Code",
|
||||
@@ -184,14 +184,15 @@
|
||||
"unauthenticated_view_body": "Noch nicht registriert? <2>Konto erstellen</2>",
|
||||
"unauthenticated_view_eula_caption": "Mit einem Klick auf „Los geht’s“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>",
|
||||
"unauthenticated_view_login_button": "Melde dich mit deinem Konto an",
|
||||
"unmute_microphone_button_label": "Mikrofon aktivieren",
|
||||
"unmute_microphone_button_label": "Mikrofon einschalten",
|
||||
"version": "{{productName}} Version: {{version}}",
|
||||
"video_tile": {
|
||||
"always_show": "Immer anzeigen",
|
||||
"change_fit_contain": "An Fenster anpassen",
|
||||
"collapse": "Minimieren",
|
||||
"expand": "Erweitern",
|
||||
"mute_for_me": "Für mich stummschalten",
|
||||
"mute_for_me": "Für mich stumm schalten",
|
||||
"muted_for_me": "Für mich stumm geschaltet",
|
||||
"volume": "Lautstärke"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
"call_ended_heading": "Call ended",
|
||||
"failed_heading": "Failed to join",
|
||||
"failed_text": "Call not found or is not accessible.",
|
||||
"knock_reject_body": "The room members declined your request to join.",
|
||||
"knock_reject_heading": "Not allowed to join",
|
||||
"knock_reject_body": "Your request to join was declined.",
|
||||
"knock_reject_heading": "Access denied",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"hangup_button_label": "End call",
|
||||
@@ -99,11 +99,11 @@
|
||||
"layout_grid_label": "Grid",
|
||||
"layout_spotlight_label": "Spotlight",
|
||||
"lobby": {
|
||||
"ask_to_join": "Ask to join call",
|
||||
"ask_to_join": "Request to join call",
|
||||
"join_as_guest": "Join as guest",
|
||||
"join_button": "Join call",
|
||||
"leave_button": "Back to recents",
|
||||
"waiting_for_invite": "Request sent"
|
||||
"waiting_for_invite": "Request sent! Waiting for permission to join…"
|
||||
},
|
||||
"log_in": "Log In",
|
||||
"logging_in": "Logging in…",
|
||||
@@ -190,6 +190,7 @@
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"volume": "Volume"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { FC, useCallback } from "react";
|
||||
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
|
||||
import classNames from "classnames";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./Slider.module.css";
|
||||
|
||||
@@ -66,7 +67,10 @@ export const Slider: FC<Props> = ({
|
||||
<Track className={styles.track}>
|
||||
<Range className={styles.highlight} />
|
||||
</Track>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
{/* Note: This is expected not to be visible on mobile.*/}
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { merge } from "lodash";
|
||||
import { merge } from "lodash-es";
|
||||
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import {
|
||||
|
||||
@@ -96,6 +96,30 @@ export interface ConfigOptions {
|
||||
* Note that this can additionally be disabled by the app's URL parameters.
|
||||
*/
|
||||
app_prompt?: boolean;
|
||||
|
||||
/**
|
||||
* These are low level options that are used to configure the MatrixRTC session.
|
||||
* Take care when changing these options.
|
||||
*/
|
||||
matrix_rtc_session?: {
|
||||
/**
|
||||
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
|
||||
* when someone leaves a call.
|
||||
*/
|
||||
key_rotation_on_leave_delay?: number;
|
||||
|
||||
/**
|
||||
* How often (in milliseconds) keep-alive messages should be sent to the server for
|
||||
* the MatrixRTC membership event.
|
||||
*/
|
||||
membership_keep_alive_period?: number;
|
||||
|
||||
/**
|
||||
* How long (in milliseconds) after the last keep-alive the server should expire the
|
||||
* MatrixRTC membership event.
|
||||
*/
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Overrides members from ConfigOptions that are always provided by the
|
||||
|
||||
105
src/index.css
105
src/index.css
@@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/* Inter unexpectedly contains various codepoints which collide with emoji, even
|
||||
when variation-16 is applied to request the emoji variant. From eyeballing
|
||||
the emoji picker, these are: 20e3, 23cf, 24c2, 25a0-25c1, 2665, 2764, 2b06, 2b1c.
|
||||
Therefore we define a unicode-range to load which excludes the glyphs
|
||||
(to avoid having to maintain a fork of Inter). */
|
||||
|
||||
@layer normalize, compound-legacy, compound;
|
||||
|
||||
@import url("@fontsource/inter/400.css");
|
||||
@import url("@fontsource/inter/500.css");
|
||||
@import url("@fontsource/inter/600.css");
|
||||
@import url("@fontsource/inter/700.css");
|
||||
@import url("@fontsource/inconsolata/400.css");
|
||||
@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);
|
||||
@@ -52,94 +53,6 @@ layer(compound);
|
||||
--stopgap-background-85: rgba(16, 19, 23, 0.85);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Regular.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Regular.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Italic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Italic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Medium.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Medium.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-MediumItalic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-MediumItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-SemiBold.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-SemiBold.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-SemiBoldItalic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-SemiBoldItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-Bold.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-Bold.woff") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
unicode-range: var(--inter-unicode-range);
|
||||
src:
|
||||
url("/fonts/Inter/Inter-BoldItalic.woff2") format("woff2"),
|
||||
url("/fonts/Inter/Inter-BoldItalic.woff") format("woff");
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
@@ -185,10 +98,6 @@ body[data-platform="ios"] {
|
||||
--cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body[data-platform="desktop"] {
|
||||
--cpd-font-family-sans: "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@layer compound-legacy {
|
||||
h1,
|
||||
h2,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MatrixError } from "matrix-js-sdk/src/matrix";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Link } from "../button/Link";
|
||||
import {
|
||||
useLoadGroupCall,
|
||||
GroupCallStatus,
|
||||
CallTerminatedMessage,
|
||||
} from "./useLoadGroupCall";
|
||||
import { ErrorView, FullScreenView } from "../FullScreenView";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomIdOrAlias: string;
|
||||
viaServers: string[];
|
||||
children: (groupCallState: GroupCallStatus) => JSX.Element;
|
||||
}
|
||||
|
||||
export function GroupCallLoader({
|
||||
client,
|
||||
roomIdOrAlias,
|
||||
viaServers,
|
||||
children,
|
||||
}: Props): JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
case "waitForInvite":
|
||||
case "canKnock":
|
||||
return children(groupCallState);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "failed":
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,29 +177,37 @@ export const GroupCallView: FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (widget && preload && skipLobby) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else if (widget && !preload && skipLobby) {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
if (skipLobby) {
|
||||
if (widget) {
|
||||
if (preload) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(
|
||||
ev.detail.data as unknown as JoinCallData,
|
||||
);
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup({ audioInput: null, videoInput: null });
|
||||
await enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session", e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
void enterRTCSession(rtcSession, perParticipantE2EE);
|
||||
}
|
||||
}
|
||||
}, [rtcSession, preload, skipLobby, perParticipantE2EE]);
|
||||
|
||||
|
||||
@@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { FC, useEffect, useState, useCallback, ReactNode } from "react";
|
||||
import { FC, useEffect, useState, ReactNode, useRef } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { MatrixError } from "matrix-js-sdk/src/http-api";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
|
||||
import { useClientLegacy } from "../ClientContext";
|
||||
import { ErrorView, LoadingView } from "../FullScreenView";
|
||||
import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
|
||||
import { RoomAuthView } from "./RoomAuthView";
|
||||
import { GroupCallLoader } from "./GroupCallLoader";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
@@ -21,13 +22,14 @@ import { HomePage } from "../home/HomePage";
|
||||
import { platform } from "../Platform";
|
||||
import { AppSelectionModal } from "./AppSelectionModal";
|
||||
import { widget } from "../widget";
|
||||
import { GroupCallStatus } from "./useLoadGroupCall";
|
||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useProfile } from "../profile/useProfile";
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import { useOptInAnalytics } from "../settings/settings";
|
||||
import { Config } from "../config/Config";
|
||||
import { Link } from "../button/Link";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
@@ -53,6 +55,7 @@ export const RoomPage: FC = () => {
|
||||
useClientLegacy();
|
||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||
|
||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||
const muteStates = useMuteStates();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,82 +85,112 @@ export const RoomPage: FC = () => {
|
||||
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
|
||||
}, [optInAnalytics, setOptInAnalytics]);
|
||||
|
||||
const groupCallView = useCallback(
|
||||
(groupCallState: GroupCallStatus): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
const wasInWaitForInviteState = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
|
||||
logger.log("Play join sound 'Not yet implemented'");
|
||||
}
|
||||
}, [groupCallState.kind]);
|
||||
|
||||
const groupCallView = (): JSX.Element => {
|
||||
switch (groupCallState.kind) {
|
||||
case "loaded":
|
||||
return (
|
||||
<GroupCallView
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
isPasswordlessUser={passwordlessUser}
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
hideHeader={hideHeader}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
wasInWaitForInviteState.current =
|
||||
wasInWaitForInviteState.current ||
|
||||
groupCallState.kind === "waitForInvite";
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
case "waitForInvite":
|
||||
case "canKnock": {
|
||||
const knock =
|
||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
||||
const label: string | JSX.Element =
|
||||
groupCallState.kind === "canKnock" ? (
|
||||
t("lobby.ask_to_join")
|
||||
) : (
|
||||
<>
|
||||
{t("lobby.waiting_for_invite")}
|
||||
<CheckIcon />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary[
|
||||
"im.nheko.summary.encryption"
|
||||
]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
return (
|
||||
<LobbyView
|
||||
client={client!}
|
||||
matrixInfo={{
|
||||
userId: client!.getUserId() ?? "",
|
||||
displayName: userDisplayName ?? "",
|
||||
avatarUrl: avatarUrl ?? "",
|
||||
roomAlias: null,
|
||||
roomId: groupCallState.roomSummary.room_id,
|
||||
roomName: groupCallState.roomSummary.name ?? "",
|
||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
||||
e2eeSystem: {
|
||||
kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
|
||||
? E2eeType.PER_PARTICIPANT
|
||||
: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
onEnter={(): void => knock?.()}
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
passwordlessUser,
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
muteStates,
|
||||
t,
|
||||
userDisplayName,
|
||||
avatarUrl,
|
||||
],
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("common.loading")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
case "failed":
|
||||
wasInWaitForInviteState.current = false;
|
||||
if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{t("group_call_loader.failed_heading")}</Heading>
|
||||
<Text>{t("group_call_loader.failed_text")}</Text>
|
||||
{/* XXX: A 'create it for me' button would be the obvious UX here. Two screens already have
|
||||
dupes of this flow, let's make a common component and put it here. */}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else if (groupCallState.error instanceof CallTerminatedMessage) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<Heading>{groupCallState.error.message}</Heading>
|
||||
<Text>{groupCallState.error.messageBody}</Text>
|
||||
{groupCallState.error.reason && (
|
||||
<>
|
||||
{t("group_call_loader.reason")}:
|
||||
<Text size="sm">"{groupCallState.error.reason}"</Text>
|
||||
</>
|
||||
)}
|
||||
<Link to="/">{t("common.home")}</Link>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorView error={groupCallState.error} />;
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
}
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
if (loading || isRegistering) {
|
||||
@@ -170,15 +203,7 @@ export const RoomPage: FC = () => {
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
content = <HomePage />;
|
||||
} else {
|
||||
content = (
|
||||
<GroupCallLoader
|
||||
client={client}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
viaServers={viaServers}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallLoader>
|
||||
);
|
||||
content = groupCallView();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -117,8 +117,8 @@ export class CallTerminatedMessage extends Error {
|
||||
}
|
||||
|
||||
export const useLoadGroupCall = (
|
||||
client: MatrixClient,
|
||||
roomIdOrAlias: string,
|
||||
client: MatrixClient | undefined,
|
||||
roomIdOrAlias: string | null,
|
||||
viaServers: string[],
|
||||
): GroupCallStatus => {
|
||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||
@@ -159,6 +159,9 @@ export const useLoadGroupCall = (
|
||||
?.getContent().reason;
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !roomIdOrAlias) {
|
||||
return;
|
||||
}
|
||||
const getRoomByAlias = async (alias: string): Promise<Room> => {
|
||||
// We lowercase the localpart when we create the room, so we must lowercase
|
||||
// it here too (we just do the whole alias). We can't do the same to room IDs
|
||||
|
||||
@@ -98,8 +98,9 @@ export async function enterRTCSession(
|
||||
|
||||
// right now we assume everything is a room-scoped call
|
||||
const livekitAlias = rtcSession.room.roomId;
|
||||
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||
const useDeviceSessionMemberEvents =
|
||||
Config.get().features?.feature_use_device_session_member_events;
|
||||
features?.feature_use_device_session_member_events;
|
||||
rtcSession.joinRoomSession(
|
||||
await makePreferredLivekitFoci(rtcSession, livekitAlias),
|
||||
makeActiveFocus(),
|
||||
@@ -108,6 +109,11 @@ export async function enterRTCSession(
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
membershipServerSideExpiryTimeout:
|
||||
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
|
||||
membershipKeepAlivePeriod:
|
||||
matrixRtcSessionConfig?.membership_keep_alive_period,
|
||||
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ Please see LICENSE in the repository root for full details.
|
||||
// purge on startup to prevent logs from accumulating.
|
||||
|
||||
import EventEmitter from "events";
|
||||
import { throttle } from "lodash";
|
||||
import { throttle } from "lodash-es";
|
||||
import { Logger, logger } from "matrix-js-sdk/src/logger";
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import loglevel, { LoggingMethod } from "loglevel";
|
||||
|
||||
@@ -102,7 +102,7 @@ export const playReactionsSound = new Setting<boolean>(
|
||||
|
||||
export const soundEffectVolumeSetting = new Setting<number>(
|
||||
"sound-effect-volume",
|
||||
1,
|
||||
0.5,
|
||||
);
|
||||
|
||||
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -23,7 +23,7 @@ import {
|
||||
RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
import { isEqual } from "lodash";
|
||||
import { isEqual } from "lodash-es";
|
||||
|
||||
import { CallViewModel, Layout } from "./CallViewModel";
|
||||
import {
|
||||
|
||||
@@ -672,16 +672,6 @@ export class CallViewModel extends ViewModel {
|
||||
this.gridModeUserSelection.next(value);
|
||||
}
|
||||
|
||||
private readonly oneOnOne: Observable<boolean> = combineLatest(
|
||||
[this.grid, this.screenShares],
|
||||
(grid, screenShares) =>
|
||||
grid.length == 2 &&
|
||||
// There might not be a remote tile if only the local user is in the call
|
||||
// and they're using the duplicate tiles option
|
||||
grid.some((vm) => !vm.local) &&
|
||||
screenShares.length === 0,
|
||||
);
|
||||
|
||||
private readonly gridLayout: Observable<LayoutMedia> = combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) => ({
|
||||
@@ -714,13 +704,22 @@ export class CallViewModel extends ViewModel {
|
||||
pip: pip ?? undefined,
|
||||
}));
|
||||
|
||||
private readonly oneOnOneLayout: Observable<LayoutMedia> =
|
||||
private readonly oneOnOneLayout: Observable<LayoutMedia | null> =
|
||||
this.mediaItems.pipe(
|
||||
map((grid) => ({
|
||||
type: "one-on-one",
|
||||
local: grid.find((vm) => vm.vm.local)!.vm as LocalUserMediaViewModel,
|
||||
remote: grid.find((vm) => !vm.vm.local)!.vm as RemoteUserMediaViewModel,
|
||||
})),
|
||||
map((mediaItems) => {
|
||||
if (mediaItems.length !== 2) return null;
|
||||
const local = mediaItems.find((vm) => vm.vm.local)!
|
||||
.vm as LocalUserMediaViewModel;
|
||||
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
|
||||
| RemoteUserMediaViewModel
|
||||
| undefined;
|
||||
// There might not be a remote tile if there are screen shares, or if
|
||||
// only the local user is in the call and they're using the duplicate
|
||||
// tiles option
|
||||
if (remote === undefined) return null;
|
||||
|
||||
return { type: "one-on-one", local, remote };
|
||||
}),
|
||||
);
|
||||
|
||||
private readonly pipLayout: Observable<LayoutMedia> = this.spotlight.pipe(
|
||||
@@ -738,9 +737,9 @@ export class CallViewModel extends ViewModel {
|
||||
switchMap((gridMode) => {
|
||||
switch (gridMode) {
|
||||
case "grid":
|
||||
return this.oneOnOne.pipe(
|
||||
return this.oneOnOneLayout.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne ? this.oneOnOneLayout : this.gridLayout,
|
||||
oneOnOne === null ? this.gridLayout : of(oneOnOne),
|
||||
),
|
||||
);
|
||||
case "spotlight":
|
||||
@@ -755,20 +754,20 @@ export class CallViewModel extends ViewModel {
|
||||
}),
|
||||
);
|
||||
case "narrow":
|
||||
return this.oneOnOne.pipe(
|
||||
return this.oneOnOneLayout.pipe(
|
||||
switchMap((oneOnOne) =>
|
||||
oneOnOne
|
||||
? // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
this.spotlightExpandedLayout
|
||||
: combineLatest(
|
||||
oneOnOne === null
|
||||
? combineLatest(
|
||||
[this.grid, this.spotlight],
|
||||
(grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
? this.spotlightPortraitLayout
|
||||
: this.gridLayout,
|
||||
).pipe(switchAll()),
|
||||
).pipe(switchAll())
|
||||
: // The expanded spotlight layout makes for a better one-on-one
|
||||
// experience in narrow windows
|
||||
this.spotlightExpandedLayout,
|
||||
),
|
||||
);
|
||||
case "flat":
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
VisibilityOnIcon,
|
||||
UserProfileIcon,
|
||||
ExpandIcon,
|
||||
VolumeOffSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -62,6 +63,7 @@ interface TileProps {
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
locallyMuted: boolean;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
@@ -71,6 +73,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
{
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
@@ -96,7 +99,16 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
);
|
||||
const { raisedHands, lowerHand, reactions } = useReactions();
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
: t("microphone_off");
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
@@ -134,11 +146,11 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
[styles.handRaised]: !showSpeaking && !!handRaised,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<MicIcon
|
||||
<AudioIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
|
||||
data-muted={!audioEnabled}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
@@ -199,6 +211,7 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
@@ -255,6 +268,7 @@ const RemoteUserMediaTile = forwardRef<
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={locallyMuted}
|
||||
mirror={false}
|
||||
menuStart={
|
||||
<>
|
||||
|
||||
@@ -195,11 +195,12 @@ export const ReactionsProvider = ({
|
||||
// Skip any event without a sender or event ID.
|
||||
if (!sender || !reactionEventId) return;
|
||||
|
||||
room.client
|
||||
.decryptEventIfNeeded(event)
|
||||
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;
|
||||
|
||||
if (event.getType() === ElementCallReactionEventType) {
|
||||
room.client
|
||||
.decryptEventIfNeeded(event)
|
||||
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
|
||||
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;
|
||||
const content: ECallReactionEventContent = event.getContent();
|
||||
|
||||
const membershipEventId = content?.["m.relates_to"]?.event_id;
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function initClient(
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
store: new MemoryStore(),
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
import htmlTemplate from "vite-plugin-html-template";
|
||||
import { codecovVitePlugin } from "@codecov/vite-plugin";
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import basicSsl from "@vitejs/plugin-basic-ssl";
|
||||
@@ -31,6 +32,12 @@ export default defineConfig(({ mode }) => {
|
||||
title: env.VITE_PRODUCT_NAME || "Element Call",
|
||||
},
|
||||
}),
|
||||
|
||||
codecovVitePlugin({
|
||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||
bundleName: "element-call",
|
||||
uploadToken: process.env.CODECOV_TOKEN,
|
||||
}),
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user