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

@ -18,7 +18,6 @@ limitations under the License.
*/
import React from 'react';
import { base32 } from "rfc4648";
import {
CallError,
CallErrorCode,
@ -29,7 +28,6 @@ import {
MatrixCall,
} from "matrix-js-sdk/src/webrtc/call";
import { logger } from 'matrix-js-sdk/src/logger';
import { randomLowercaseString, randomUppercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import { RuleId, TweakName, Tweaks } from "matrix-js-sdk/src/@types/PushRules";
import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor';
@ -42,7 +40,6 @@ import { _t } from './languageHandler';
import dis from './dispatcher/dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import SettingsStore from './settings/SettingsStore';
import { Jitsi } from "./widgets/Jitsi";
import { WidgetType } from "./widgets/WidgetType";
import { SettingLevel } from "./settings/SettingLevel";
import QuestionDialog from "./components/views/dialogs/QuestionDialog";
@ -1023,65 +1020,26 @@ export default class CallHandler extends EventEmitter {
return false;
}
private async placeJitsiCall(roomId: string, type: string): Promise<void> {
logger.info("Place conference call in " + roomId);
private async placeJitsiCall(roomId: string, type: CallType): Promise<void> {
const client = MatrixClientPeg.get();
logger.info(`Place conference call in ${roomId}`);
Analytics.trackEvent('voip', 'placeConferenceCall');
dis.dispatch({
action: 'appsDrawer',
show: true,
});
dis.dispatch({ action: 'appsDrawer', show: true });
// prevent double clicking the call button
const room = MatrixClientPeg.get().getRoom(roomId);
const jitsiWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.JITSI.matches(app.type));
if (jitsiWidget) {
// If there already is a Jitsi widget pin it
WidgetLayoutStore.instance.moveToContainer(room, jitsiWidget, Container.Top);
// Prevent double clicking the call button
const widget = WidgetStore.instance.getApps(roomId).find(app => WidgetType.JITSI.matches(app.type));
if (widget) {
// If there already is a Jitsi widget, pin it
WidgetLayoutStore.instance.moveToContainer(client.getRoom(roomId), widget, Container.Top);
return;
}
const jitsiDomain = Jitsi.getInstance().preferredDomain;
const jitsiAuth = await Jitsi.getInstance().getJitsiAuth();
let confId;
if (jitsiAuth === '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
const random = randomUppercaseString(1) + randomLowercaseString(23);
confId = 'Jitsi' + random;
}
let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({ auth: jitsiAuth });
// TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
const parsedUrl = new URL(widgetUrl);
parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
parsedUrl.searchParams.set('confId', confId);
widgetUrl = parsedUrl.toString();
const widgetData = {
conferenceId: confId,
isAudioOnly: type === 'voice',
domain: jitsiDomain,
auth: jitsiAuth,
roomName: room.name,
};
const widgetId = (
'jitsi_' +
MatrixClientPeg.get().credentials.userId +
'_' +
Date.now()
);
WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
try {
const userId = client.credentials.userId;
await WidgetUtils.addJitsiWidget(roomId, type, 'Jitsi', `jitsi_${userId}_${Date.now()}`);
logger.log('Jitsi widget added');
}).catch((e) => {
} catch (e) {
if (e.errcode === 'M_FORBIDDEN') {
Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
title: _t('Permission Required'),
@ -1089,7 +1047,7 @@ export default class CallHandler extends EventEmitter {
});
}
logger.error(e);
});
}
}
public terminateCallApp(roomId: string): void {

View file

@ -41,6 +41,7 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
import IndicatorScrollbar from "./IndicatorScrollbar";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import SettingsStore from "../../settings/SettingsStore";
import VoiceChannelRadio from "../views/voip/VoiceChannelRadio";
import UserMenu from "./UserMenu";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
@ -443,6 +444,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{ roomList }
</div>
</div>
{ SettingsStore.getValue("feature_voice_rooms") && <VoiceChannelRadio /> }
</aside>
</div>
);

View file

