Voice rooms prototype (#8084)

* Add voice room labs flag

Signed-off-by: Robin Townsend <robin@robin.town>

* Add more widget actions for interacting with Jitsi

Signed-off-by: Robin Townsend <robin@robin.town>

* Factor out a more generic Jitsi creation utility

Signed-off-by: Robin Townsend <robin@robin.town>

* Add utilities for managing voice channels

Signed-off-by: Robin Townsend <robin@robin.town>

* Enable creation of voice rooms

Signed-off-by: Robin Townsend <robin@robin.town>

* Force a maximized view of voice channel widgets

Signed-off-by: Robin Townsend <robin@robin.town>

* Add voice channel store

Signed-off-by: Robin Townsend <robin@robin.town>

* Factor out a more generic FacePile

Signed-off-by: Robin Townsend <robin@robin.town>

* Implement room tile changes for voice rooms

Signed-off-by: Robin Townsend <robin@robin.town>

* Add interactive radio component to the left panel

Signed-off-by: Robin Townsend <robin@robin.town>

* Test voice rooms

Signed-off-by: Robin Townsend <robin@robin.town>

* Update name of call room type

Signed-off-by: Robin Townsend <robin@robin.town>

* Clarify that voice rooms are under development

Signed-off-by: Robin Townsend <robin@robin.town>

* Use readonly

Signed-off-by: Robin Townsend <robin@robin.town>

* Move acks to the end of handlers

Signed-off-by: Robin Townsend <robin@robin.town>

* Add comment about avatar URLs coming from Jitsi

Signed-off-by: Robin Townsend <robin@robin.town>

* Don't use unicode ellipses

for translation reasons?

Signed-off-by: Robin Townsend <robin@robin.town>

* Fix tests

Signed-off-by: Robin Townsend <robin@robin.town>

* Fix tests, again

Signed-off-by: Robin Townsend <robin@robin.town>

* Remove unnecessary export

Signed-off-by: Robin Townsend <robin@robin.town>

* Ack Jitsi events when we wait for them

Signed-off-by: Robin Townsend <robin@robin.town>
This commit is contained in:
Robin 2022-03-22 18:14:11 -04:00 committed by GitHub
parent f416a970ca
commit cfabcdda35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1302 additions and 238 deletions

View file

@ -25,12 +25,15 @@ import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleBu
import dis from '../../../dispatcher/dispatcher';
import defaultDispatcher from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
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 DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import FacePile from "../elements/FacePile";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import NotificationBadge from "./NotificationBadge";
@ -50,12 +53,19 @@ import IconizedContextMenu, {
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import VoiceChannelStore, { VoiceChannelEvent, IJitsiParticipant } from "../../../stores/VoiceChannelStore";
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 {
Disconnected,
Connecting,
Connected,
}
interface IProps {
room: Room;
showMessagePreview: boolean;
@ -70,6 +80,8 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
voiceConnectionState: VoiceConnectionState;
voiceParticipants: IJitsiParticipant[];
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -88,6 +100,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
private isVoiceRoom: boolean;
constructor(props: IProps) {
super(props);
@ -96,14 +109,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
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,
voiceParticipants: [],
};
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();
}
private onRoomNameUpdate = (room: Room) => {
@ -238,7 +254,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
});
};
private onTileClick = (ev: React.KeyboardEvent) => {
private onTileClick = async (ev: React.KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();
@ -252,6 +268,11 @@ 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) => {
@ -576,6 +597,68 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
}
private updateVoiceParticipants = (participants: IJitsiParticipant[]) => {
this.setState({ voiceParticipants: participants });
};
private renderVoiceChannel(): React.ReactElement {
if (!this.state.voiceParticipants.length) return null;
const faces = this.state.voiceParticipants.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}
/>,
);
// 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));
try {
await VoiceChannelStore.instance.connect(this.props.room.roomId);
this.setState({ voiceConnectionState: VoiceConnectionState.Connected });
VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, () => {
this.setState({
voiceConnectionState: VoiceConnectionState.Disconnected,
voiceParticipants: [],
}),
VoiceChannelStore.instance.off(VoiceChannelEvent.Participants, this.updateVoiceParticipants);
});
VoiceChannelStore.instance.on(VoiceChannelEvent.Participants, this.updateVoiceParticipants);
} catch (e) {
logger.error("Failed to connect voice", e);
this.setState({ voiceConnectionState: VoiceConnectionState.Disconnected });
}
}
public render(): React.ReactElement {
const classes = classNames({
'mx_RoomTile': true,
@ -607,11 +690,39 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
}
let messagePreview = null;
if (this.showMessagePreview && this.state.messagePreview) {
messagePreview = (
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>
);
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>
);
}
} else if (this.showMessagePreview && this.state.messagePreview) {
subtitle = (
<div
className="mx_RoomTile_messagePreview"
className="mx_RoomTile_subtitle"
id={messagePreviewId(this.props.room.roomId)}
title={this.state.messagePreview}
>
@ -620,21 +731,20 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
}
const nameClasses = classNames({
"mx_RoomTile_name": true,
"mx_RoomTile_nameWithPreview": !!messagePreview,
"mx_RoomTile_nameHasUnreadEvents": this.notificationState.isUnread,
const titleClasses = classNames({
"mx_RoomTile_title": true,
"mx_RoomTile_titleWithSubtitle": !!subtitle,
"mx_RoomTile_titleHasUnreadEvents": this.notificationState.isUnread,
});
let nameContainer = (
<div className="mx_RoomTile_nameContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
const titleContainer = this.props.isMinimized ? null : (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={titleClasses} tabIndex={-1} dir="auto">
{ name }
</div>
{ messagePreview }
{ subtitle }
</div>
);
if (this.props.isMinimized) nameContainer = null;
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
@ -690,10 +800,15 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
oobData={({ avatarUrl: roomProfile.avatarMxc })}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
{ nameContainer }
{ badge }
{ this.renderGeneralMenu() }
{ this.renderNotificationsMenu(isActive) }
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{ titleContainer }
{ badge }
{ this.renderGeneralMenu() }
{ this.renderNotificationsMenu(isActive) }
</div>
{ this.renderVoiceChannel() }
</div>
</Button>
}
</RovingTabIndexWrapper>