Merge remote-tracking branch 'origin/livekit' into hs/new-reactions-design

This commit is contained in:
Half-Shot
2024-11-14 12:05:47 +00:00
42 changed files with 881 additions and 725 deletions

View File

@@ -23,3 +23,4 @@ jobs:
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }} SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -14,6 +14,8 @@ on:
required: true required: true
SENTRY_AUTH_TOKEN: SENTRY_AUTH_TOKEN:
required: true required: true
CODECOV_TOKEN:
required: false
jobs: jobs:
build: build:
@@ -36,6 +38,7 @@ jobs:
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }} SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
VITE_APP_VERSION: ${{ inputs.vite_app_version }} VITE_APP_VERSION: ${{ inputs.vite_app_version }}
NODE_OPTIONS: "--max-old-space-size=4096" NODE_OPTIONS: "--max-old-space-size=4096"
- name: Upload Artifact - name: Upload Artifact

View File

@@ -106,7 +106,7 @@ rc_message:
MSC3266 allows to request a room summary of rooms you are not joined. The 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 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. join view.
Element Call requires a Livekit SFU alongside a [Livekit JWT Element Call requires a Livekit SFU alongside a [Livekit JWT

View File

@@ -24,6 +24,9 @@
"@babel/preset-env": "^7.22.20", "@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15", "@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0", "@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-durationformat": "^0.6.1",
"@formatjs/intl-segmenter": "^11.7.3", "@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.11.0", "@livekit/components-core": "^0.11.0",
@@ -48,7 +51,7 @@
"@types/content-type": "^1.1.5", "@types/content-type": "^1.1.5",
"@types/grecaptcha": "^3.0.9", "@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7", "@types/jsdom": "^21.1.7",
"@types/lodash": "^4.14.199", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
@@ -59,7 +62,7 @@
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@use-gesture/react": "^10.2.11", "@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.9.1", "@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-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1", "@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
@@ -85,10 +88,10 @@
"jsdom": "^25.0.0", "jsdom": "^25.0.0",
"knip": "^5.27.2", "knip": "^5.27.2",
"livekit-client": "^2.5.7", "livekit-client": "^2.5.7",
"lodash": "^4.17.21", "lodash-es": "^4.17.21",
"loglevel": "^1.9.1", "loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6971e7bebaad643c233e5057da7a0d42441c0789", "matrix-js-sdk": "matrix-org/matrix-js-sdk#8e9a04cdec0f88fc876bbbf406db55b0677f005d",
"matrix-widget-api": "^1.8.2", "matrix-widget-api": "^1.10.0",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3", "observable-hooks": "^4.2.3",
"pako": "^2.0.4", "pako": "^2.0.4",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -81,8 +81,8 @@
"call_ended_heading": "Anruf beendet", "call_ended_heading": "Anruf beendet",
"failed_heading": "Beitreten fehlgeschlagen", "failed_heading": "Beitreten fehlgeschlagen",
"failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt", "failed_text": "Anruf nicht gefunden oder Beitritt nicht erlaubt",
"knock_reject_body": "Die Mitglieder des Raums haben Deine Beitrittsanfrage abgelehnt.", "knock_reject_body": "Die Teilnahmeanfrage wurde abgelehnt.",
"knock_reject_heading": "Beitritt nicht erlaubt", "knock_reject_heading": "Zugriff verweigert",
"reason": "Grund" "reason": "Grund"
}, },
"hangup_button_label": "Anruf beenden", "hangup_button_label": "Anruf beenden",
@@ -100,11 +100,11 @@
"layout_grid_label": "Raster", "layout_grid_label": "Raster",
"layout_spotlight_label": "Fokus", "layout_spotlight_label": "Fokus",
"lobby": { "lobby": {
"ask_to_join": "Beitritt anfragen", "ask_to_join": "Teilnahmeanfrage senden",
"join_as_guest": "Als Gast beitreten", "join_as_guest": "Als Gast beitreten",
"join_button": "Anruf beitreten", "join_button": "Anruf beitreten",
"leave_button": "Zurück zu kürzlichen Anrufen", "leave_button": "Zurück zu kürzlichen Anrufen",
"waiting_for_invite": "Anfrage gesendet" "waiting_for_invite": "Anfrage gesendet! Warten auf Teilnahmefreigabe …"
}, },
"log_in": "Anmelden", "log_in": "Anmelden",
"logging_in": "Anmelden …", "logging_in": "Anmelden …",
@@ -115,7 +115,7 @@
"matrix_id": "Matrix-ID: {{id}}", "matrix_id": "Matrix-ID: {{id}}",
"microphone_off": "Mikrofon aus", "microphone_off": "Mikrofon aus",
"microphone_on": "Mikrofon an", "microphone_on": "Mikrofon an",
"mute_microphone_button_label": "Mikrofon deaktivieren", "mute_microphone_button_label": "Mikrofon stumm schalten",
"participant_count_one": "{{count, number}}", "participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}", "participant_count_other": "{{count, number}}",
"qr_code": "QR-Code", "qr_code": "QR-Code",
@@ -184,14 +184,15 @@
"unauthenticated_view_body": "Noch nicht registriert? <2>Konto erstellen</2>", "unauthenticated_view_body": "Noch nicht registriert? <2>Konto erstellen</2>",
"unauthenticated_view_eula_caption": "Mit einem Klick auf „Los gehts“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>", "unauthenticated_view_eula_caption": "Mit einem Klick auf „Los gehts“ akzeptierst du unseren <2>Endbenutzer-Lizenzvertrag (EULA)</2>",
"unauthenticated_view_login_button": "Melde dich mit deinem Konto an", "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}}", "version": "{{productName}} Version: {{version}}",
"video_tile": { "video_tile": {
"always_show": "Immer anzeigen", "always_show": "Immer anzeigen",
"change_fit_contain": "An Fenster anpassen", "change_fit_contain": "An Fenster anpassen",
"collapse": "Minimieren", "collapse": "Minimieren",
"expand": "Erweitern", "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" "volume": "Lautstärke"
} }
} }