@ -75,6 +75,8 @@ import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import WidgetStore from "../../stores/WidgetStore";
import { getVoiceChannel } from "../../utils/VoiceChannelUtils";
import AppTile from "../views/elements/AppTile";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
@ -137,7 +139,7 @@ interface IRoomProps extends MatrixClientProps {
enum MainSplitContentType {
Timeline,
MaximisedWidget,
// Video
Video, // immersive voip
}
export interface IRoomState {
room?: Room;
@ -189,6 +191,7 @@ export interface IRoomState {
canReact: boolean;
canSendMessages: boolean;
tombstone?: MatrixEvent;
resizing: boolean;
layout: Layout;
lowBandwidth: boolean;
alwaysShowTimestamps: boolean;
@ -261,6 +264,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
statusBarVisible: false,
canReact: false,
canSendMessages: false,
resizing: false,
layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"),
alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
@ -302,6 +306,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.props.resizeNotifier.on("isResizing", this.onIsResizing);
this.settingWatchers = [
SettingsStore.watchSetting("layout", null, (...[,,, value]) =>
this.setState({ layout: value as Layout }),
@ -327,6 +333,10 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
];
}
private onIsResizing = (resizing: boolean) => {
this.setState({ resizing });
};
private onWidgetStoreUpdate = () => {
if (!this.state.room) return;
this.checkWidgets(this.state.room);
@ -366,10 +376,13 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
};
private getMainSplitContentType = (room) => {
// TODO-video check if video should be displayed in main panel
return (WidgetLayoutStore.instance.hasMaximisedWidget(room))
? MainSplitContentType.MaximisedWidget
: MainSplitContentType.Timeline;
if (SettingsStore.getValue("feature_voice_rooms") && room.isCallRoom()) {
return MainSplitContentType.Video;
}
if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
return MainSplitContentType.MaximisedWidget;
}
return MainSplitContentType.Timeline;
};
private onRoomViewStoreUpdate = async (initial?: boolean): Promise<void> => {
@ -729,6 +742,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate);
WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate);
this.props.resizeNotifier.off("isResizing", this.onIsResizing);
if (this.state.room) {
WidgetLayoutStore.instance.off(
WidgetLayoutStore.emissionForRoom(this.state.room),
@ -2089,28 +2104,27 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
const showChatEffects = SettingsStore.getValue('showChatEffects');
let mainSplitBody;
// Decide what to show in the main split
let mainSplitBody = <React.Fragment>
<Measured
sensor={this.roomViewBody.current}
onMeasurement={this.onMeasurement}
/>
{ auxPanel }
<div className={timelineClasses}>
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</React.Fragment>;
switch (this.state.mainSplitContentType) {
case MainSplitContentType.Timeline:
// keep the timeline in as the mainSplitBody
mainSplitBody = <>
<Measured
sensor={this.roomViewBody.current}
onMeasurement={this.onMeasurement}
/>
{ auxPanel }
<div className={timelineClasses}>
<FileDropTarget parent={this.roomView.current} onFileDrop={this.onFileDrop} />
{ topUnreadMessagesBar }
{ jumpToBottom }
{ messagePanel }
{ searchResultsPanel }
</div>
{ statusBarArea }
{ previewBar }
{ messageComposer }
</>;
break;
case MainSplitContentType.MaximisedWidget:
mainSplitBody = <AppsDrawer
@ -2120,15 +2134,26 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
showApps={true}
/>;
break;
// TODO-video MainSplitContentType.Video:
// break;
case MainSplitContentType.Video: {
const app = getVoiceChannel(this.state.room.roomId);
if (!app) break;
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}
/>;
}
}
let excludedRightPanelPhaseButtons = [RightPanelPhases.Timeline];
let onAppsClick = this.onAppsClick;
let onForgetClick = this.onForgetClick;
let onSearchClick = this.onSearchClick;
if (this.state.mainSplitContentType === MainSplitContentType.MaximisedWidget) {
// Disable phase buttons and action button to have a simplified header when a widget is maximised
if (this.state.mainSplitContentType !== MainSplitContentType.Timeline) {
// Disable phase buttons and action button to have a simplified header
// and enable (not disable) the RightPanelPhases.Timeline button
excludedRightPanelPhaseButtons = [
RightPanelPhases.ThreadPanel,

View file

@ -58,7 +58,7 @@ import {
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import SpaceStore from "../../stores/spaces/SpaceStore";
import FacePile from "../views/elements/FacePile";
import { RoomFacePile } from "../views/elements/FacePile";
import {
AddExistingToSpace,
defaultDmsRenderer,
@ -354,7 +354,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>
@ -495,7 +495,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
</div>
<div className="mx_SpaceRoomView_landing_info">
<SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
<RoomFacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton }
{ settingsButton }
</div>

View file

@ -17,16 +17,20 @@ limitations under the License.
import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomType } from "matrix-js-sdk/src/@types/event";
import { JoinRule, Preset, Visibility } from "matrix-js-sdk/src/@types/partials";
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
import withValidation, { IFieldState } from '../elements/Validation';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { IOpts, privateShouldBeEncrypted } from "../../../createRoom";
import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Heading from "../typography/Heading";
import Field from "../elements/Field";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
@ -45,6 +49,7 @@ interface IProps {
}
interface IState {
type?: RoomType;
joinRule: JoinRule;
isPublic: boolean;
isEncrypted: boolean;
@ -76,6 +81,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
}
this.state = {
type: null,
isPublic: this.props.defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(),
joinRule,
@ -95,6 +101,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
private roomCreateOptions() {
const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = opts.createOpts = {};
opts.roomType = this.state.type;
createOpts.name = this.state.name;
if (this.state.joinRule === JoinRule.Public) {
@ -178,6 +185,10 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
this.props.onFinished(false);
};
private onTypeChange = (type: RoomType | "text") => {
this.setState({ type: type === "text" ? null : type });
};
private onNameChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ name: ev.target.value });
};
@ -337,6 +348,20 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content">
{ SettingsStore.getValue("feature_voice_rooms") ? <>
<Heading size="h3">{ _t("Room type") }</Heading>
<StyledRadioGroup
name="type"
value={this.state.type ?? "text"}
onChange={this.onTypeChange}
definitions={[
{ value: "text", label: _t("Text room") },
{ value: RoomType.UnstableCall, label: _t("Voice & video room") },
]}
/>
<Heading size="h3">{ _t("Room details") }</Heading>
</> : null }
<Field
ref={this.nameField}
label={_t('Name')}

View file

@ -26,17 +26,48 @@ import TextWithTooltip from "../elements/TextWithTooltip";
import { useRoomMembers } from "../../../hooks/useRoomMembers";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
interface IProps extends HTMLAttributes<HTMLSpanElement> {
faces: ReactNode[];
overflow: boolean;
tooltip?: ReactNode;
children?: ReactNode;
}
const FacePile = ({ faces, overflow, tooltip, children, ...props }: IProps) => {
const pileContents = <>
{ overflow ? <span className="mx_FacePile_more" /> : null }
{ faces }
</>;
return <div {...props} className="mx_FacePile">
{ tooltip ? (
<TextWithTooltip class="mx_FacePile_faces" tooltip={tooltip} tooltipProps={{ yOffset: 32 }}>
{ pileContents }
</TextWithTooltip>
) : (
<div className="mx_FacePile_faces">
{ pileContents }
</div>
) }
{ children }
</div>;
};
export default FacePile;
const DEFAULT_NUM_FACES = 5;
interface IProps extends HTMLAttributes<HTMLSpanElement> {
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
interface IRoomProps extends HTMLAttributes<HTMLSpanElement> {
room: Room;
onlyKnownUsers?: boolean;
numShown?: number;
}
const isKnownMember = (member: RoomMember) => !!DMRoomMap.shared().getDMRoomsForUserId(member.userId)?.length;
const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IProps) => {
export const RoomFacePile = (
{ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, ...props }: IRoomProps,
) => {
const cli = useContext(MatrixClientContext);
const isJoined = room.getMyMembership() === "join";
let members = useRoomMembers(room);
@ -58,6 +89,8 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_FACES, .
// 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(", ");
const faces = shownMembers.map(m =>
<MemberAvatar key={m.userId} member={m} width={28} height={28} />);
let tooltip: ReactNode;
if (props.onClick) {
@ -90,16 +123,9 @@ const FacePile = ({ room, onlyKnownUsers = true, numShown = DEFAULT_NUM_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>
return <FacePile faces={faces} overflow={members.length > numShown} tooltip={tooltip}>
{ onlyKnownUsers && <span className="mx_FacePile_summary">
{ _t("%(count)s people you know have already joined", { count: members.length }) }
</span> }
</div>;
</FacePile>;
};
export default FacePile;

View file

@ -126,7 +126,7 @@ export default class GroupInviteTile extends React.Component {
const av = <BaseAvatar name={groupName} width={24} height={24} url={httpAvatarUrl} />;
const isMenuDisplayed = Boolean(this.state.contextMenuPosition);
const nameClasses = classNames('mx_RoomTile_name mx_RoomTile_invite mx_RoomTile_badgeShown', {
const nameClasses = classNames('mx_RoomTile_title mx_RoomTile_invite mx_RoomTile_badgeShown', {
'mx_RoomTile_badgeShown': this.state.badgeHover || isMenuDisplayed,
});
@ -180,17 +180,21 @@ export default class GroupInviteTile extends React.Component {
<div className="mx_RoomTile_avatar">
{ av }
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
tabIndex={isActive ? 0 : -1}
>
{ badgeContent }
</ContextMenuButton>
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
<div className="mx_RoomTile_titleContainer">
{ label }
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
tabIndex={isActive ? 0 : -1}
>
{ badgeContent }
</ContextMenuButton>
</div>
</div>
</div>
{ tooltip }
</AccessibleButton>

View file

@ -79,12 +79,12 @@ export default class ExtraTile extends React.Component<IProps, IState> {
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
"mx_RoomTile_name": true,
"mx_RoomTile_nameHasUnreadEvents": this.props.notificationState?.isUnread,
"mx_RoomTile_title": true,
"mx_RoomTile_titleHasUnreadEvents": this.props.notificationState?.isUnread,
});
let nameContainer = (
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_titleContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{ name }
</div>
@ -110,9 +110,13 @@ export default class ExtraTile extends React.Component<IProps, IState> {
<div className="mx_RoomTile_avatarContainer">
{ this.props.avatar }
</div>
{ nameContainer }
<div className="mx_RoomTile_badgeContainer">
{ badge }
<div className="mx_RoomTile_details">
<div className="mx_RoomTile_primaryDetails">
{ nameContainer }
<div className="mx_RoomTile_badgeContainer">
{ badge }
</div>
</div>
</div>
</Button>
</React.Fragment>

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>

View file

@ -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<boolean>(store.audioMuted);
const [videoMuted, setVideoMuted] = useState<boolean>(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 <div className="mx_VoiceChannelRadio">
<div className="mx_VoiceChannelRadio_statusBar">
<DecoratedRoomAvatar room={room} avatarSize={36} />
<div className="mx_VoiceChannelRadio_titleContainer">
<div className="mx_VoiceChannelRadio_status">{ _t("Connected") }</div>
<div className="mx_VoiceChannelRadio_name">{ room.name }</div>
</div>
<AccessibleTooltipButton
className="mx_VoiceChannelRadio_disconnectButton"
title={_t("Disconnect")}
onClick={() => store.disconnect()}
/>
</div>
<div className="mx_VoiceChannelRadio_controlBar">
<AccessibleButton
className={classNames({
"mx_VoiceChannelRadio_videoButton": true,
"mx_VoiceChannelRadio_button_active": !videoMuted,
})}
onClick={() => videoMuted ? store.unmuteVideo() : store.muteVideo()}
>
{ videoMuted ? _t("Video off") : _t("Video") }
</AccessibleButton>
<AccessibleButton
className={classNames({
"mx_VoiceChannelRadio_audioButton": true,
"mx_VoiceChannelRadio_button_active": !audioMuted,
})}
onClick={() => audioMuted ? store.unmuteAudio() : store.muteAudio()}
>
{ audioMuted ? _t("Mic off") : _t("Mic") }
</AccessibleButton>
</div>
</div>;
};
const VoiceChannelRadio: FC<{}> = () => {
const store = VoiceChannelStore.instance;
const [activeChannel, setActiveChannel] = useState<string>(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;

View file

@ -47,6 +47,7 @@ const RoomContext = createContext<IRoomState>({
statusBarVisible: false,
canReact: false,
canSendMessages: false,
resizing: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,

View file

@ -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<string | null> {
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

View file

@ -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 <idserver />?": "Disconnect from the identity server <idserver />?",
"Disconnect": "Disconnect",
"You should <b>remove your personal data</b> from identity server <idserver /> before disconnecting. Unfortunately, identity server <idserver /> is currently offline or cannot be reached.": "You should <b>remove your personal data</b> from identity server <idserver /> before disconnecting. Unfortunately, identity server <idserver /> 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 <newIssueLink>create a new issue</newIssueLink> on GitHub so that we can investigate this bug.": "Please <newIssueLink>create a new issue</newIssueLink> 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)",

View file

@ -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,

View 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 { 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<void>(resolve =>
this.activeChannel.once(`action:${action}`, (ev: CustomEvent<IWidgetApiRequest>) => {
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<IWidgetApiRequest>) => {
this.activeChannel.transport.reply(ev.detail, {});
};
private onHangup = (ev: CustomEvent<IWidgetApiRequest>) => {
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<IWidgetApiRequest>) => {
this._participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VoiceChannelEvent.Participants, ev.detail.data.participants);
this.ack(ev);
};
private onMuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
this._audioMuted = true;
this.emit(VoiceChannelEvent.MuteAudio);
this.ack(ev);
};
private onUnmuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
this._audioMuted = false;
this.emit(VoiceChannelEvent.UnmuteAudio);
this.ack(ev);
};
private onMuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
this._videoMuted = true;
this.emit(VoiceChannelEvent.MuteVideo);
this.ack(ev);
};
private onUnmuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
this._videoMuted = false;
this.emit(VoiceChannelEvent.UnmuteVideo);
this.ack(ev);
};
}

View file

@ -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",

View file

@ -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);
};

View file

@ -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<void> {
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<IApp>,