- { badge }
+
+
+ { nameContainer }
+
+ { badge }
+
+
diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx
index dababf8e75..3510141043 100644
--- a/src/components/views/rooms/RoomTile.tsx
+++ b/src/components/views/rooms/RoomTile.tsx
@@ -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
{
private roomTileRef = createRef();
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 {
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 {
});
};
- 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 {
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 {
);
}
+ 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 =>
+ ,
+ );
+
+ // TODO: The below "join" button will eventually show up on text rooms
+ // with an active voice channel, but that isn't implemented yet
+ return
+
+ { this.isVoiceRoom ? null : (
+
+ { _t("Join") }
+
+ ) }
+
;
+ }
+
+ 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 {
);
}
- let messagePreview = null;
- if (this.showMessagePreview && this.state.messagePreview) {
- messagePreview = (
+ let subtitle;
+ if (this.isVoiceRoom) {
+ switch (this.state.voiceConnectionState) {
+ case VoiceConnectionState.Disconnected:
+ subtitle = (
+
+ { _t("Voice room") }
+
+ );
+ break;
+ case VoiceConnectionState.Connecting:
+ subtitle = (
+
+ { _t("Connecting...") }
+
+ );
+ break;
+ case VoiceConnectionState.Connected:
+ subtitle = (
+
+ { _t("Connected") }
+
+ );
+ }
+ } else if (this.showMessagePreview && this.state.messagePreview) {
+ subtitle = (
@@ -620,21 +731,20 @@ export default class RoomTile extends React.PureComponent
{
);
}
- 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 = (
-
-
+ const titleContainer = this.props.isMinimized ? null : (
+
+
{ name }
- { messagePreview }
+ { subtitle }
);
- 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
{
oobData={({ avatarUrl: roomProfile.avatarMxc })}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
- { nameContainer }
- { badge }
- { this.renderGeneralMenu() }
- { this.renderNotificationsMenu(isActive) }
+
+
+ { titleContainer }
+ { badge }
+ { this.renderGeneralMenu() }
+ { this.renderNotificationsMenu(isActive) }
+
+ { this.renderVoiceChannel() }
+
}
diff --git a/src/components/views/voip/VoiceChannelRadio.tsx b/src/components/views/voip/VoiceChannelRadio.tsx
new file mode 100644
index 0000000000..3a3e362e5b
--- /dev/null
+++ b/src/components/views/voip/VoiceChannelRadio.tsx
@@ -0,0 +1,91 @@
+/*
+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, useContext } from "react";
+import classNames from "classnames";
+
+import { _t } from "../../../languageHandler";
+import { useEventEmitter } from "../../../hooks/useEventEmitter";
+import MatrixClientContext from "../../../contexts/MatrixClientContext";
+import VoiceChannelStore, { VoiceChannelEvent } from "../../../stores/VoiceChannelStore";
+import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
+import AccessibleButton from "../elements/AccessibleButton";
+import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
+
+const _VoiceChannelRadio: FC<{ roomId: string }> = ({ roomId }) => {
+ const cli = useContext(MatrixClientContext);
+ const room = cli.getRoom(roomId);
+ const store = VoiceChannelStore.instance;
+
+ const [audioMuted, setAudioMuted] = useState(store.audioMuted);
+ const [videoMuted, setVideoMuted] = useState(store.videoMuted);
+
+ useEventEmitter(store, VoiceChannelEvent.MuteAudio, () => setAudioMuted(true));
+ useEventEmitter(store, VoiceChannelEvent.UnmuteAudio, () => setAudioMuted(false));
+ useEventEmitter(store, VoiceChannelEvent.MuteVideo, () => setVideoMuted(true));
+ useEventEmitter(store, VoiceChannelEvent.UnmuteVideo, () => setVideoMuted(false));
+
+ return
+
+
+
+
{ _t("Connected") }
+
{ room.name }
+
+
store.disconnect()}
+ />
+
+
+
videoMuted ? store.unmuteVideo() : store.muteVideo()}
+ >
+ { videoMuted ? _t("Video off") : _t("Video") }
+
+
audioMuted ? store.unmuteAudio() : store.muteAudio()}
+ >
+ { audioMuted ? _t("Mic off") : _t("Mic") }
+
+
+
;
+};
+
+const VoiceChannelRadio: FC<{}> = () => {
+ const store = VoiceChannelStore.instance;
+
+ const [activeChannel, setActiveChannel] = useState(VoiceChannelStore.instance.roomId);
+ useEventEmitter(store, VoiceChannelEvent.Connect, () =>
+ setActiveChannel(VoiceChannelStore.instance.roomId),
+ );
+ useEventEmitter(store, VoiceChannelEvent.Disconnect, () =>
+ setActiveChannel(null),
+ );
+
+ return activeChannel ? <_VoiceChannelRadio roomId={activeChannel} /> : null;
+};
+
+export default VoiceChannelRadio;
diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts
index 7665c656dd..631b849e01 100644
--- a/src/contexts/RoomContext.ts
+++ b/src/contexts/RoomContext.ts
@@ -47,6 +47,7 @@ const RoomContext = createContext({
statusBarVisible: false,
canReact: false,
canSendMessages: false,
+ resizing: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,
diff --git a/src/createRoom.ts b/src/createRoom.ts
index 2677075652..0523e08b0d 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -43,6 +43,7 @@ import { isJoinedOrNearlyJoined } from "./utils/membership";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./CallHandler";
import SpaceStore from "./stores/spaces/SpaceStore";
import { makeSpaceParentEvent } from "./utils/space";
+import { addVoiceChannel } from "./utils/VoiceChannelUtils";
import { Action } from "./dispatcher/actions";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import Spinner from "./components/views/elements/Spinner";
@@ -247,6 +248,11 @@ export default async function createRoom(opts: IOpts): Promise {
if (opts.associatedWithCommunity) {
return GroupStore.addRoomToGroup(opts.associatedWithCommunity, roomId, false);
}
+ }).then(() => {
+ // Set up voice rooms with a Jitsi widget
+ if (opts.roomType === RoomType.UnstableCall) {
+ return addVoiceChannel(roomId, createOpts.name);
+ }
}).then(function() {
// NB createRoom doesn't block on the client seeing the echo that the
// room has been created, so we race here with the client knowing that
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index ddda953227..e5800fd3d4 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -880,6 +880,7 @@
"Threaded messaging": "Threaded messaging",
"Custom user status messages": "Custom user status messages",
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
+ "Voice & video rooms (under active development)": "Voice & video rooms (under active development)",
"Render simple counters in room header": "Render simple counters in room header",
"Multiple integration managers (requires manual setup)": "Multiple integration managers (requires manual setup)",
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
@@ -1017,6 +1018,12 @@
"Your camera is turned off": "Your camera is turned off",
"Your camera is still enabled": "Your camera is still enabled",
"Dial": "Dial",
+ "Connected": "Connected",
+ "Disconnect": "Disconnect",
+ "Video off": "Video off",
+ "Video": "Video",
+ "Mic off": "Mic off",
+ "Mic": "Mic",
"Dialpad": "Dialpad",
"Mute the microphone": "Mute the microphone",
"Unmute the microphone": "Unmute the microphone",
@@ -1368,7 +1375,6 @@
"The identity server you have chosen does not have any terms of service.": "The identity server you have chosen does not have any terms of service.",
"Disconnect identity server": "Disconnect identity server",
"Disconnect from the identity server ?": "Disconnect from the identity server ?",
- "Disconnect": "Disconnect",
"You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.": "You should remove your personal data from identity server before disconnecting. Unfortunately, identity server is currently offline or cannot be reached.",
"You should:": "You should:",
"check your browser plugins for anything that might block the identity server (such as Privacy Badger)": "check your browser plugins for anything that might block the identity server (such as Privacy Badger)",
@@ -1866,6 +1872,9 @@
"Low Priority": "Low Priority",
"Copy room link": "Copy room link",
"Leave": "Leave",
+ "Join": "Join",
+ "Voice room": "Voice room",
+ "Connecting...": "Connecting...",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
"%(count)s unread messages including mentions.|one": "1 unread mention.",
"%(count)s unread messages.|other": "%(count)s unread messages.",
@@ -2249,7 +2258,6 @@
"Application window": "Application window",
"Share content": "Share content",
"Backspace": "Backspace",
- "Join": "Join",
"Please create a new issue on GitHub so that we can investigate this bug.": "Please create a new issue on GitHub so that we can investigate this bug.",
"%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s",
"%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times",
@@ -2500,6 +2508,10 @@
"Create a room in %(communityName)s": "Create a room in %(communityName)s",
"Create a public room": "Create a public room",
"Create a private room": "Create a private room",
+ "Room type": "Room type",
+ "Text room": "Text room",
+ "Voice & video room": "Voice & video room",
+ "Room details": "Room details",
"Topic (optional)": "Topic (optional)",
"Room visibility": "Room visibility",
"Private room (invite only)": "Private room (invite only)",
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index fc4c4a68ce..f3cf3d281a 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -256,6 +256,15 @@ export const SETTINGS: {[setting: string]: ISetting} = {
default: false,
controller: new IncompatibleController("showCommunitiesInsteadOfSpaces", false, false),
},
+ "feature_voice_rooms": {
+ isFeature: true,
+ labsGroup: LabGroup.Rooms,
+ displayName: _td("Voice & video rooms (under active development)"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ // Reload to ensure that the left panel etc. get remounted
+ controller: new ReloadOnChangeController(),
+ },
"feature_state_counters": {
isFeature: true,
labsGroup: LabGroup.Rooms,
diff --git a/src/stores/VoiceChannelStore.ts b/src/stores/VoiceChannelStore.ts
new file mode 100644
index 0000000000..d505c2575f
--- /dev/null
+++ b/src/stores/VoiceChannelStore.ts
@@ -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 { EventEmitter } from "events";
+import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
+
+import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
+import { WidgetMessagingStore } from "./widgets/WidgetMessagingStore";
+import { getVoiceChannel } from "../utils/VoiceChannelUtils";
+import { timeout } from "../utils/promise";
+import WidgetUtils from "../utils/WidgetUtils";
+
+export enum VoiceChannelEvent {
+ Connect = "connect",
+ Disconnect = "disconnect",
+ Participants = "participants",
+ MuteAudio = "mute_audio",
+ UnmuteAudio = "unmute_audio",
+ MuteVideo = "mute_video",
+ UnmuteVideo = "unmute_video",
+}
+
+export interface IJitsiParticipant {
+ avatarURL: string;
+ displayName: string;
+ formattedDisplayName: string;
+ participantId: string;
+}
+
+/*
+ * Holds information about the currently active voice channel.
+ */
+export default class VoiceChannelStore extends EventEmitter {
+ private static _instance: VoiceChannelStore;
+ private static readonly TIMEOUT = 8000;
+
+ public static get instance(): VoiceChannelStore {
+ if (!VoiceChannelStore._instance) {
+ VoiceChannelStore._instance = new VoiceChannelStore();
+ }
+ return VoiceChannelStore._instance;
+ }
+
+ private activeChannel: ClientWidgetApi;
+ private _roomId: string;
+ private _participants: IJitsiParticipant[];
+ private _audioMuted: boolean;
+ private _videoMuted: boolean;
+
+ public get roomId(): string {
+ return this._roomId;
+ }
+
+ public get participants(): IJitsiParticipant[] {
+ return this._participants;
+ }
+
+ public get audioMuted(): boolean {
+ return this._audioMuted;
+ }
+
+ public get videoMuted(): boolean {
+ return this._videoMuted;
+ }
+
+ public connect = async (roomId: string) => {
+ if (this.activeChannel) await this.disconnect();
+
+ const jitsi = getVoiceChannel(roomId);
+ if (!jitsi) throw new Error(`No voice channel in room ${roomId}`);
+
+ const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(jitsi));
+ if (!messaging) throw new Error(`Failed to bind voice channel in room ${roomId}`);
+
+ this.activeChannel = messaging;
+ this._roomId = roomId;
+
+ // Participant data and mute state will come down the event pipeline very quickly,
+ // so prepare in advance
+ messaging.on(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
+ messaging.on(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
+ messaging.on(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
+ messaging.on(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
+ messaging.on(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
+
+ // Actually perform the join
+ const waitForJoin = this.waitForAction(ElementWidgetActions.JoinCall);
+ messaging.transport.send(ElementWidgetActions.JoinCall, {});
+ try {
+ await waitForJoin;
+ } catch (e) {
+ // If it timed out, clean up our advance preparations
+ this.activeChannel = null;
+ this._roomId = null;
+
+ messaging.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
+ messaging.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
+ messaging.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
+ messaging.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
+ messaging.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
+
+ throw e;
+ }
+
+ messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
+
+ this.emit(VoiceChannelEvent.Connect);
+ };
+
+ public disconnect = async () => {
+ this.assertConnected();
+
+ const waitForHangup = this.waitForAction(ElementWidgetActions.HangupCall);
+ this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {});
+ await waitForHangup;
+
+ // onHangup cleans up for us
+ };
+
+ public muteAudio = async () => {
+ this.assertConnected();
+
+ const waitForMute = this.waitForAction(ElementWidgetActions.MuteAudio);
+ this.activeChannel.transport.send(ElementWidgetActions.MuteAudio, {});
+ await waitForMute;
+ };
+
+ public unmuteAudio = async () => {
+ this.assertConnected();
+
+ const waitForUnmute = this.waitForAction(ElementWidgetActions.UnmuteAudio);
+ this.activeChannel.transport.send(ElementWidgetActions.UnmuteAudio, {});
+ await waitForUnmute;
+ };
+
+ public muteVideo = async () => {
+ this.assertConnected();
+
+ const waitForMute = this.waitForAction(ElementWidgetActions.MuteVideo);
+ this.activeChannel.transport.send(ElementWidgetActions.MuteVideo, {});
+ await waitForMute;
+ };
+
+ public unmuteVideo = async () => {
+ this.assertConnected();
+
+ const waitForUnmute = this.waitForAction(ElementWidgetActions.UnmuteVideo);
+ this.activeChannel.transport.send(ElementWidgetActions.UnmuteVideo, {});
+ await waitForUnmute;
+ };
+
+ private assertConnected = () => {
+ if (!this.activeChannel) throw new Error("Not connected to any voice channel");
+ };
+
+ private waitForAction = async (action: ElementWidgetActions) => {
+ const wait = new Promise(resolve =>
+ this.activeChannel.once(`action:${action}`, (ev: CustomEvent) => {
+ resolve();
+ this.ack(ev);
+ }),
+ );
+ if (await timeout(wait, false, VoiceChannelStore.TIMEOUT) === false) {
+ throw new Error("Communication with voice channel timed out");
+ }
+ };
+
+ private ack = (ev: CustomEvent) => {
+ this.activeChannel.transport.reply(ev.detail, {});
+ };
+
+ private onHangup = (ev: CustomEvent) => {
+ this.activeChannel.off(`action:${ElementWidgetActions.CallParticipants}`, this.onParticipants);
+ this.activeChannel.off(`action:${ElementWidgetActions.MuteAudio}`, this.onMuteAudio);
+ this.activeChannel.off(`action:${ElementWidgetActions.UnmuteAudio}`, this.onUnmuteAudio);
+ this.activeChannel.off(`action:${ElementWidgetActions.MuteVideo}`, this.onMuteVideo);
+ this.activeChannel.off(`action:${ElementWidgetActions.UnmuteVideo}`, this.onUnmuteVideo);
+
+ this._roomId = null;
+ this._participants = null;
+ this._audioMuted = null;
+ this._videoMuted = null;
+
+ this.emit(VoiceChannelEvent.Disconnect);
+ this.ack(ev);
+ // Save this for last, since ack needs activeChannel to exist
+ this.activeChannel = null;
+ };
+
+ private onParticipants = (ev: CustomEvent) => {
+ this._participants = ev.detail.data.participants as IJitsiParticipant[];
+ this.emit(VoiceChannelEvent.Participants, ev.detail.data.participants);
+ this.ack(ev);
+ };
+
+ private onMuteAudio = (ev: CustomEvent) => {
+ this._audioMuted = true;
+ this.emit(VoiceChannelEvent.MuteAudio);
+ this.ack(ev);
+ };
+
+ private onUnmuteAudio = (ev: CustomEvent) => {
+ this._audioMuted = false;
+ this.emit(VoiceChannelEvent.UnmuteAudio);
+ this.ack(ev);
+ };
+
+ private onMuteVideo = (ev: CustomEvent) => {
+ this._videoMuted = true;
+ this.emit(VoiceChannelEvent.MuteVideo);
+ this.ack(ev);
+ };
+
+ private onUnmuteVideo = (ev: CustomEvent) => {
+ this._videoMuted = false;
+ this.emit(VoiceChannelEvent.UnmuteVideo);
+ this.ack(ev);
+ };
+}
diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts
index cd591a6fb4..e58581ce92 100644
--- a/src/stores/widgets/ElementWidgetActions.ts
+++ b/src/stores/widgets/ElementWidgetActions.ts
@@ -18,7 +18,13 @@ import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
+ JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
+ CallParticipants = "io.element.participants",
+ MuteAudio = "io.element.mute_audio",
+ UnmuteAudio = "io.element.unmute_audio",
+ MuteVideo = "io.element.mute_video",
+ UnmuteVideo = "io.element.unmute_video",
StartLiveStream = "im.vector.start_live_stream",
OpenIntegrationManager = "integration_manager_open",
diff --git a/src/utils/VoiceChannelUtils.ts b/src/utils/VoiceChannelUtils.ts
new file mode 100644
index 0000000000..2b1deebddc
--- /dev/null
+++ b/src/utils/VoiceChannelUtils.ts
@@ -0,0 +1,32 @@
+/*
+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 { CallType } from "matrix-js-sdk/src/webrtc/call";
+
+import WidgetStore, { IApp } from "../stores/WidgetStore";
+import { WidgetType } from "../widgets/WidgetType";
+import WidgetUtils from "./WidgetUtils";
+
+export const VOICE_CHANNEL_ID = "io.element.voice";
+
+export const getVoiceChannel = (roomId: string): IApp => {
+ const apps = WidgetStore.instance.getApps(roomId);
+ return apps.find(app => WidgetType.JITSI.matches(app.type) && app.id === VOICE_CHANNEL_ID);
+};
+
+export const addVoiceChannel = async (roomId: string, roomName: string) => {
+ await WidgetUtils.addJitsiWidget(roomId, CallType.Voice, "Voice channel", VOICE_CHANNEL_ID, roomName);
+};
diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts
index 02bd980f03..1515b77b2c 100644
--- a/src/utils/WidgetUtils.ts
+++ b/src/utils/WidgetUtils.ts
@@ -16,11 +16,14 @@ limitations under the License.
*/
import * as url from "url";
+import { base32 } from "rfc4648";
import { Capability, IWidget, IWidgetData, MatrixCapabilities } from "matrix-widget-api";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix";
+import { CallType } from "matrix-js-sdk/src/webrtc/call";
+import { randomLowercaseString, randomUppercaseString } from "matrix-js-sdk/src/randomstring";
import { MatrixClientPeg } from '../MatrixClientPeg';
import SdkConfig from "../SdkConfig";
@@ -29,6 +32,7 @@ import WidgetEchoStore from '../stores/WidgetEchoStore';
import SettingsStore from "../settings/SettingsStore";
import { IntegrationManagers } from "../integrations/IntegrationManagers";
import { WidgetType } from "../widgets/WidgetType";
+import { Jitsi } from "../widgets/Jitsi";
import { objectClone } from "./objects";
import { _t } from "../languageHandler";
import { IApp } from "../stores/WidgetStore";
@@ -434,6 +438,42 @@ export default class WidgetUtils {
await client.setAccountData('m.widgets', userWidgets);
}
+ static async addJitsiWidget(
+ roomId: string,
+ type: CallType,
+ name: string,
+ widgetId: string,
+ oobRoomName?: string,
+ ): Promise {
+ const domain = Jitsi.getInstance().preferredDomain;
+ const auth = await Jitsi.getInstance().getJitsiAuth();
+
+ let confId;
+ if (auth === 'openidtoken-jwt') {
+ // Create conference ID from room ID
+ // For compatibility with Jitsi, use base32 without padding.
+ // More details here:
+ // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
+ confId = base32.stringify(Buffer.from(roomId), { pad: false });
+ } else {
+ // Create a random conference ID
+ confId = `Jitsi${randomUppercaseString(1)}${randomLowercaseString(23)}`;
+ }
+
+ // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
+ const widgetUrl = new URL(WidgetUtils.getLocalJitsiWrapperUrl({ auth }));
+ widgetUrl.search = ''; // Causes the URL class use searchParams instead
+ widgetUrl.searchParams.set('confId', confId);
+
+ await WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl.toString(), name, {
+ conferenceId: confId,
+ roomName: oobRoomName ?? MatrixClientPeg.get().getRoom(roomId)?.name,
+ isAudioOnly: type === CallType.Voice,
+ domain,
+ auth,
+ });
+ }
+
static makeAppConfig(
appId: string,
app: Partial,
diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx
new file mode 100644
index 0000000000..31f927c2d3
--- /dev/null
+++ b/test/components/views/rooms/RoomTile-test.tsx
@@ -0,0 +1,130 @@
+/*
+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 from "react";
+import { mount } from "enzyme";
+import { act } from "react-dom/test-utils";
+import { MatrixWidgetType } from "matrix-widget-api";
+
+import "../../../skinned-sdk";
+import { stubClient, mkStubRoom } from "../../../test-utils";
+import PlatformPeg from "../../../../src/PlatformPeg";
+import RoomTile from "../../../../src/components/views/rooms/RoomTile";
+import SettingsStore from "../../../../src/settings/SettingsStore";
+import WidgetStore from "../../../../src/stores/WidgetStore";
+import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
+import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
+import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore";
+import { DefaultTagID } from "../../../../src/stores/room-list/models";
+import DMRoomMap from "../../../../src/utils/DMRoomMap";
+import { VOICE_CHANNEL_ID } from "../../../../src/utils/VoiceChannelUtils";
+
+describe("RoomTile", () => {
+ PlatformPeg.get = () => ({ overrideBrowserShortcuts: () => false });
+ SettingsStore.getValue = setting => setting === "feature_voice_rooms";
+ beforeEach(() => {
+ stubClient();
+ DMRoomMap.makeShared();
+ });
+
+ describe("voice rooms", () => {
+ const room = mkStubRoom("!1:example.org");
+ room.isCallRoom.mockReturnValue(true);
+
+ // Set up mocks to simulate the remote end of the widget API
+ let messageSent;
+ let messageSendMock;
+ let onceMock;
+ beforeEach(() => {
+ let resolveMessageSent;
+ messageSent = new Promise(resolve => resolveMessageSent = resolve);
+ messageSendMock = jest.fn().mockImplementation(() => resolveMessageSent());
+ onceMock = jest.fn();
+
+ jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([{
+ id: VOICE_CHANNEL_ID,
+ eventId: "$1:example.org",
+ roomId: "!1:example.org",
+ type: MatrixWidgetType.JitsiMeet,
+ url: "",
+ name: "Voice channel",
+ creatorUserId: "@alice:example.org",
+ avatar_url: null,
+ }]);
+ jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue({
+ on: () => {},
+ off: () => {},
+ once: onceMock,
+ transport: {
+ send: messageSendMock,
+ reply: () => {},
+ },
+ });
+ });
+
+ it("tracks connection state", async () => {
+ const tile = mount(
+ ,
+ );
+ expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Voice room");
+
+ act(() => { tile.simulate("click"); });
+ tile.update();
+ expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connecting...");
+
+ // Wait for the VoiceChannelStore to connect to the widget API
+ await messageSent;
+ // Then, locate the callback that will confirm the join
+ const [, join] = onceMock.mock.calls.find(([action]) =>
+ action === `action:${ElementWidgetActions.JoinCall}`,
+ );
+
+ // Now we confirm the join and wait for the VoiceChannelStore to update
+ const waitForConnect = new Promise(resolve =>
+ VoiceChannelStore.instance.once(VoiceChannelEvent.Connect, resolve),
+ );
+ join({ detail: {} });
+ await waitForConnect;
+ // Wait yet another tick for the room tile to update
+ await Promise.resolve();
+
+ tile.update();
+ expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Connected");
+
+ // Locate the callback that will perform the hangup
+ const [, hangup] = onceMock.mock.calls.find(([action]) =>
+ action === `action:${ElementWidgetActions.HangupCall}`,
+ );
+
+ // Hangup and wait for the VoiceChannelStore, once again
+ const waitForHangup = new Promise(resolve =>
+ VoiceChannelStore.instance.once(VoiceChannelEvent.Disconnect, resolve),
+ );
+ hangup({ detail: {} });
+ await waitForHangup;
+ // Wait yet another tick for the room tile to update
+ await Promise.resolve();
+
+ tile.update();
+ expect(tile.find(".mx_RoomTile_voiceIndicator").text()).toEqual("Voice room");
+ });
+ });
+});
diff --git a/test/components/views/voip/VoiceChannelRadio-test.tsx b/test/components/views/voip/VoiceChannelRadio-test.tsx
new file mode 100644
index 0000000000..c02545ab70
--- /dev/null
+++ b/test/components/views/voip/VoiceChannelRadio-test.tsx
@@ -0,0 +1,141 @@
+/*
+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 { EventEmitter } from "events";
+import React from "react";
+import { mount } from "enzyme";
+import { act } from "react-dom/test-utils";
+
+import "../../../skinned-sdk";
+import { stubClient, mkStubRoom, wrapInMatrixClientContext } from "../../../test-utils";
+import _VoiceChannelRadio from "../../../../src/components/views/voip/VoiceChannelRadio";
+import VoiceChannelStore, { VoiceChannelEvent } from "../../../../src/stores/VoiceChannelStore";
+import DMRoomMap from "../../../../src/utils/DMRoomMap";
+
+const VoiceChannelRadio = wrapInMatrixClientContext(_VoiceChannelRadio);
+
+class StubVoiceChannelStore extends EventEmitter {
+ private _roomId: string;
+ public get roomId(): string { return this._roomId; }
+ private _audioMuted: boolean;
+ public get audioMuted(): boolean { return this._audioMuted; }
+ private _videoMuted: boolean;
+ public get videoMuted(): boolean { return this._videoMuted; }
+
+ public connect = jest.fn().mockImplementation(async (roomId: string) => {
+ this._roomId = roomId;
+ this._audioMuted = true;
+ this._videoMuted = true;
+ this.emit(VoiceChannelEvent.Connect);
+ });
+ public disconnect = jest.fn().mockImplementation(async () => {
+ this._roomId = null;
+ this.emit(VoiceChannelEvent.Disconnect);
+ });
+ public muteAudio = jest.fn().mockImplementation(async () => {
+ this._audioMuted = true;
+ this.emit(VoiceChannelEvent.MuteAudio);
+ });
+ public unmuteAudio = jest.fn().mockImplementation(async () => {
+ this._audioMuted = false;
+ this.emit(VoiceChannelEvent.UnmuteAudio);
+ });
+ public muteVideo = jest.fn().mockImplementation(async () => {
+ this._videoMuted = true;
+ this.emit(VoiceChannelEvent.MuteVideo);
+ });
+ public unmuteVideo = jest.fn().mockImplementation(async () => {
+ this._videoMuted = false;
+ this.emit(VoiceChannelEvent.UnmuteVideo);
+ });
+}
+
+describe("VoiceChannelRadio", () => {
+ const room = mkStubRoom("!1:example.org");
+ room.isCallRoom.mockReturnValue(true);
+
+ beforeEach(() => {
+ stubClient();
+ DMRoomMap.makeShared();
+ // Stub out the VoiceChannelStore
+ jest.spyOn(VoiceChannelStore, "instance", "get").mockReturnValue(new StubVoiceChannelStore());
+ });
+
+ it("shows when connecting voice", async () => {
+ const radio = mount();
+ expect(radio.children().children().exists()).toEqual(false);
+
+ act(() => { VoiceChannelStore.instance.connect("!1:example.org"); });
+ radio.update();
+ expect(radio.children().children().exists()).toEqual(true);
+ });
+
+ it("hides when disconnecting voice", () => {
+ VoiceChannelStore.instance.connect("!1:example.org");
+ const radio = mount();
+ expect(radio.children().children().exists()).toEqual(true);
+
+ act(() => { VoiceChannelStore.instance.disconnect(); });
+ radio.update();
+ expect(radio.children().children().exists()).toEqual(false);
+ });
+
+ describe("disconnect button", () => {
+ it("works", () => {
+ VoiceChannelStore.instance.connect("!1:example.org");
+ const radio = mount();
+
+ act(() => {
+ radio.find("AccessibleButton.mx_VoiceChannelRadio_disconnectButton").simulate("click");
+ });
+ expect(VoiceChannelStore.instance.disconnect).toHaveBeenCalled();
+ });
+ });
+
+ describe("video button", () => {
+ it("works", () => {
+ VoiceChannelStore.instance.connect("!1:example.org");
+ const radio = mount();
+
+ act(() => {
+ radio.find("AccessibleButton.mx_VoiceChannelRadio_videoButton").simulate("click");
+ });
+ expect(VoiceChannelStore.instance.unmuteVideo).toHaveBeenCalled();
+
+ act(() => {
+ radio.find("AccessibleButton.mx_VoiceChannelRadio_videoButton").simulate("click");
+ });
+ expect(VoiceChannelStore.instance.muteVideo).toHaveBeenCalled();
+ });
+ });
+
+ describe("audio button", () => {
+ it("works", () => {
+ VoiceChannelStore.instance.connect("!1:example.org");
+ const radio = mount();
+
+ act(() => {
+ radio.find("AccessibleButton.mx_VoiceChannelRadio_audioButton").simulate("click");
+ });
+ expect(VoiceChannelStore.instance.unmuteAudio).toHaveBeenCalled();
+
+ act(() => {
+ radio.find("AccessibleButton.mx_VoiceChannelRadio_audioButton").simulate("click");
+ });
+ expect(VoiceChannelStore.instance.muteAudio).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/test/end-to-end-tests/src/usecases/accept-invite.ts b/test/end-to-end-tests/src/usecases/accept-invite.ts
index 76510af8b9..727497dad6 100644
--- a/test/end-to-end-tests/src/usecases/accept-invite.ts
+++ b/test/end-to-end-tests/src/usecases/accept-invite.ts
@@ -21,7 +21,7 @@ import { ElementSession } from "../session";
export async function acceptInvite(session: ElementSession, name: string): Promise {
session.log.step(`accepts "${name}" invite`);
const inviteSublist = await findSublist(session, "invites");
- const invitesHandles = await inviteSublist.$$(".mx_RoomTile_name");
+ const invitesHandles = await inviteSublist.$$(".mx_RoomTile_title");
const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => {
const text = await session.innerText(inviteHandle);
return { inviteHandle, text };
diff --git a/test/end-to-end-tests/src/usecases/select-room.ts b/test/end-to-end-tests/src/usecases/select-room.ts
index 10a37fe545..29bb726c5c 100644
--- a/test/end-to-end-tests/src/usecases/select-room.ts
+++ b/test/end-to-end-tests/src/usecases/select-room.ts
@@ -20,7 +20,7 @@ import { ElementSession } from "../session";
export async function selectRoom(session: ElementSession, name: string): Promise {
session.log.step(`select "${name}" room`);
const inviteSublist = await findSublist(session, "rooms");
- const invitesHandles = await inviteSublist.$$(".mx_RoomTile_name");
+ const invitesHandles = await inviteSublist.$$(".mx_RoomTile_title");
const invitesWithText = await Promise.all(invitesHandles.map(async (roomHandle) => {
const text = await session.innerText(roomHandle);
return { roomHandle, text };
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index b7b39d15ce..a3e8ac0804 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -353,7 +353,8 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl
name,
getAvatarUrl: () => 'mxc://avatar.url/room.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
- isSpaceRoom: jest.fn(() => false),
+ isSpaceRoom: jest.fn().mockReturnValue(false),
+ isCallRoom: jest.fn().mockReturnValue(false),
getUnreadNotificationCount: jest.fn(() => 0),
getEventReadUpTo: jest.fn(() => null),
getCanonicalAlias: jest.fn(),