View File

@@ -80,8 +80,8 @@
"call_ended_heading": "Call ended", "call_ended_heading": "Call ended",
"failed_heading": "Failed to join", "failed_heading": "Failed to join",
"failed_text": "Call not found or is not accessible.", "failed_text": "Call not found or is not accessible.",
"knock_reject_body": "The room members declined your request to join.", "knock_reject_body": "Your request to join was declined.",
"knock_reject_heading": "Not allowed to join", "knock_reject_heading": "Access denied",
"reason": "Reason" "reason": "Reason"
}, },
"hangup_button_label": "End call", "hangup_button_label": "End call",
@@ -99,11 +99,11 @@
"layout_grid_label": "Grid", "layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight", "layout_spotlight_label": "Spotlight",
"lobby": { "lobby": {
"ask_to_join": "Ask to join call", "ask_to_join": "Request to join call",
"join_as_guest": "Join as guest", "join_as_guest": "Join as guest",
"join_button": "Join call", "join_button": "Join call",
"leave_button": "Back to recents", "leave_button": "Back to recents",
"waiting_for_invite": "Request sent" "waiting_for_invite": "Request sent! Waiting for permission to join…"
}, },
"log_in": "Log In", "log_in": "Log In",
"logging_in": "Logging in…", "logging_in": "Logging in…",
@@ -190,6 +190,7 @@
"collapse": "Collapse", "collapse": "Collapse",
"expand": "Expand", "expand": "Expand",
"mute_for_me": "Mute for me", "mute_for_me": "Mute for me",
"muted_for_me": "Muted for me",
"volume": "Volume" "volume": "Volume"
} }
} }

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { FC, useCallback } from "react"; import { FC, useCallback } from "react";
import { Root, Track, Range, Thumb } from "@radix-ui/react-slider"; import { Root, Track, Range, Thumb } from "@radix-ui/react-slider";
import classNames from "classnames"; import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import styles from "./Slider.module.css"; import styles from "./Slider.module.css";
@@ -66,7 +67,10 @@ export const Slider: FC<Props> = ({
<Track className={styles.track}> <Track className={styles.track}>
<Range className={styles.highlight} /> <Range className={styles.highlight} />
</Track> </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> </Root>
); );
}; };

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { merge } from "lodash"; import { merge } from "lodash-es";
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { import {

View File

@@ -96,6 +96,30 @@ export interface ConfigOptions {
* Note that this can additionally be disabled by the app's URL parameters. * Note that this can additionally be disabled by the app's URL parameters.
*/ */
app_prompt?: boolean; 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 // Overrides members from ConfigOptions that are always provided by the

View File

@@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. 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; @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("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") @import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
layer(compound); layer(compound);
@@ -52,94 +53,6 @@ layer(compound);
--stopgap-background-85: rgba(16, 19, 23, 0.85); --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 { body {
background-color: var(--cpd-color-bg-canvas-default); background-color: var(--cpd-color-bg-canvas-default);
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh)); 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; --cpd-font-family-sans: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
} }
body[data-platform="desktop"] {
--cpd-font-family-sans: "Inter", sans-serif;
}
@layer compound-legacy { @layer compound-legacy {
h1, h1,
h2, h2,

View File

@@ -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} />;
}
}
}

