Show a lobby screen in video rooms (#8287)
* Show a lobby screen in video rooms * Add connecting state * Test VideoRoomView * Test VideoLobby * Get the local video stream with useAsyncMemo * Clean up code review nits * Explicitly state what !important is overriding * Use spacing variables * Wait for video channel messaging * Update join button copy * Show frame on both the lobby and widget * Force dark theme for video lobby * Wait for the widget to be ready * Make VideoChannelStore constructor private * Allow video lobby to shrink * Add invite button to video room header * Show connected members on lobby screen * Make avatars in video lobby clickable * Increase video channel store timeout * Fix Jitsi Meet getting wedged on startup in Chrome and Safari * Revert "Fix Jitsi Meet getting wedged on startup in Chrome and Safari" This reverts commit 9f77b8c227c1a5bffa5d91b0c48bf3bbc44d4cec. * Disable device buttons while connecting * Factor RoomFacePile into a separate file * Fix i18n lint * Fix switching video channels while connected * Properly limit number of connected members in face pile * Fix CSS lint
This commit is contained in:
parent
9a065581e5
commit
6e86a14cc9
30 changed files with 1338 additions and 267 deletions
|
@ -75,8 +75,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay";
|
|||
import { containsEmoji } from '../../effects/utils';
|
||||
import { CHAT_EFFECTS } from '../../effects';
|
||||
import WidgetStore from "../../stores/WidgetStore";
|
||||
import { getVideoChannel } from "../../utils/VideoChannelUtils";
|
||||
import AppTile from "../views/elements/AppTile";
|
||||
import VideoRoomView from "./VideoRoomView";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import Notifier from "../../Notifier";
|
||||
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
|
||||
|
@ -1249,7 +1248,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onInviteButtonClick = () => {
|
||||
private onInviteClick = () => {
|
||||
// open the room inviter
|
||||
dis.dispatch({
|
||||
action: 'view_invite',
|
||||
|
@ -1904,7 +1903,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
statusBar = <RoomStatusBar
|
||||
room={this.state.room}
|
||||
isPeeking={myMembership !== "join"}
|
||||
onInviteClick={this.onInviteButtonClick}
|
||||
onInviteClick={this.onInviteClick}
|
||||
onVisible={this.onStatusBarVisible}
|
||||
onHidden={this.onStatusBarHidden}
|
||||
/>;
|
||||
|
@ -2169,18 +2168,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
</>;
|
||||
break;
|
||||
case MainSplitContentType.Video: {
|
||||
const app = getVideoChannel(this.state.room.roomId);
|
||||
if (!app) break;
|
||||
mainSplitContentClassName = "mx_MainSplit_video";
|
||||
mainSplitBody = <AppTile
|
||||
app={app}
|
||||
room={this.state.room}
|
||||
userId={this.context.credentials.userId}
|
||||
creatorUserId={app.creatorUserId}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
showMenubar={false}
|
||||
pointerEvents={this.state.resizing ? "none" : null}
|
||||
/>;
|
||||
mainSplitBody = <>
|
||||
<VideoRoomView room={this.state.room} resizing={this.state.resizing} />
|
||||
{ previewBar }
|
||||
</>;
|
||||
}
|
||||
}
|
||||
const mainSplitContentClasses = classNames("mx_RoomView_body", mainSplitContentClassName);
|
||||
|
@ -2190,6 +2182,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
let onAppsClick = this.onAppsClick;
|
||||
let onForgetClick = this.onForgetClick;
|
||||
let onSearchClick = this.onSearchClick;
|
||||
let onInviteClick = null;
|
||||
|
||||
// Simplify the header for other main split types
|
||||
switch (this.state.mainSplitContentType) {
|
||||
|
@ -2212,6 +2205,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
onAppsClick = null;
|
||||
onForgetClick = null;
|
||||
onSearchClick = null;
|
||||
if (this.state.room.canInvite(this.context.credentials.userId)) {
|
||||
onInviteClick = this.onInviteClick;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -2227,6 +2223,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
|||
oobData={this.props.oobData}
|
||||
inRoom={myMembership === 'join'}
|
||||
onSearchClick={onSearchClick}
|
||||
onInviteClick={onInviteClick}
|
||||
onForgetClick={(myMembership === "leave") ? onForgetClick : null}
|
||||
e2eStatus={this.state.e2eStatus}
|
||||
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
|
||||
|
|
|
@ -58,7 +58,7 @@ import {
|
|||
} from "../../utils/space";
|
||||
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
|
||||
import MemberAvatar from "../views/avatars/MemberAvatar";
|
||||
import FacePile from "../views/elements/FacePile";
|
||||
import RoomFacePile from "../views/elements/RoomFacePile";
|
||||
import {
|
||||
AddExistingToSpace,
|
||||
defaultDmsRenderer,
|
||||
|
@ -298,7 +298,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISp
|
|||
</div>
|
||||
}
|
||||
</RoomTopic>
|
||||
{ space.getJoinRule() === "public" && <FacePile room={space} /> }
|
||||
{ space.getJoinRule() === "public" && <RoomFacePile room={space} /> }
|
||||
<div className="mx_SpaceRoomView_preview_joinButtons">
|
||||
{ joinButtons }
|
||||
</div>
|
||||
|
@ -454,7 +454,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
|
|||
<div className="mx_SpaceRoomView_landing_infoBar">
|
||||
<SpaceInfo space={space} />
|
||||
<div className="mx_SpaceRoomView_landing_infoBar_interactive">
|
||||
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
|
||||
<RoomFacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
|
||||
{ inviteButton }
|
||||
{ settingsButton }
|
||||
</div>
|
||||
|
|
67
src/components/structures/VideoRoomView.tsx
Normal file
67
src/components/structures/VideoRoomView.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useContext, useState, useMemo } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { useEventEmitter } from "../../hooks/useEventEmitter";
|
||||
import { getVideoChannel } from "../../utils/VideoChannelUtils";
|
||||
import WidgetStore from "../../stores/WidgetStore";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore";
|
||||
import AppTile from "../views/elements/AppTile";
|
||||
import VideoLobby from "../views/voip/VideoLobby";
|
||||
|
||||
const VideoRoomView: FC<{ room: Room, resizing: boolean }> = ({ room, resizing }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const store = VideoChannelStore.instance;
|
||||
|
||||
// In case we mount before the WidgetStore knows about our Jitsi widget
|
||||
const [widgetLoaded, setWidgetLoaded] = useState(false);
|
||||
useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => {
|
||||
if (roomId === null || roomId === room.roomId) setWidgetLoaded(true);
|
||||
});
|
||||
|
||||
const app = useMemo(() => {
|
||||
const app = getVideoChannel(room.roomId);
|
||||
if (!app) logger.warn(`No video channel for room ${room.roomId}`);
|
||||
return app;
|
||||
}, [room, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId);
|
||||
useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId));
|
||||
useEventEmitter(store, VideoChannelEvent.Disconnect, () => setConnected(false));
|
||||
|
||||
if (!app) return null;
|
||||
|
||||
return <div className="mx_VideoRoomView">
|
||||
{ connected ? null : <VideoLobby room={room} /> }
|
||||
{ /* We render the widget even if we're disconnected, so it stays loaded */ }
|
||||
<AppTile
|
||||
app={app}
|
||||
room={room}
|
||||
userId={cli.credentials.userId}
|
||||
creatorUserId={app.creatorUserId}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
showMenubar={false}
|
||||
pointerEvents={resizing ? "none" : null}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default VideoRoomView;
|
|
@ -14,91 +14,46 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, HTMLAttributes, ReactNode, useContext } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import React, { FC, HTMLAttributes, ReactNode } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import TextWithTooltip from "../elements/TextWithTooltip";
|
||||
import { useRoomMembers } from "../../../hooks/useRoomMembers";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
const DEFAULT_NUM_FACES = 5;
|
||||
import TooltipTarget from "./TooltipTarget";
|
||||
import TextWithTooltip from "./TextWithTooltip";
|
||||
|
||||
interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
room: Room;
|
||||
onlyKnownUsers?: boolean;
|
||||
numShown?: number;
|
||||
members: RoomMember[];
|
||||
faceSize: number;
|
||||
overflow: boolean;
|
||||
tooltip?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
|
||||
const FacePile: FC<IProps> = ({ members, faceSize, overflow, tooltip, children, ...props }) => {
|
||||
const faces = members.map(
|
||||
tooltip ?
|
||||
m => <MemberAvatar key={m.userId} member={m} width={faceSize} height={faceSize} /> :
|
||||
m => <TooltipTarget key={m.userId} label={m.name}>
|
||||
<MemberAvatar member={m} width={faceSize} height={faceSize} viewUserOnClick={!props.onClick} />
|
||||
</TooltipTarget>,
|
||||
);
|
||||
|
||||
const FacePile: FC<IProps> = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const isJoined = room.getMyMembership() === "join";
|
||||
let members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
||||
// sort users with an explicit avatar first
|
||||
const iteratees = [member => member.getMxcAvatarUrl() ? 0 : 1];
|
||||
if (onlyKnownUsers) {
|
||||
members = members.filter(isKnownMember);
|
||||
} else {
|
||||
// sort known users first
|
||||
iteratees.unshift(member => isKnownMember(member) ? 0 : 1);
|
||||
}
|
||||
|
||||
// exclude ourselves from the shown members list
|
||||
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
|
||||
if (shownMembers.length < 1) return null;
|
||||
|
||||
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
|
||||
// reverse members in tooltip order to make the order between the two match up.
|
||||
const commaSeparatedMembers = shownMembers.map(m => m.rawDisplayName).reverse().join(", ");
|
||||
|
||||
let tooltip: ReactNode;
|
||||
if (props.onClick) {
|
||||
let subText: string;
|
||||
if (isJoined) {
|
||||
subText = _t("Including you, %(commaSeparatedMembers)s", { commaSeparatedMembers });
|
||||
} else {
|
||||
subText = _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers });
|
||||
}
|
||||
|
||||
tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("View all %(count)s members", { count }) }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ subText }
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
if (isJoined) {
|
||||
tooltip = _t("%(count)s members including you, %(commaSeparatedMembers)s", {
|
||||
count: count - 1,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
} else {
|
||||
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
|
||||
count,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
}
|
||||
}
|
||||
const pileContents = <>
|
||||
{ overflow ? <span className="mx_FacePile_more" /> : null }
|
||||
{ faces }
|
||||
</>;
|
||||
|
||||
return <div {...props} className="mx_FacePile">
|
||||
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
|
||||
{ members.length > numShown ? <span className="mx_FacePile_face mx_FacePile_more" /> : null }
|
||||
{ shownMembers.map(m =>
|
||||
<MemberAvatar key={m.userId} member={m} width={28} height={28} className="mx_FacePile_face" />) }
|
||||
</TextWithTooltip>
|
||||
{ onlyKnownUsers && <span className="mx_FacePile_summary">
|
||||
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
||||
</span> }
|
||||
{ tooltip ? (
|
||||
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
|
||||
{ pileContents }
|
||||
</TextWithTooltip>
|
||||
) : (
|
||||
<div className="mx_FacePile_faces">
|
||||
{ pileContents }
|
||||
</div>
|
||||
) }
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
107
src/components/views/elements/RoomFacePile.tsx
Normal file
107
src/components/views/elements/RoomFacePile.tsx
Normal file
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, HTMLAttributes, ReactNode, useContext } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { sortBy } from "lodash";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import FacePile from "./FacePile";
|
||||
import { useRoomMembers } from "../../../hooks/useRoomMembers";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
|
||||
const DEFAULT_NUM_FACES = 5;
|
||||
|
||||
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
|
||||
|
||||
interface IProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
room: Room;
|
||||
onlyKnownUsers?: boolean;
|
||||
numShown?: number;
|
||||
}
|
||||
|
||||
const RoomFacePile: FC<IProps> = (
|
||||
{ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props },
|
||||
) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const isJoined = room.getMyMembership() === "join";
|
||||
let members = useRoomMembers(room);
|
||||
const count = members.length;
|
||||
|
||||
// sort users with an explicit avatar first
|
||||
const iteratees = [member => member.getMxcAvatarUrl() ? 0 : 1];
|
||||
if (onlyKnownUsers) {
|
||||
members = members.filter(isKnownMember);
|
||||
} else {
|
||||
// sort known users first
|
||||
iteratees.unshift(member => isKnownMember(member) ? 0 : 1);
|
||||
}
|
||||
|
||||
// exclude ourselves from the shown members list
|
||||
const shownMembers = sortBy(members.filter(m => m.userId !== cli.getUserId()), iteratees).slice(0, numShown);
|
||||
if (shownMembers.length < 1) return null;
|
||||
|
||||
// We reverse the order of the shown faces in CSS to simplify their visual overlap,
|
||||
// reverse members in tooltip order to make the order between the two match up.
|
||||
const commaSeparatedMembers = shownMembers.map(m => m.name).reverse().join(", ");
|
||||
|
||||
let tooltip: ReactNode;
|
||||
if (props.onClick) {
|
||||
let subText: string;
|
||||
if (isJoined) {
|
||||
subText = _t("Including you, %(commaSeparatedMembers)s", { commaSeparatedMembers });
|
||||
} else {
|
||||
subText = _t("Including %(commaSeparatedMembers)s", { commaSeparatedMembers });
|
||||
}
|
||||
|
||||
tooltip = <div>
|
||||
<div className="mx_Tooltip_title">
|
||||
{ _t("View all %(count)s members", { count }) }
|
||||
</div>
|
||||
<div className="mx_Tooltip_sub">
|
||||
{ subText }
|
||||
</div>
|
||||
</div>;
|
||||
} else {
|
||||
if (isJoined) {
|
||||
tooltip = _t("%(count)s members including you, %(commaSeparatedMembers)s", {
|
||||
count: count - 1,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
} else {
|
||||
tooltip = _t("%(count)s members including %(commaSeparatedMembers)s", {
|
||||
count,
|
||||
commaSeparatedMembers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return <FacePile
|
||||
members={shownMembers}
|
||||
faceSize={28}
|
||||
overflow={members.length > numShown}
|
||||
tooltip={tooltip}
|
||||
{...props}
|
||||
>
|
||||
{ onlyKnownUsers && <span className="mx_FacePile_summary">
|
||||
{ _t("%(count)s people you know have already joined", { count: members.length }) }
|
||||
</span> }
|
||||
</FacePile>;
|
||||
};
|
||||
|
||||
export default RoomFacePile;
|
|
@ -53,6 +53,7 @@ interface IProps {
|
|||
oobData?: IOOBData;
|
||||
inRoom: boolean;
|
||||
onSearchClick: () => void;
|
||||
onInviteClick: () => void;
|
||||
onForgetClick: () => void;
|
||||
onCallPlaced: (type: CallType) => void;
|
||||
onAppsClick: () => void;
|
||||
|
@ -255,6 +256,16 @@ export default class RoomHeader extends React.Component<IProps, IState> {
|
|||
buttons.push(searchButton);
|
||||
}
|
||||
|
||||
if (this.props.onInviteClick && this.props.inRoom) {
|
||||
const inviteButton = <AccessibleTooltipButton
|
||||
className="mx_RoomHeader_button mx_RoomHeader_inviteButton"
|
||||
onClick={this.props.onInviteClick}
|
||||
title={_t("Invite")}
|
||||
key="invite"
|
||||
/>;
|
||||
buttons.push(inviteButton);
|
||||
}
|
||||
|
||||
const rightRow =
|
||||
<div className="mx_RoomHeader_buttons">
|
||||
{ buttons }
|
||||
|
|
|
@ -61,6 +61,7 @@ import { RoomViewStore } from "../../../stores/RoomViewStore";
|
|||
|
||||
enum VideoStatus {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
}
|
||||
|
||||
|
@ -105,7 +106,16 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
const videoConnected = VideoChannelStore.instance.roomId === this.props.room.roomId;
|
||||
let videoStatus;
|
||||
if (VideoChannelStore.instance.roomId === this.props.room.roomId) {
|
||||
if (VideoChannelStore.instance.connected) {
|
||||
videoStatus = VideoStatus.Connected;
|
||||
} else {
|
||||
videoStatus = VideoStatus.Connecting;
|
||||
}
|
||||
} else {
|
||||
videoStatus = VideoStatus.Disconnected;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId,
|
||||
|
@ -113,9 +123,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
generalMenuPosition: null,
|
||||
// generatePreview() will return nothing if the user has previews disabled
|
||||
messagePreview: "",
|
||||
videoStatus: videoConnected ? VideoStatus.Connected : VideoStatus.Disconnected,
|
||||
videoStatus,
|
||||
videoMembers: getConnectedMembers(this.props.room.currentState),
|
||||
jitsiParticipants: videoConnected ? VideoChannelStore.instance.participants : [],
|
||||
jitsiParticipants: VideoChannelStore.instance.participants,
|
||||
};
|
||||
this.generatePreview();
|
||||
|
||||
|
@ -185,8 +195,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
|
||||
this.props.room.currentState.on(RoomStateEvent.Events, this.updateVideoMembers);
|
||||
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoStatus);
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoStatus);
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.onConnectVideo);
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.StartConnect, this.onStartConnectVideo);
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.onDisconnectVideo);
|
||||
if (VideoChannelStore.instance.roomId === this.props.room.roomId) {
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants);
|
||||
}
|
||||
|
@ -204,8 +215,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
|
||||
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
|
||||
|
||||
VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoStatus);
|
||||
VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoStatus);
|
||||
VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.onConnectVideo);
|
||||
VideoChannelStore.instance.off(VideoChannelEvent.StartConnect, this.onStartConnectVideo);
|
||||
VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.onDisconnectVideo);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
|
@ -586,15 +598,37 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
|
||||
private updateVideoStatus = () => {
|
||||
if (VideoChannelStore.instance.roomId === this.props.room?.roomId) {
|
||||
if (VideoChannelStore.instance.connected) {
|
||||
this.onConnectVideo(this.props.room?.roomId);
|
||||
} else {
|
||||
this.onStartConnectVideo(this.props.room?.roomId);
|
||||
}
|
||||
} else {
|
||||
this.onDisconnectVideo(this.props.room?.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
private onConnectVideo = (roomId: string) => {
|
||||
if (roomId === this.props.room?.roomId) {
|
||||
this.setState({ videoStatus: VideoStatus.Connected });
|
||||
VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants);
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
private onStartConnectVideo = (roomId: string) => {
|
||||
if (roomId === this.props.room?.roomId) {
|
||||
this.setState({ videoStatus: VideoStatus.Connecting });
|
||||
}
|
||||
};
|
||||
|
||||
private onDisconnectVideo = (roomId: string) => {
|
||||
if (roomId === this.props.room?.roomId) {
|
||||
this.setState({ videoStatus: VideoStatus.Disconnected });
|
||||
VideoChannelStore.instance.off(VideoChannelEvent.Participants, this.updateJitsiParticipants);
|
||||
}
|
||||
};
|
||||
|
||||
private updateJitsiParticipants = (participants: IJitsiParticipant[]) => {
|
||||
private updateJitsiParticipants = (roomId: string, participants: IJitsiParticipant[]) => {
|
||||
this.setState({ jitsiParticipants: participants });
|
||||
};
|
||||
|
||||
|
@ -636,6 +670,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
|
|||
videoActive = false;
|
||||
participantCount = this.state.videoMembers.length;
|
||||
break;
|
||||
case VideoStatus.Connecting:
|
||||
videoText = _t("Connecting...");
|
||||
videoActive = true;
|
||||
participantCount = this.state.videoMembers.length;
|
||||
break;
|
||||
case VideoStatus.Connected:
|
||||
videoText = _t("Connected");
|
||||
videoActive = true;
|
||||
|
|
232
src/components/views/voip/VideoLobby.tsx
Normal file
232
src/components/views/voip/VideoLobby.tsx
Normal file
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState, useMemo, useRef, useEffect } from "react";
|
||||
import classNames from "classnames";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||
import { useStateToggle } from "../../../hooks/useStateToggle";
|
||||
import { useConnectedMembers } from "../../../utils/VideoChannelUtils";
|
||||
import VideoChannelStore from "../../../stores/VideoChannelStore";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
import { Alignment } from "../elements/Tooltip";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import FacePile from "../elements/FacePile";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
|
||||
interface IDeviceButtonProps {
|
||||
kind: string;
|
||||
devices: MediaDeviceInfo[];
|
||||
setDevice: (device: MediaDeviceInfo) => void;
|
||||
deviceListLabel: string;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
toggle: () => void;
|
||||
activeTitle: string;
|
||||
inactiveTitle: string;
|
||||
}
|
||||
|
||||
const DeviceButton: FC<IDeviceButtonProps> = ({
|
||||
kind, devices, setDevice, deviceListLabel, active, disabled, toggle, activeTitle, inactiveTitle,
|
||||
}) => {
|
||||
// Depending on permissions, the browser might not let us know device labels,
|
||||
// in which case there's nothing helpful we can display
|
||||
const labelledDevices = useMemo(() => devices.filter(d => d.label.length), [devices]);
|
||||
|
||||
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const selectDevice = (device: MediaDeviceInfo) => {
|
||||
setDevice(device);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const buttonRect = buttonRef.current.getBoundingClientRect();
|
||||
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
|
||||
<IconizedContextMenuOptionList>
|
||||
{ labelledDevices.map(d =>
|
||||
<IconizedContextMenuOption
|
||||
key={d.deviceId}
|
||||
label={d.label}
|
||||
onClick={() => selectDevice(d)}
|
||||
/>,
|
||||
) }
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
||||
if (!devices.length) return null;
|
||||
|
||||
return <div
|
||||
className={classNames({
|
||||
"mx_VideoLobby_deviceButtonWrapper": true,
|
||||
"mx_VideoLobby_deviceButtonWrapper_active": active,
|
||||
})}
|
||||
>
|
||||
<AccessibleTooltipButton
|
||||
className={`mx_VideoLobby_deviceButton mx_VideoLobby_deviceButton_${kind}`}
|
||||
title={active ? activeTitle : inactiveTitle}
|
||||
alignment={Alignment.Top}
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{ labelledDevices.length > 1 ? (
|
||||
<ContextMenuButton
|
||||
className="mx_VideoLobby_deviceListButton"
|
||||
inputRef={buttonRef}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={deviceListLabel}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null }
|
||||
{ contextMenu }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const MAX_FACES = 8;
|
||||
|
||||
const VideoLobby: FC<{ room: Room }> = ({ room }) => {
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const me = useMemo(() => room.getMember(room.myUserId), [room]);
|
||||
const connectedMembers = useConnectedMembers(room.currentState);
|
||||
const videoRef = useRef<HTMLVideoElement>();
|
||||
|
||||
const devices = useAsyncMemo(async () => {
|
||||
try {
|
||||
return await navigator.mediaDevices.enumerateDevices();
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to get media device list: ${e}`);
|
||||
return [];
|
||||
}
|
||||
}, [], []);
|
||||
const audioDevices = useMemo(() => devices.filter(d => d.kind === "audioinput"), [devices]);
|
||||
const videoDevices = useMemo(() => devices.filter(d => d.kind === "videoinput"), [devices]);
|
||||
|
||||
const [selectedAudioDevice, selectAudioDevice] = useState<MediaDeviceInfo>(null);
|
||||
const [selectedVideoDevice, selectVideoDevice] = useState<MediaDeviceInfo>(null);
|
||||
|
||||
const audioDevice = selectedAudioDevice ?? audioDevices[0];
|
||||
const videoDevice = selectedVideoDevice ?? videoDevices[0];
|
||||
|
||||
const [audioActive, toggleAudio] = useStateToggle(true);
|
||||
const [videoActive, toggleVideo] = useStateToggle(true);
|
||||
|
||||
const videoStream = useAsyncMemo(async () => {
|
||||
if (videoDevice && videoActive) {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia({
|
||||
video: { deviceId: videoDevice.deviceId },
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`Failed to get stream for device ${videoDevice.deviceId}: ${e}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [videoDevice, videoActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoStream) {
|
||||
const videoElement = videoRef.current;
|
||||
videoElement.srcObject = videoStream;
|
||||
videoElement.play();
|
||||
|
||||
return () => {
|
||||
videoStream?.getTracks().forEach(track => track.stop());
|
||||
videoElement.srcObject = null;
|
||||
};
|
||||
}
|
||||
}, [videoStream]);
|
||||
|
||||
const connect = async () => {
|
||||
setConnecting(true);
|
||||
try {
|
||||
await VideoChannelStore.instance.connect(
|
||||
room.roomId, audioActive ? audioDevice : null, videoActive ? videoDevice : null,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
let facePile;
|
||||
if (connectedMembers.length) {
|
||||
const shownMembers = connectedMembers.slice(0, MAX_FACES);
|
||||
const overflow = connectedMembers.length > shownMembers.length;
|
||||
|
||||
facePile = <div className="mx_VideoLobby_connectedMembers">
|
||||
{ _t("%(count)s people connected", { count: connectedMembers.length }) }
|
||||
<FacePile members={shownMembers} faceSize={24} overflow={overflow} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div className="mx_VideoLobby">
|
||||
{ facePile }
|
||||
<div className="mx_VideoLobby_preview">
|
||||
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{ visibility: videoActive ? null : "hidden" }}
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
<div className="mx_VideoLobby_controls">
|
||||
<DeviceButton
|
||||
kind="audio"
|
||||
devices={audioDevices}
|
||||
setDevice={selectAudioDevice}
|
||||
deviceListLabel={_t("Audio devices")}
|
||||
active={audioActive}
|
||||
disabled={connecting}
|
||||
toggle={toggleAudio}
|
||||
activeTitle={_t("Mute microphone")}
|
||||
inactiveTitle={_t("Unmute microphone")}
|
||||
/>
|
||||
<DeviceButton
|
||||
kind="video"
|
||||
devices={videoDevices}
|
||||
setDevice={selectVideoDevice}
|
||||
deviceListLabel={_t("Video devices")}
|
||||
active={videoActive}
|
||||
disabled={connecting}
|
||||
toggle={toggleVideo}
|
||||
activeTitle={_t("Turn off camera")}
|
||||
inactiveTitle={_t("Turn on camera")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AccessibleButton
|
||||
className="mx_VideoLobby_joinButton"
|
||||
kind="primary"
|
||||
disabled={connecting}
|
||||
onClick={connect}
|
||||
>
|
||||
{ _t("Connect now") }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default VideoLobby;
|
Loading…
Add table
Add a link
Reference in a new issue