merge: upstream Element Call v0.28.0 (cinny #201) #61

Merged
jared merged 197 commits from lotus-upstream-v0.28.0 into lotus 2026-10-24 01:14:32 -04:00
4 changed files with 171 additions and 99 deletions
Showing only changes of commit a166fbbd08 - Show all commits
+4 -8
View File
@@ -10,7 +10,7 @@ import { type MatrixClient } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { GroupCallView } from "./room/GroupCallView";
import { type MuteStates } from "./state/MuteStates";
import { useMuteStates } from "./state/useMuteStates";
import { type UrlParams } from "./UrlParams";
interface Props {
@@ -18,8 +18,6 @@ interface Props {
client: MatrixClient;
/** The call to join. */
rtcSession: MatrixRTCSession;
/** The audio and video mute state to start from, and keep in step with. */
muteStates: MuteStates;
/**
* Whether the user is signed in as a guest, and so should be offered the
* chance to create an account when the call ends.
@@ -40,15 +38,10 @@ interface Props {
* showing one: no routing, no authentication, no resolving of room aliases.
* Those belong to whatever is hosting it — the standalone app's own shell, or
* an application embedding Element Call directly.
*
* TODO: `muteStates` is still passed in, because the standalone shell shares
* one with the lobby it shows while waiting to be let into a room. Ownership
* moves here once that lobby has its own.
*/
export const ElementCallView: FC<Props> = ({
client,
rtcSession,
muteStates,
isPasswordlessUser,
confineToRoom,
preload,
@@ -56,6 +49,9 @@ export const ElementCallView: FC<Props> = ({
}): ReactNode => {
// Whether the user is in the call is the call's own business, not its host's.
const [joined, setJoined] = useState(false);
const muteStates = useMuteStates();
if (muteStates === null) return null;
return (
<GroupCallView
+92
View File
@@ -0,0 +1,92 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type JSX, type ReactNode } from "react";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useMuteStates } from "../state/useMuteStates";
interface Props {
client: MatrixClient;
/** What we know about the room from peeking at it. */
roomSummary: RoomSummary;
/** The user's own name and avatar, to show in their own tile. */
profile: { displayName: string; avatarUrl: string };
/**
* Asks to be let in, if the room allows it. Absent once we have asked and
* are waiting for an answer.
*/
knock: (() => void) | null;
confineToRoom: boolean;
hideHeader: boolean;
}
/**
* The lobby shown while the user is outside a room they want to call in —
* either able to ask to join, or waiting for someone to answer.
*
* This belongs to the app shell rather than to the call: it exists precisely
* because there is no call to be in yet. It keeps its own mute state, which is
* why it is a component rather than part of the page — so that the call's mute
* state and this one are never alive at the same time, reporting over each
* other to the host.
*/
export const KnockLobbyView: FC<Props> = ({
client,
roomSummary,
profile,
knock,
confineToRoom,
hideHeader,
}): ReactNode => {
const { t } = useTranslation();
const muteStates = useMuteStates();
if (muteStates === null) return null;
const waitingForInvite = knock === null;
const enterLabel: string | JSX.Element = waitingForInvite ? (
<>
{t("lobby.waiting_for_invite")}
<CheckIcon />
</>
) : (
t("lobby.ask_to_join")
);
return (
<LobbyView
client={client}
matrixInfo={{
userId: client.getUserId() ?? "",
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
roomAlias: null,
roomId: roomSummary.room_id,
roomName: roomSummary.name ?? "",
roomAvatar: roomSummary.avatar_url ?? null,
e2eeSystem: {
kind: roomSummary["im.nheko.summary.encryption"]
? E2eeType.PER_PARTICIPANT
: E2eeType.NONE,
},
}}
onEnter={(): void => knock?.()}
enterLabel={enterLabel}
waitingForInvite={waitingForInvite}
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={null}
muteStates={muteStates}
onShareClick={null}
/>
);
};
+24 -91
View File
@@ -6,21 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
useEffect,
useState,
type ReactNode,
useRef,
type JSX,
} from "react";
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
import { type MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Trans, useTranslation } from "react-i18next";
import {
CheckIcon,
UnknownSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
@@ -29,22 +19,15 @@ import { ElementCallView } from "../ElementCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
import { useHostBridge } from "../HostBridge.ts";
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { KnockLobbyView } from "./KnockLobbyView";
import { useProfile } from "../profile/useProfile";
import { useOptInAnalytics } from "../settings/settings";
import { Link } from "../button/Link";
import { ErrorView } from "../ErrorView";
import { useMediaDevices } from "../MediaDevicesContext";
import { MuteStates } from "../state/MuteStates";
import { ObservableScope } from "../state/ObservableScope";
import { calculateInitialMuteState } from "../state/initialMuteState.ts";
export const RoomPage: FC = (): ReactNode => {
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -63,26 +46,6 @@ export const RoomPage: FC = (): ReactNode => {
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
const devices = useMediaDevices();
const [muteStates, setMuteStates] = useState<MuteStates | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMuteStates(
new MuteStates(
scope,
devices,
calculateInitialMuteState(
urlParams.skipLobby,
urlParams.callIntent,
urlParams.isWidget,
),
hostBridge,
),
);
return (): void => scope.end();
}, [devices, urlParams, hostBridge]);
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user
@@ -123,64 +86,34 @@ export const RoomPage: FC = (): ReactNode => {
switch (groupCallState.kind) {
case "loaded":
return (
muteStates && (
<ElementCallView
client={client!}
rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser}
confineToRoom={confineToRoom}
preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current}
muteStates={muteStates}
/>
)
<ElementCallView
client={client!}
rtcSession={groupCallState.rtcSession}
isPasswordlessUser={passwordlessUser}
confineToRoom={confineToRoom}
preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current}
/>
);
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 />
</>
);
return (
muteStates && (
<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={header !== "standard"}
participantCount={null}
muteStates={muteStates}
onShareClick={null}
/>
)
<KnockLobbyView
client={client!}
roomSummary={groupCallState.roomSummary}
profile={{
displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "",
}}
knock={
groupCallState.kind === "canKnock" ? groupCallState.knock : null
}
confineToRoom={confineToRoom}
hideHeader={header !== "standard"}
/>
);
}
case "loading":
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useState } from "react";
import { MuteStates } from "./MuteStates";
import { ObservableScope } from "./ObservableScope";
import { calculateInitialMuteState } from "./initialMuteState";
import { useMediaDevices } from "../MediaDevicesContext";
import { useHostBridge } from "../HostBridge";
import { useUrlParams } from "../UrlParams";
/**
* Audio and video mute state, kept in step with the host.
*
* `null` until the media devices have been looked at, since what the user
* starts muted depends on what they have.
*
* Whoever shows the user their own camera owns one of these. Note there should
* only ever be one alive at a time: each reports the user's mute state to the
* host, so two would have them talking over each other.
*/
export function useMuteStates(): MuteStates | null {
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const devices = useMediaDevices();
const [muteStates, setMuteStates] = useState<MuteStates | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMuteStates(
new MuteStates(
scope,
devices,
calculateInitialMuteState(
urlParams.skipLobby,
urlParams.callIntent,
urlParams.isWidget,
),
hostBridge,
),
);
return (): void => scope.end();
}, [devices, urlParams, hostBridge]);
return muteStates;
}