View File

@@ -177,29 +177,37 @@ export const GroupCallView: FC<Props> = ({
} }
}; };
if (widget && preload && skipLobby) { if (skipLobby) {
// In preload mode without lobby we wait for a join action before entering if (widget) {
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => { if (preload) {
(async (): Promise<void> => { // In preload mode without lobby we wait for a join action before entering
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
await enterRTCSession(rtcSession, perParticipantE2EE); (async (): Promise<void> => {
widget!.api.transport.reply(ev.detail, {}); await defaultDeviceSetup(
})().catch((e) => { ev.detail.data as unknown as JoinCallData,
logger.error("Error joining RTC session", e); );
}); await enterRTCSession(rtcSession, perParticipantE2EE);
}; widget!.api.transport.reply(ev.detail, {});
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); })().catch((e) => {
return (): void => { logger.error("Error joining RTC session", e);
widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); });
}; };
} else if (widget && !preload && skipLobby) { widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
// No lobby and no preload: we enter the rtc session right away return (): void => {
(async (): Promise<void> => { widget!.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
await defaultDeviceSetup({ audioInput: null, videoInput: null }); };
await enterRTCSession(rtcSession, perParticipantE2EE); } else {
})().catch((e) => { // No lobby and no preload: we enter the rtc session right away
logger.error("Error joining RTC session", e); (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]); }, [rtcSession, preload, skipLobby, perParticipantE2EE]);

View File

