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:
parent
f416a970ca
commit
cfabcdda35
32 changed files with 1302 additions and 238 deletions
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
91
src/components/views/voip/VoiceChannelRadio.tsx
Normal file
91
src/components/views/voip/VoiceChannelRadio.tsx
Normal 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;
|
|
@ -47,6 +47,7 @@ const RoomContext = createContext<IRoomState>({
|
|||
statusBarVisible: false,
|
||||
canReact: false,
|
||||
canSendMessages: false,
|
||||
resizing: false,
|
||||
layout: Layout.Group,
|
||||
lowBandwidth: false,
|
||||
alwaysShowTimestamps: false,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)",
|
||||
|
|
|
@ -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,
|
||||
|
|
232
src/stores/VoiceChannelStore.ts
Normal file
232
src/stores/VoiceChannelStore.ts
Normal file
|
@ -0,0 +1,232 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
|
||||
|
|
32
src/utils/VoiceChannelUtils.ts
Normal file
32
src/utils/VoiceChannelUtils.ts
Normal 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);
|
||||
};
|
|
@ -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>,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue