Update video rooms to new design specs (#8207)

* Remove radio component

* "Voice room" → "video room"

* Remove interactivity from video room tiles

* Update connection state when joining via widget

* Simplify room header buttons for video rooms

* Split out video room creation into a separate menu option

* Simplify room options for video rooms

* Update video room tile layout

* Tell the Jitsi widget whether it's a video channel

* Update tests

* "Voice" → "video" in more places

* Fix tests

* Re-add frame to immersive Jitsi widgets

* Comment ack

* Make updateDevices more readable

* Type FacePile
This commit is contained in:
Robin 2022-04-01 10:36:10 -04:00 committed by GitHub
parent 020c1c6f31
commit 1f64835fab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 798 additions and 1305 deletions

View file

@ -206,6 +206,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
const buttons: JSX.Element[] = [];
if (this.props.inRoom &&
this.props.onCallPlaced &&
!this.context.tombstone &&
SettingsStore.getValue("showCallButtonsInComposer")
) {

View file

@ -16,6 +16,7 @@ limitations under the License.
import React, { ComponentType, createRef, ReactComponentElement, RefObject } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import * as fbEmitter from "fbemitter";
import { EventType } from "matrix-js-sdk/src/@types/event";
@ -222,8 +223,8 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
showCreateRoom
? (<>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconCreateNewRoom"
label={_t("New room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -235,6 +236,19 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to create new rooms in this space")}
/>
{ SettingsStore.getValue("feature_video_rooms") && <IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(activeSpace, RoomType.UnstableCall);
}}
disabled={!canAddRooms}
tooltip={canAddRooms ? undefined
: _t("You do not have permissions to create new rooms in this space")}
/> }
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconAddExistingRoom"
@ -254,17 +268,32 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
</IconizedContextMenuOptionList>;
} else if (menuDisplayed) {
contextMenuContent = <IconizedContextMenuOptionList first>
{ showCreateRoom && <IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconCreateNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_room" });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
/> }
{ showCreateRoom && <>
<IconizedContextMenuOption
label={_t("New room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_room" });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
/>
{ SettingsStore.getValue("feature_video_rooms") && <IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({
action: "view_create_room",
type: RoomType.UnstableCall,
});
}}
/> }
</> }
<IconizedContextMenuOption
label={_t("Explore public rooms")}
iconClassName="mx_RoomList_iconExplore"

View file

@ -16,11 +16,12 @@ limitations under the License.
import React, { useContext, useEffect, useState } from "react";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { _t } from "../../../languageHandler";
import { useEventEmitterState, useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
@ -127,6 +128,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const pendingActions = usePendingActions();
const filterCondition = RoomListStore.instance.getFirstNameFilterCondition();
@ -195,19 +197,31 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
/>;
}
let createNewRoomOption: JSX.Element;
let newRoomOptions: JSX.Element;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId())) {
createNewRoomOption = <IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconCreateRoom"
label={_t("Create new room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>;
newRoomOptions = <>
<IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewRoom"
label={_t("New room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{ videoRoomsEnabled && <IconizedContextMenuOption
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
label={_t("New video room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace, RoomType.UnstableCall);
closePlusMenu();
}}
/> }
</>;
}
contextMenu = <IconizedContextMenu
@ -217,7 +231,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
>
<IconizedContextMenuOptionList first>
{ inviteOption }
{ createNewRoomOption }
{ newRoomOptions }
<IconizedContextMenuOption
label={_t("Explore rooms")}
iconClassName="mx_RoomListHeader_iconExplore"
@ -262,12 +276,11 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
} else if (plusMenuDisplayed) {
let startChatOpt: JSX.Element;
let createRoomOpt: JSX.Element;
let newRoomOpts: JSX.Element;
let joinRoomOpt: JSX.Element;
if (canCreateRooms) {
startChatOpt = (
newRoomOpts = <>
<IconizedContextMenuOption
label={_t("Start new chat")}
iconClassName="mx_RoomListHeader_iconStartChat"
@ -278,11 +291,9 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
closePlusMenu();
}}
/>
);
createRoomOpt = (
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomListHeader_iconCreateRoom"
label={_t("New room")}
iconClassName="mx_RoomListHeader_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -291,7 +302,20 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
closePlusMenu();
}}
/>
);
{ videoRoomsEnabled && <IconizedContextMenuOption
label={_t("New video room")}
iconClassName="mx_RoomListHeader_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({
action: "view_create_room",
type: RoomType.UnstableCall,
});
closePlusMenu();
}}
/> }
</>;
}
if (canExploreRooms) {
joinRoomOpt = (
@ -314,8 +338,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
compact
>
<IconizedContextMenuOptionList first>
{ startChatOpt }
{ createRoomOpt }
{ newRoomOpts }
{ joinRoomOpt }
</IconizedContextMenuOptionList>
</IconizedContextMenu>;

View file

@ -33,10 +33,7 @@ import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import BaseAvatar from "../avatars/BaseAvatar";
import MemberAvatar from "../avatars/MemberAvatar";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import FacePile from "../elements/FacePile";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import NotificationBadge from "./NotificationBadge";
@ -55,17 +52,16 @@ import IconizedContextMenu, {
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import VoiceChannelStore, { VoiceChannelEvent, IJitsiParticipant } from "../../../stores/VoiceChannelStore";
import { getConnectedMembers } from "../../../utils/VoiceChannelUtils";
import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../../../stores/VideoChannelStore";
import { getConnectedMembers } from "../../../utils/VideoChannelUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
enum VoiceConnectionState {
enum VideoStatus {
Disconnected,
Connecting,
Connected,
}
@ -83,10 +79,10 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
voiceConnectionState: VoiceConnectionState;
// Active voice channel members, according to room state
voiceMembers: RoomMember[];
// Active voice channel members, according to Jitsi
videoStatus: VideoStatus;
// Active video channel members, according to room state
videoMembers: RoomMember[];
// Active video channel members, according to Jitsi
jitsiParticipants: IJitsiParticipant[];
}
@ -106,27 +102,28 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
private isVoiceRoom: boolean;
private isVideoRoom: boolean;
constructor(props: IProps) {
super(props);
const videoConnected = VideoChannelStore.instance.roomId === this.props.room.roomId;
this.state = {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
voiceConnectionState: VoiceChannelStore.instance.roomId === this.props.room.roomId ?
VoiceConnectionState.Connected : VoiceConnectionState.Disconnected,
voiceMembers: [],
jitsiParticipants: [],
videoStatus: videoConnected ? VideoStatus.Connected : VideoStatus.Disconnected,
videoMembers: getConnectedMembers(this.props.room.currentState),
jitsiParticipants: videoConnected ? VideoChannelStore.instance.participants : [],
};
this.generatePreview();
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.isVoiceRoom = SettingsStore.getValue("feature_voice_rooms") && this.props.room.isCallRoom();
this.isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isCallRoom();
}
private onRoomNameUpdate = (room: Room) => {
@ -165,8 +162,9 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
prevProps.room?.currentState?.off(RoomStateEvent.Events, this.updateVoiceMembers);
this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVoiceMembers);
prevProps.room?.currentState?.off(RoomStateEvent.Events, this.updateVideoMembers);
this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVideoMembers);
this.updateVideoStatus();
prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate);
this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate);
}
@ -177,7 +175,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
if (this.state.selected) {
this.scrollIntoView();
}
this.updateVoiceMembers();
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
@ -188,7 +185,13 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate);
this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVoiceMembers);
this.props.room?.currentState?.on(RoomStateEvent.Events, this.updateVideoMembers);
VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoStatus);
VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoStatus);
if (VideoChannelStore.instance.roomId === this.props.room.roomId) {
VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants);
}
}
public componentWillUnmount() {
@ -198,13 +201,16 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.props.room.currentState.off(RoomStateEvent.Events, this.updateVoiceMembers);
this.props.room.currentState.off(RoomStateEvent.Events, this.updateVideoMembers);
this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
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);
}
private onAction = (payload: ActionPayload) => {
@ -255,11 +261,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
// Connect to the voice channel if this is a voice room
if (this.isVoiceRoom && this.state.voiceConnectionState === VoiceConnectionState.Disconnected) {
await this.connectVoice();
}
};
private onActiveRoomUpdate = (isActive: boolean) => {
@ -584,87 +585,24 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
}
private updateVoiceMembers = () => {
this.setState({ voiceMembers: getConnectedMembers(this.props.room.currentState) });
private updateVideoMembers = () => {
this.setState({ videoMembers: getConnectedMembers(this.props.room.currentState) });
};
private updateVideoStatus = () => {
if (VideoChannelStore.instance.roomId === this.props.room?.roomId) {
this.setState({ videoStatus: VideoStatus.Connected });
VideoChannelStore.instance.on(VideoChannelEvent.Participants, this.updateJitsiParticipants);
} else {
this.setState({ videoStatus: VideoStatus.Disconnected });
VideoChannelStore.instance.off(VideoChannelEvent.Participants, this.updateJitsiParticipants);
}
};
private updateJitsiParticipants = (participants: IJitsiParticipant[]) => {
this.setState({ jitsiParticipants: participants });
};
private renderVoiceChannel(): React.ReactElement | null {
let faces;
if (this.state.voiceConnectionState === VoiceConnectionState.Connected) {
faces = this.state.jitsiParticipants.map(p =>
<BaseAvatar
key={p.participantId}
name={p.displayName ?? p.formattedDisplayName}
idName={p.participantId}
// This comes directly from Jitsi, so we shouldn't apply custom media routing to it
url={p.avatarURL}
width={24}
height={24}
/>,
);
} else if (this.state.voiceMembers.length) {
faces = this.state.voiceMembers.map(m =>
<MemberAvatar
key={m.userId}
member={m}
width={24}
height={24}
/>,
);
} else {
return null;
}
// TODO: The below "join" button will eventually show up on text rooms
// with an active voice channel, but that isn't implemented yet
return <div className="mx_RoomTile_voiceChannel">
<FacePile faces={faces} overflow={false} />
{ this.isVoiceRoom ? null : (
<AccessibleButton
kind="link"
className="mx_RoomTile_connectVoiceButton"
onClick={this.connectVoice.bind(this)}
>
{ _t("Join") }
</AccessibleButton>
) }
</div>;
}
private async connectVoice() {
this.setState({ voiceConnectionState: VoiceConnectionState.Connecting });
// TODO: Actually wait for the widget to be ready, instead of guessing.
// This hack is only in place until we find out for sure whether design
// wants the room view to open when connecting voice, or if this should
// somehow connect in the background. Until then, it's not worth the
// effort to solve this properly.
await new Promise(resolve => setTimeout(resolve, 1000));
const waitForConnect = VoiceChannelStore.instance.connect(this.props.room.roomId);
// Participant data comes down the event channel quickly, so prepare in advance
VoiceChannelStore.instance.on(VoiceChannelEvent.Participants, this.updateJitsiParticipants);
try {
await waitForConnect;
this.setState({ voiceConnectionState: VoiceConnectionState.Connected });
VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, () => {
this.setState({
voiceConnectionState: VoiceConnectionState.Disconnected,
jitsiParticipants: [],
}),
VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateJitsiParticipants);
});
} catch (e) {
// If it failed, clean up our advance preparations
logger.error("Failed to connect voice", e);
VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateJitsiParticipants);
}
}
public render(): React.ReactElement {
const classes = classNames({
'mx_RoomTile': true,
@ -692,34 +630,44 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
let subtitle;
if (this.isVoiceRoom) {
switch (this.state.voiceConnectionState) {
case VoiceConnectionState.Disconnected:
subtitle = (
<div className="mx_RoomTile_subtitle mx_RoomTile_voiceIndicator">
{ _t("Voice room") }
</div>
);
if (this.isVideoRoom) {
let videoText: string;
let videoActive: boolean;
let participantCount: number;
switch (this.state.videoStatus) {
case VideoStatus.Disconnected:
videoText = _t("Video");
videoActive = false;
participantCount = this.state.videoMembers.length;
break;
case VoiceConnectionState.Connecting:
subtitle = (
<div className="mx_RoomTile_subtitle mx_RoomTile_voiceIndicator">
{ _t("Connecting...") }
</div>
);
break;
case VoiceConnectionState.Connected:
subtitle = (
<div
className={
"mx_RoomTile_subtitle mx_RoomTile_voiceIndicator " +
"mx_RoomTile_voiceIndicator_active"
}
>
{ _t("Connected") }
</div>
);
case VideoStatus.Connected:
videoText = _t("Connected");
videoActive = true;
participantCount = this.state.jitsiParticipants.length;
}
subtitle = (
<div className="mx_RoomTile_subtitle">
<span
className={classNames({
"mx_RoomTile_videoIndicator": true,
"mx_RoomTile_videoIndicator_active": videoActive,
})}
>
{ videoText }
</span>
{ participantCount ? <>
{ " · " }
<span
className="mx_RoomTile_videoParticipants"
aria-label={_t("%(count)s participants", { count: participantCount })}
>
{ participantCount }
</span>
</> : null }
</div>
);
} else if (this.showMessagePreview && this.state.messagePreview) {
subtitle = (
<div
@ -800,15 +748,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
displayBadge={this.props.isMinimized}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{ titleContainer }
{ badge }
{ this.renderGeneralMenu() }
{ this.renderNotificationsMenu(isActive) }
</div>
{ this.renderVoiceChannel() }
</div>
{ titleContainer }
{ badge }
{ this.renderGeneralMenu() }
{ this.renderNotificationsMenu(isActive) }
</Button>
}
</RovingTabIndexWrapper>