@@ -5,15 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. 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 { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; 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 { useClientLegacy } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView"; import { ErrorView, FullScreenView, LoadingView } from "../FullScreenView";
import { RoomAuthView } from "./RoomAuthView"; import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView"; import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams"; import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
@@ -21,13 +22,14 @@ import { HomePage } from "../home/HomePage";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal"; import { AppSelectionModal } from "./AppSelectionModal";
import { widget } from "../widget"; import { widget } from "../widget";
import { GroupCallStatus } from "./useLoadGroupCall"; import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView"; import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates"; import { useMuteStates } from "./MuteStates";
import { useOptInAnalytics } from "../settings/settings"; import { useOptInAnalytics } from "../settings/settings";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { Link } from "../button/Link";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { const {
@@ -53,6 +55,7 @@ export const RoomPage: FC = () => {
useClientLegacy(); useClientLegacy();
const { avatarUrl, displayName: userDisplayName } = useProfile(client); const { avatarUrl, displayName: userDisplayName } = useProfile(client);
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
const muteStates = useMuteStates(); const muteStates = useMuteStates();
useEffect(() => { useEffect(() => {
@@ -82,82 +85,112 @@ export const RoomPage: FC = () => {
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true); if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]); }, [optInAnalytics, setOptInAnalytics]);
const groupCallView = useCallback( const wasInWaitForInviteState = useRef<boolean>(false);
(groupCallState: GroupCallStatus): JSX.Element => {
switch (groupCallState.kind) { useEffect(() => {
case "loaded": if (groupCallState.kind === "loaded" && wasInWaitForInviteState.current) {
return ( logger.log("Play join sound 'Not yet implemented'");
<GroupCallView }
client={client!} }, [groupCallState.kind]);
rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser} const groupCallView = (): JSX.Element => {
confineToRoom={confineToRoom} switch (groupCallState.kind) {
preload={preload} case "loaded":
skipLobby={skipLobby} return (
hideHeader={hideHeader} <GroupCallView
muteStates={muteStates} 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": return (
case "canKnock": { <LobbyView
const knock = client={client!}
groupCallState.kind === "canKnock" ? groupCallState.knock : null; matrixInfo={{
const label: string | JSX.Element = userId: client!.getUserId() ?? "",
groupCallState.kind === "canKnock" ? ( displayName: userDisplayName ?? "",
t("lobby.ask_to_join") avatarUrl: avatarUrl ?? "",
) : ( roomAlias: null,
<> roomId: groupCallState.roomSummary.room_id,
{t("lobby.waiting_for_invite")} roomName: groupCallState.roomSummary.name ?? "",
<CheckIcon /> roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
</> e2eeSystem: {
); kind: groupCallState.roomSummary["im.nheko.summary.encryption"]
return ( ? E2eeType.PER_PARTICIPANT
<LobbyView : E2eeType.NONE,
client={client!} },
matrixInfo={{ }}
userId: client!.getUserId() ?? "", onEnter={(): void => knock?.()}
displayName: userDisplayName ?? "", enterLabel={label}
avatarUrl: avatarUrl ?? "", waitingForInvite={groupCallState.kind === "waitForInvite"}
roomAlias: null, confineToRoom={confineToRoom}
roomId: groupCallState.roomSummary.room_id, hideHeader={hideHeader}
roomName: groupCallState.roomSummary.name ?? "", participantCount={null}
roomAvatar: groupCallState.roomSummary.avatar_url ?? null, muteStates={muteStates}
e2eeSystem: { onShareClick={null}
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 <> </>;
} }
}, case "loading":
[ return (
client, <FullScreenView>
passwordlessUser, <h1>{t("common.loading")}</h1>
confineToRoom, </FullScreenView>
preload, );
skipLobby, case "failed":
hideHeader, wasInWaitForInviteState.current = false;
muteStates, if ((groupCallState.error as MatrixError).errcode === "M_NOT_FOUND") {
t, return (
userDisplayName, <FullScreenView>
avatarUrl, <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; let content: ReactNode;
if (loading || isRegistering) { if (loading || isRegistering) {
@@ -170,15 +203,7 @@ export const RoomPage: FC = () => {
// TODO: This doesn't belong here, the app routes need to be reworked // TODO: This doesn't belong here, the app routes need to be reworked
content = <HomePage />; content = <HomePage />;
} else { } else {
content = ( content = groupCallView();
<GroupCallLoader
client={client}
roomIdOrAlias={roomIdOrAlias}
viaServers={viaServers}
>
{groupCallView}
</GroupCallLoader>
);
} }
return ( return (

View File

@@ -117,8 +117,8 @@ export class CallTerminatedMessage extends Error {
} }
export const useLoadGroupCall = ( export const useLoadGroupCall = (
client: MatrixClient, client: MatrixClient | undefined,
roomIdOrAlias: string, roomIdOrAlias: string | null,
viaServers: string[], viaServers: string[],
): GroupCallStatus => { ): GroupCallStatus => {
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" }); const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
@@ -159,6 +159,9 @@ export const useLoadGroupCall = (
?.getContent().reason; ?.getContent().reason;
useEffect(() => { useEffect(() => {
if (!client || !roomIdOrAlias) {
return;
}
const getRoomByAlias = async (alias: string): Promise<Room> => { const getRoomByAlias = async (alias: string): Promise<Room> => {
// We lowercase the localpart when we create the room, so we must lowercase // 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 // it here too (we just do the whole alias). We can't do the same to room IDs

View File

@@ -98,8 +98,9 @@ export async function enterRTCSession(
// right now we assume everything is a room-scoped call // right now we assume everything is a room-scoped call
const livekitAlias = rtcSession.room.roomId; const livekitAlias = rtcSession.room.roomId;
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents = const useDeviceSessionMemberEvents =
Config.get().features?.feature_use_device_session_member_events; features?.feature_use_device_session_member_events;
rtcSession.joinRoomSession( rtcSession.joinRoomSession(
await makePreferredLivekitFoci(rtcSession, livekitAlias), await makePreferredLivekitFoci(rtcSession, livekitAlias),
makeActiveFocus(), makeActiveFocus(),
@@ -108,6 +109,11 @@ export async function enterRTCSession(
...(useDeviceSessionMemberEvents !== undefined && { ...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents, useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}), }),
membershipServerSideExpiryTimeout:
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
membershipKeepAlivePeriod:
matrixRtcSessionConfig?.membership_keep_alive_period,
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
}, },
); );
} }

View File

@@ -28,7 +28,7 @@ Please see LICENSE in the repository root for full details.
// purge on startup to prevent logs from accumulating. // purge on startup to prevent logs from accumulating.
import EventEmitter from "events"; import EventEmitter from "events";
import { throttle } from "lodash"; import { throttle } from "lodash-es";
import { Logger, logger } from "matrix-js-sdk/src/logger"; import { Logger, logger } from "matrix-js-sdk/src/logger";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import loglevel, { LoggingMethod } from "loglevel"; import loglevel, { LoggingMethod } from "loglevel";

View File

@@ -102,7 +102,7 @@ export const playReactionsSound = new Setting<boolean>(
export const soundEffectVolumeSetting = new Setting<number>( export const soundEffectVolumeSetting = new Setting<number>(
"sound-effect-volume", "sound-effect-volume",
1, 0.5,
); );
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true); export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);

Binary file not shown.

Binary file not shown.

View File

@@ -23,7 +23,7 @@ import {
RemoteParticipant, RemoteParticipant,
} from "livekit-client"; } from "livekit-client";
import * as ComponentsCore from "@livekit/components-core"; import * as ComponentsCore from "@livekit/components-core";
import { isEqual } from "lodash"; import { isEqual } from "lodash-es";
import { CallViewModel, Layout } from "./CallViewModel"; import { CallViewModel, Layout } from "./CallViewModel";
import { import {

View File

@@ -672,16 +672,6 @@ export class CallViewModel extends ViewModel {
this.gridModeUserSelection.next(value); 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( private readonly gridLayout: Observable<LayoutMedia> = combineLatest(
[this.grid, this.spotlight], [this.grid, this.spotlight],
(grid, spotlight) => ({ (grid, spotlight) => ({
@@ -714,13 +704,22 @@ export class CallViewModel extends ViewModel {
pip: pip ?? undefined, pip: pip ?? undefined,
})); }));
private readonly oneOnOneLayout: Observable<LayoutMedia> = private readonly oneOnOneLayout: Observable<LayoutMedia | null> =
this.mediaItems.pipe( this.mediaItems.pipe(
map((grid) => ({ map((mediaItems) => {
type: "one-on-one", if (mediaItems.length !== 2) return null;
local: grid.find((vm) => vm.vm.local)!.vm as LocalUserMediaViewModel, const local = mediaItems.find((vm) => vm.vm.local)!
remote: grid.find((vm) => !vm.vm.local)!.vm as RemoteUserMediaViewModel, .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( private readonly pipLayout: Observable<LayoutMedia> = this.spotlight.pipe(
@@ -738,9 +737,9 @@ export class CallViewModel extends ViewModel {
switchMap((gridMode) => { switchMap((gridMode) => {
switch (gridMode) { switch (gridMode) {
case "grid": case "grid":
return this.oneOnOne.pipe( return this.oneOnOneLayout.pipe(
switchMap((oneOnOne) => switchMap((oneOnOne) =>
oneOnOne ? this.oneOnOneLayout : this.gridLayout, oneOnOne === null ? this.gridLayout : of(oneOnOne),
), ),
); );
case "spotlight": case "spotlight":
@@ -755,20 +754,20 @@ export class CallViewModel extends ViewModel {
}), }),
); );
case "narrow": case "narrow":
return this.oneOnOne.pipe( return this.oneOnOneLayout.pipe(
switchMap((oneOnOne) => switchMap((oneOnOne) =>
oneOnOne oneOnOne === null
? // The expanded spotlight layout makes for a better one-on-one ? combineLatest(
// experience in narrow windows
this.spotlightExpandedLayout
: combineLatest(
[this.grid, this.spotlight], [this.grid, this.spotlight],
(grid, spotlight) => (grid, spotlight) =>
grid.length > smallMobileCallThreshold || grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm instanceof ScreenShareViewModel) spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? this.spotlightPortraitLayout ? this.spotlightPortraitLayout
: this.gridLayout, : 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": case "flat":

View File

@@ -26,6 +26,7 @@ import {
VisibilityOnIcon, VisibilityOnIcon,
UserProfileIcon, UserProfileIcon,
ExpandIcon, ExpandIcon,
VolumeOffSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { import {
ContextMenu, ContextMenu,
@@ -62,6 +63,7 @@ interface TileProps {
interface UserMediaTileProps extends TileProps { interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel; vm: UserMediaViewModel;
mirror: boolean; mirror: boolean;
locallyMuted: boolean;
menuStart?: ReactNode; menuStart?: ReactNode;
menuEnd?: ReactNode; menuEnd?: ReactNode;
} }
@@ -71,6 +73,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
{ {
vm, vm,
showSpeakingIndicators, showSpeakingIndicators,
locallyMuted,
menuStart, menuStart,
menuEnd, menuEnd,
className, className,
@@ -96,7 +99,16 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
); );
const { raisedHands, lowerHand, reactions } = useReactions(); 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 [menuOpen, setMenuOpen] = useState(false);
const menu = ( const menu = (
@@ -134,11 +146,11 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
[styles.handRaised]: !showSpeaking && !!handRaised, [styles.handRaised]: !showSpeaking && !!handRaised,
})} })}
nameTagLeadingIcon={ nameTagLeadingIcon={
<MicIcon <AudioIcon
width={20} width={20}
height={20} height={20}
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")} aria-label={audioIconLabel}
data-muted={!audioEnabled} data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon} className={styles.muteIcon}
/> />
} }
@@ -199,6 +211,7 @@ const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
locallyMuted={false}
mirror={mirror} mirror={mirror}
menuStart={ menuStart={
<ToggleMenuItem <ToggleMenuItem
@@ -255,6 +268,7 @@ const RemoteUserMediaTile = forwardRef<
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
locallyMuted={locallyMuted}
mirror={false} mirror={false}
menuStart={ menuStart={
<> <>

View File

@@ -195,11 +195,12 @@ export const ReactionsProvider = ({
// Skip any event without a sender or event ID. // Skip any event without a sender or event ID.
if (!sender || !reactionEventId) return; 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) { 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 content: ECallReactionEventContent = event.getContent();
const membershipEventId = content?.["m.relates_to"]?.event_id; const membershipEventId = content?.["m.relates_to"]?.event_id;

View File

@@ -123,6 +123,7 @@ export async function initClient(
localTimeoutMs: 5000, localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled, useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed, 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: // In case of logging in a new matrix account but there is still crypto local store. This is needed for:

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { defineConfig, loadEnv } from "vite"; import { defineConfig, loadEnv } from "vite";
import svgrPlugin from "vite-plugin-svgr"; import svgrPlugin from "vite-plugin-svgr";
import htmlTemplate from "vite-plugin-html-template"; import htmlTemplate from "vite-plugin-html-template";
import { codecovVitePlugin } from "@codecov/vite-plugin";
import { sentryVitePlugin } from "@sentry/vite-plugin"; import { sentryVitePlugin } from "@sentry/vite-plugin";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import basicSsl from "@vitejs/plugin-basic-ssl"; import basicSsl from "@vitejs/plugin-basic-ssl";
@@ -31,6 +32,12 @@ export default defineConfig(({ mode }) => {
title: env.VITE_PRODUCT_NAME || "Element Call", title: env.VITE_PRODUCT_NAME || "Element Call",
}, },
}), }),
codecovVitePlugin({
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
bundleName: "element-call",
uploadToken: process.env.CODECOV_TOKEN,
}),
]; ];
if ( if (

987
yarn.lock

File diff suppressed because it is too large Load Diff