Signed-off-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Timo K
2024-04-05 16:51:27 +02:00
committed by Andrew Ferrazzutti
parent 7d58d37ebc
commit 7aafeee369
8 changed files with 153 additions and 38 deletions

View File

@@ -81,8 +81,10 @@
"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",
"join_button": "Join call", "join_button": "Join call",
"leave_button": "Back to recents" "leave_button": "Back to recents",
"waiting_for_invite": "Request sent"
}, },
"log_in": "Log In", "log_in": "Log In",
"logging_in": "Logging in…", "logging_in": "Logging in…",

View File

@@ -57,6 +57,7 @@ export function GroupCallLoader({
switch (groupCallState.kind) { switch (groupCallState.kind) {
case "loaded": case "loaded":
case "waitForInvite": case "waitForInvite":
case "canKnock":
return <>{children(groupCallState)}</>; return <>{children(groupCallState)}</>;
case "loading": case "loading":
return ( return (

View File

@@ -37,7 +37,7 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useProfile } from "../profile/useProfile"; import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../media-utils"; import { findDeviceByName } from "../media-utils";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
import { MuteStates, useMuteStates } from "./MuteStates"; import { MUTE_PARTICIPANT_COUNT, MuteStates } from "./MuteStates";
import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext"; import { useMediaDevices, MediaDevices } from "../livekit/MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers"; import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
@@ -64,6 +64,7 @@ interface Props {
skipLobby: boolean; skipLobby: boolean;
hideHeader: boolean; hideHeader: boolean;
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
muteStates: MuteStates;
} }
export const GroupCallView: FC<Props> = ({ export const GroupCallView: FC<Props> = ({
@@ -74,10 +75,23 @@ export const GroupCallView: FC<Props> = ({
skipLobby, skipLobby,
hideHeader, hideHeader,
rtcSession, rtcSession,
muteStates,
}) => { }) => {
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const isJoined = useMatrixRTCSessionJoinState(rtcSession); const isJoined = useMatrixRTCSessionJoinState(rtcSession);
// The mute state reactively gets updated once the participant count reaches the threshold.
// The user then still is able to unmute again.
// The more common case is that the user is muted from the start (participant count is already over the threshold).
const [autoMuteHappened, setAutoMuteHappened] = useState(false);
useEffect(() => {
if (autoMuteHappened) return;
if (memberships.length >= MUTE_PARTICIPANT_COUNT) {
muteStates.audio.setEnabled?.(false);
setAutoMuteHappened(true);
}
}, [autoMuteHappened, memberships, muteStates.audio]);
useEffect(() => { useEffect(() => {
window.rtcSession = rtcSession; window.rtcSession = rtcSession;
return () => { return () => {
@@ -122,7 +136,6 @@ export const GroupCallView: FC<Props> = ({
const latestDevices = useRef<MediaDevices>(); const latestDevices = useRef<MediaDevices>();
latestDevices.current = deviceContext; latestDevices.current = deviceContext;
const muteStates = useMuteStates(memberships.length);
const latestMuteStates = useRef<MuteStates>(); const latestMuteStates = useRef<MuteStates>();
latestMuteStates.current = muteStates; latestMuteStates.current = muteStates;

View File

@@ -24,6 +24,10 @@ limitations under the License.
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
} }
.wait {
color: black !important;
background-color: rgba(255, 255, 255, 0.67) !important;
}
@media (max-width: 500px) { @media (max-width: 500px) {
.join { .join {

View File

@@ -21,8 +21,8 @@ import { Button, Link } from "@vector-im/compound-web";
import classNames from "classnames"; import classNames from "classnames";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import styles from "./LobbyView.module.css";
import inCallStyles from "./InCallView.module.css"; import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useLocationNavigation } from "../useLocationNavigation"; import { useLocationNavigation } from "../useLocationNavigation";
import { MatrixInfo, VideoPreview } from "./VideoPreview"; import { MatrixInfo, VideoPreview } from "./VideoPreview";
@@ -43,10 +43,12 @@ interface Props {
matrixInfo: MatrixInfo; matrixInfo: MatrixInfo;
muteStates: MuteStates; muteStates: MuteStates;
onEnter: () => void; onEnter: () => void;
enterLabel?: JSX.Element;
confineToRoom: boolean; confineToRoom: boolean;
hideHeader: boolean; hideHeader: boolean;
participantCount: number; participantCount: number;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
waitingForInvite?: boolean;
} }
export const LobbyView: FC<Props> = ({ export const LobbyView: FC<Props> = ({
@@ -54,10 +56,12 @@ export const LobbyView: FC<Props> = ({
matrixInfo, matrixInfo,
muteStates, muteStates,
onEnter, onEnter,
enterLabel,
confineToRoom, confineToRoom,
hideHeader, hideHeader,
participantCount, participantCount,
onShareClick, onShareClick,
waitingForInvite,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
useLocationNavigation(); useLocationNavigation();
@@ -117,12 +121,18 @@ export const LobbyView: FC<Props> = ({
<div className={styles.content}> <div className={styles.content}>
<VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}> <VideoPreview matrixInfo={matrixInfo} muteStates={muteStates}>
<Button <Button
className={styles.join} className={classNames(
size="lg" "cpd-theme-dark",
onClick={onEnter} styles.join,
waitingForInvite ? styles.wait : "",
)}
size={waitingForInvite ? "sm" : "lg"}
onClick={() => {
if (!waitingForInvite) onEnter();
}}
data-testid="lobby_joinCall" data-testid="lobby_joinCall"
> >
{t("lobby.join_button")} {enterLabel ?? t("lobby.join_button")}
</Button> </Button>
</VideoPreview> </VideoPreview>
{!recentsButtonInFooter && recentsButton} {!recentsButtonInFooter && recentsButton}

View File

@@ -20,10 +20,11 @@ import { MediaDevice, useMediaDevices } from "../livekit/MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
/** /**
* If there already is this many participants in the call, we automatically mute * If there already are this many participants in the call, we automatically mute
* the user * the user.
*/ */
const MUTE_PARTICIPANT_COUNT = 8; // TODO set back to 8. for demo purposes we use 1 here.
export const MUTE_PARTICIPANT_COUNT = 1;
interface DeviceAvailable { interface DeviceAvailable {
enabled: boolean; enabled: boolean;
@@ -51,26 +52,27 @@ function useMuteState(
device: MediaDevice, device: MediaDevice,
enabledByDefault: () => boolean, enabledByDefault: () => boolean,
): MuteState { ): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean>( const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
(prev) => device.available.length > 0 && (prev ?? enabledByDefault()), (prev) =>
device.available.length > 0 ? prev ?? enabledByDefault() : undefined,
[device], [device],
); );
return useMemo( return useMemo(
() => () =>
device.available.length === 0 device.available.length === 0
? deviceUnavailable ? deviceUnavailable
: { enabled, setEnabled }, : {
enabled: enabled ?? false,
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
},
[device, enabled, setEnabled], [device, enabled, setEnabled],
); );
} }
export function useMuteStates(participantCount: number): MuteStates { export function useMuteStates(): MuteStates {
const devices = useMediaDevices(); const devices = useMediaDevices();
const audio = useMuteState( const audio = useMuteState(devices.audioInput, () => true);
devices.audioInput,
() => participantCount <= MUTE_PARTICIPANT_COUNT,
);
const video = useMuteState(devices.videoInput, () => true); const video = useMuteState(devices.videoInput, () => true);
return useMemo(() => ({ audio, video }), [audio, video]); return useMemo(() => ({ audio, video }), [audio, video]);

View File

@@ -16,6 +16,8 @@ limitations under the License.
import { FC, useEffect, useState, useCallback, ReactNode } from "react"; import { FC, useEffect, useState, useCallback, ReactNode } from "react";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import CheckIcon from "@vector-im/compound-design-tokens/icons/check.svg?react";
import { useClientLegacy } from "../ClientContext"; import { useClientLegacy } from "../ClientContext";
import { ErrorView, LoadingView } from "../FullScreenView"; import { ErrorView, LoadingView } from "../FullScreenView";
@@ -30,7 +32,10 @@ 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 { GroupCallStatus } from "./useLoadGroupCall";
import { WaitForInviteView } from "./WaitForInviteView"; import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile";
import { useMuteStates } from "./MuteStates";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { const {
@@ -41,7 +46,7 @@ export const RoomPage: FC = () => {
displayName, displayName,
skipLobby, skipLobby,
} = useUrlParams(); } = useUrlParams();
const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier(); const { roomAlias, roomId, viaServers } = useRoomIdentifier();
const roomIdOrAlias = roomId ?? roomAlias; const roomIdOrAlias = roomId ?? roomAlias;
@@ -49,17 +54,14 @@ export const RoomPage: FC = () => {
logger.error("No room specified"); logger.error("No room specified");
} }
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const { registerPasswordlessUser } = useRegisterPasswordlessUser(); const { registerPasswordlessUser } = useRegisterPasswordlessUser();
const [isRegistering, setIsRegistering] = useState(false); const [isRegistering, setIsRegistering] = useState(false);
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
const { loading, authenticated, client, error, passwordlessUser } = const { loading, authenticated, client, error, passwordlessUser } =
useClientLegacy(); useClientLegacy();
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
const muteStates = useMuteStates();
useEffect(() => { useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as // If we've finished loading, are not already authed and we've been given a display name as
@@ -78,6 +80,12 @@ export const RoomPage: FC = () => {
registerPasswordlessUser, registerPasswordlessUser,
]); ]);
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
useEffect(() => {
// During the beta, opt into analytics by default
if (optInAnalytics === null && setOptInAnalytics) setOptInAnalytics(true);
}, [optInAnalytics, setOptInAnalytics]);
const groupCallView = useCallback( const groupCallView = useCallback(
(groupCallState: GroupCallStatus) => { (groupCallState: GroupCallStatus) => {
switch (groupCallState.kind) { switch (groupCallState.kind) {
@@ -91,19 +99,66 @@ export const RoomPage: FC = () => {
preload={preload} preload={preload}
skipLobby={skipLobby} skipLobby={skipLobby}
hideHeader={hideHeader} hideHeader={hideHeader}
muteStates={muteStates}
/> />
); );
case "waitForInvite": case "waitForInvite":
case "canKnock": {
const knock =
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
const label =
groupCallState.kind === "canKnock" ? (
<>{t("lobby.ask_to_join")}</>
) : (
<>
{t("lobby.waiting_for_invite")}
<CheckIcon />
</>
);
return ( return (
<WaitForInviteView <LobbyView
hideHeader={hideHeader}
client={client!} client={client!}
roomSummary={groupCallState.roomSummary} matrixInfo={{
userId: client!.getUserId() ?? "",
displayName: userDisplayName ?? "",
avatarUrl: avatarUrl ?? "",
roomAlias: groupCallState.roomSummary.canonical_alias ?? "",
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={0}
muteStates={muteStates}
onShareClick={null}
/> />
); );
}
} }
}, },
[client, passwordlessUser, confineToRoom, preload, hideHeader, skipLobby], [
client,
passwordlessUser,
confineToRoom,
preload,
skipLobby,
hideHeader,
muteStates,
t,
userDisplayName,
avatarUrl,
],
); );
let content: ReactNode; let content: ReactNode;

View File

@@ -46,11 +46,18 @@ export type GroupCallWaitForInvite = {
roomSummary: RoomSummary; roomSummary: RoomSummary;
}; };
export type GroupCallCanKnock = {
kind: "canKnock";
roomSummary: RoomSummary;
knock: () => void;
};
export type GroupCallStatus = export type GroupCallStatus =
| GroupCallLoaded | GroupCallLoaded
| GroupCallLoadFailed | GroupCallLoadFailed
| GroupCallLoading | GroupCallLoading
| GroupCallWaitForInvite; | GroupCallWaitForInvite
| GroupCallCanKnock;
export interface GroupCallLoadState { export interface GroupCallLoadState {
error?: Error | KnockRejectError; error?: Error | KnockRejectError;
groupCall?: GroupCall; groupCall?: GroupCall;
@@ -73,7 +80,7 @@ export interface RoomSummary {
join_rule?: JoinRule.Knock | JoinRule.Public; // Added by MSC2403 join_rule?: JoinRule.Knock | JoinRule.Public; // Added by MSC2403
room_type?: RoomType; room_type?: RoomType;
membership?: Membership; membership?: Membership;
is_encrypted: boolean; "im.nheko.summary.encryption"?: boolean;
} }
export const useLoadGroupCall = ( export const useLoadGroupCall = (
@@ -110,10 +117,11 @@ export const useLoadGroupCall = (
const getRoomByKnocking = async ( const getRoomByKnocking = async (
roomId: string, roomId: string,
viaServers: string[], viaServers: string[],
onKnockSent: () => void,
): Promise<Room> => { ): Promise<Room> => {
let joinedRoom: Room | null = null; let joinedRoom: Room | null = null;
await client.knockRoom(roomId); await client.knockRoom(roomId);
onKnockSent();
const invitePromise = new Promise<void>((resolve, reject) => { const invitePromise = new Promise<void>((resolve, reject) => {
client.on( client.on(
RoomEvent.MyMembership, RoomEvent.MyMembership,
@@ -168,8 +176,12 @@ export const useLoadGroupCall = (
"Room not found. The widget-api did not pass over the relevant room events/information.", "Room not found. The widget-api did not pass over the relevant room events/information.",
); );
// if the room does not exist we first search for it with viaServers // If the room does not exist we first search for it with viaServers
const roomSummary = await client.getRoomSummary(roomId, viaServers); // We need to cast it to our own RoomSummary interface since we use the im.nheko.summary.encryption field.
const roomSummary = (await client.getRoomSummary(
roomId,
viaServers,
)) as unknown as RoomSummary;
if (room?.getMyMembership() === KnownMembership.Ban) { if (room?.getMyMembership() === KnownMembership.Ban) {
throw new BannedError(); throw new BannedError();
} else { } else {
@@ -178,8 +190,24 @@ export const useLoadGroupCall = (
viaServers, viaServers,
}); });
} else if (roomSummary.join_rule === JoinRule.Knock) { } else if (roomSummary.join_rule === JoinRule.Knock) {
setState({ kind: "waitForInvite", roomSummary }); let knock: () => void = () => {};
room = await getRoomByKnocking(roomSummary.room_id, viaServers); const userPressedAskToJoinPromise: Promise<void> = new Promise(
(resolve) => {
if (roomSummary.membership !== KnownMembership.Knock) {
knock = resolve;
} else {
// resolve immediately if the user already knocked
resolve();
}
},
);
setState({ kind: "canKnock", roomSummary, knock });
await userPressedAskToJoinPromise;
room = await getRoomByKnocking(
roomSummary.room_id,
viaServers,
() => setState({ kind: "waitForInvite", roomSummary }),
);
} else { } else {
throw new Error( throw new Error(
`Room ${roomSummary.room_id} is not joinable. This likely means, that the conference owner has changed the room settings to private.`, `Room ${roomSummary.room_id} is not joinable. This likely means, that the conference owner has changed the room settings to private.`,