Prepare for Element Call integration (#9224)

* Improve accessibility and testability of Tooltip

Adding a role to Tooltip was motivated by React Testing Library's
reliance on accessibility-related attributes to locate elements.

* Make the ReadyWatchingStore constructor safer

The ReadyWatchingStore constructor previously had a chance to
immediately call onReady, which was dangerous because it was potentially
calling the derived class's onReady at a point when the derived class
hadn't even finished construction yet. In normal usage, I guess this
never was a problem, but it was causing some of the tests I was writing
to crash. This is solved by separating out the onReady call into a start
method.

* Rename 1:1 call components to 'LegacyCall'

to reflect the fact that they're slated for removal, and to not clash
with the new Call code.

* Refactor VideoChannelStore into Call and CallStore

Call is an abstract class that currently only has a Jitsi
implementation, but this will make it easy to later add an Element Call
implementation.

* Remove WidgetReady, ClientReady, and ForceHangupCall hacks

These are no longer used by the new Jitsi call implementation, and can
be removed.

* yarn i18n

* Delete call map entries instead of inserting nulls

* Allow multiple active calls and consolidate call listeners

* Fix a race condition when creating a video room

* Un-hardcode the media device fallback labels

* Apply misc code review fixes

* yarn i18n

* Disconnect from calls more politely on logout

* Fix some strict mode errors

* Fix another updateRoom race condition
This commit is contained in:
Robin 2022-08-30 15:13:39 -04:00 committed by GitHub
parent 50f6986f6c
commit 0d6a550c33
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
107 changed files with 2573 additions and 2157 deletions

View file

@ -33,7 +33,7 @@ import { Notifier } from "../Notifier";
import type { Renderer } from "react-dom";
import RightPanelStore from "../stores/right-panel/RightPanelStore";
import WidgetStore from "../stores/WidgetStore";
import CallHandler from "../CallHandler";
import LegacyCallHandler from "../LegacyCallHandler";
import UserActivity from "../UserActivity";
import { ModalWidgetStore } from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
@ -89,7 +89,7 @@ declare global {
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore;
mxCallHandler: CallHandler;
mxLegacyCallHandler: LegacyCallHandler;
mxUserActivity: UserActivity;
mxModalWidgetStore: ModalWidgetStore;
mxVoipUserMapper: VoipUserMapper;

View file

@ -54,7 +54,7 @@ import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/
import SdkConfig from './SdkConfig';
import { ensureDMExists } from './createRoom';
import { Container, WidgetLayoutStore } from './stores/widgets/WidgetLayoutStore';
import IncomingCallToast, { getIncomingCallToastKey } from './toasts/IncomingCallToast';
import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from './toasts/IncomingLegacyCallToast';
import ToastStore from './stores/ToastStore';
import Resend from './Resend';
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
@ -100,7 +100,7 @@ interface ThirdpartyLookupResponse {
fields: ThirdpartyLookupResponseFields;
}
export enum CallHandlerEvent {
export enum LegacyCallHandlerEvent {
CallsChanged = "calls_changed",
CallChangeRoom = "call_change_room",
SilencedCallsChanged = "silenced_calls_changed",
@ -108,11 +108,11 @@ export enum CallHandlerEvent {
}
/**
* CallHandler manages all currently active calls. It should be used for
* LegacyCallHandler manages all currently active calls. It should be used for
* placing, answering, rejecting and hanging up calls. It also handles ringing,
* PSTN support and other things.
*/
export default class CallHandler extends EventEmitter {
export default class LegacyCallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one.
@ -130,11 +130,11 @@ export default class CallHandler extends EventEmitter {
private silencedCalls = new Set<string>(); // callIds
public static get instance() {
if (!window.mxCallHandler) {
window.mxCallHandler = new CallHandler();
if (!window.mxLegacyCallHandler) {
window.mxLegacyCallHandler = new LegacyCallHandler();
}
return window.mxCallHandler;
return window.mxLegacyCallHandler;
}
/*
@ -186,7 +186,7 @@ export default class CallHandler extends EventEmitter {
public silenceCall(callId: string): void {
this.silencedCalls.add(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
// Don't pause audio if we have calls which are still ringing
if (this.areAnyCallsUnsilenced()) return;
@ -195,7 +195,7 @@ export default class CallHandler extends EventEmitter {
public unSilenceCall(callId: string): void {
this.silencedCalls.delete(callId);
this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.emit(LegacyCallHandlerEvent.SilencedCallsChanged, this.silencedCalls);
this.play(AudioID.Ring);
}
@ -311,7 +311,7 @@ export default class CallHandler extends EventEmitter {
return;
}
const mappedRoomId = CallHandler.instance.roomIdForCall(call);
const mappedRoomId = LegacyCallHandler.instance.roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) {
logger.log(
"Got incoming call for room " + mappedRoomId +
@ -389,7 +389,7 @@ export default class CallHandler extends EventEmitter {
}
public play(audioId: AudioID): void {
const logPrefix = `CallHandler.play(${audioId}):`;
const logPrefix = `LegacyCallHandler.play(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens?
@ -424,7 +424,7 @@ export default class CallHandler extends EventEmitter {
}
public pause(audioId: AudioID): void {
const logPrefix = `CallHandler.pause(${audioId}):`;
const logPrefix = `LegacyCallHandler.pause(${audioId}):`;
logger.debug(`${logPrefix} beginning of function`);
// TODO: Attach an invisible element for this instead
// which listens?
@ -688,32 +688,32 @@ export default class CallHandler extends EventEmitter {
}
private setCallState(call: MatrixCall, status: CallState): void {
const mappedRoomId = CallHandler.instance.roomIdForCall(call);
const mappedRoomId = LegacyCallHandler.instance.roomIdForCall(call);
logger.log(
`Call state in ${mappedRoomId} changed to ${status}`,
);
const toastKey = getIncomingCallToastKey(call.callId);
const toastKey = getIncomingLegacyCallToastKey(call.callId);
if (status === CallState.Ringing) {
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
component: IncomingLegacyCallToast,
bodyClassName: "mx_IncomingLegacyCallToast",
props: { call },
});
} else {
ToastStore.sharedInstance().dismissToast(toastKey);
}
this.emit(CallHandlerEvent.CallState, mappedRoomId, status);
this.emit(LegacyCallHandlerEvent.CallState, mappedRoomId, status);
}
private removeCallForRoom(roomId: string): void {
logger.log("Removing call for room ", roomId);
this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.emit(LegacyCallHandlerEvent.CallsChanged, this.calls);
}
private showICEFallbackPrompt(): void {
@ -1115,9 +1115,9 @@ export default class CallHandler extends EventEmitter {
// Should we always emit CallsChanged too?
if (changedRooms) {
this.emit(CallHandlerEvent.CallChangeRoom, call);
this.emit(LegacyCallHandlerEvent.CallChangeRoom, call);
} else {
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.emit(LegacyCallHandlerEvent.CallsChanged, this.calls);
}
}
}

View file

@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform";
import ThreepidInviteStore from "./stores/ThreepidInviteStore";
import { PosthogAnalytics } from "./PosthogAnalytics";
import CallHandler from './CallHandler';
import LegacyCallHandler from './LegacyCallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import { _t } from "./languageHandler";
@ -59,8 +59,6 @@ import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialo
import { setSentryUser } from "./sentry";
import SdkConfig from "./SdkConfig";
import { DialogOpener } from "./utils/DialogOpener";
import VideoChannelStore from "./stores/VideoChannelStore";
import { fixStuckDevices } from "./utils/VideoChannelUtils";
import { Action } from "./dispatcher/actions";
import AbstractLocalStorageSettingsHandler from "./settings/handlers/AbstractLocalStorageSettingsHandler";
import { OverwriteLoginPayload } from "./dispatcher/payloads/OverwriteLoginPayload";
@ -808,7 +806,7 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
DMRoomMap.makeShared().start();
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.instance.start();
CallHandler.instance.start();
LegacyCallHandler.instance.start();
// Start Mjolnir even though we haven't checked the feature flag yet. Starting
// the thing just wastes CPU cycles, but should result in no actual functionality
@ -840,11 +838,6 @@ async function startMatrixClient(startSyncing = true): Promise<void> {
// Now that we have a MatrixClientPeg, update the Jitsi info
Jitsi.getInstance().start();
// In case we disconnected uncleanly from a video room, clean up the stuck device
if (VideoChannelStore.instance.roomId) {
fixStuckDevices(MatrixClientPeg.get().getRoom(VideoChannelStore.instance.roomId), false);
}
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({ action: 'client_started' });
@ -932,7 +925,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise<void
*/
export function stopMatrixClient(unsetClient = true): void {
Notifier.stop();
CallHandler.instance.stop();
LegacyCallHandler.instance.stop();
UserActivity.sharedInstance().stop();
TypingStore.sharedInstance().reset();
Presence.stop();

View file

@ -137,4 +137,18 @@ export default class MediaDeviceHandler extends EventEmitter {
case MediaDeviceKindEnum.VideoInput: return this.getVideoInput();
}
}
public static get startWithAudioMuted(): boolean {
return SettingsStore.getValue("audioInputMuted");
}
public static set startWithAudioMuted(value: boolean) {
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
}
public static get startWithVideoMuted(): boolean {
return SettingsStore.getValue("videoInputMuted");
}
public static set startWithVideoMuted(value: boolean) {
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
}
}

View file

@ -44,7 +44,7 @@ import { RoomViewStore } from "./stores/RoomViewStore";
import UserActivity from "./UserActivity";
import { mediaFromMxc } from "./customisations/Media";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import CallHandler from "./CallHandler";
import LegacyCallHandler from "./LegacyCallHandler";
import VoipUserMapper from "./VoipUserMapper";
/*
@ -397,7 +397,7 @@ export const Notifier = {
_evaluateEvent: function(ev: MatrixEvent) {
let roomId = ev.getRoomId();
if (CallHandler.instance.getSupportsVirtualRooms()) {
if (LegacyCallHandler.instance.getSupportsVirtualRooms()) {
// Attempt to translate a virtual room to a native one
const nativeRoomId = VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(roomId);
if (nativeRoomId) {

View file

@ -52,7 +52,7 @@ import SdkConfig from "./SdkConfig";
import SettingsStore from "./settings/SettingsStore";
import { UIComponent, UIFeature } from "./settings/UIFeature";
import { CHAT_EFFECTS } from "./effects";
import CallHandler from "./CallHandler";
import LegacyCallHandler from "./LegacyCallHandler";
import { guessAndSetDMRoom } from "./Rooms";
import { upgradeRoom } from './utils/RoomUpgrade';
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
@ -1183,7 +1183,7 @@ export const Commands = [
description: _td("Switches to this room's virtual room, if it has one"),
category: CommandCategories.advanced,
isEnabled(): boolean {
return CallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom();
return LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom();
},
runFn: (roomId) => {
return success((async () => {
@ -1212,7 +1212,7 @@ export const Commands = [
return success((async () => {
if (isPhoneNumber) {
const results = await CallHandler.instance.pstnLookup(this.state.value);
const results = await LegacyCallHandler.instance.pstnLookup(this.state.value);
if (!results || results.length === 0 || !results[0].userid) {
throw newTranslatableError("Unable to find Matrix ID for phone number");
}
@ -1269,7 +1269,7 @@ export const Commands = [
category: CommandCategories.other,
isEnabled: () => !isCurrentLocalRoom(),
runFn: function(roomId, args) {
const call = CallHandler.instance.getCallForRoom(roomId);
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(newTranslatableError("No active call in this room"));
}
@ -1284,7 +1284,7 @@ export const Commands = [
category: CommandCategories.other,
isEnabled: () => !isCurrentLocalRoom(),
runFn: function(roomId, args) {
const call = CallHandler.instance.getCallForRoom(roomId);
const call = LegacyCallHandler.instance.getCallForRoom(roomId);
if (!call) {
return reject(newTranslatableError("No active call in this room"));
}

View file

@ -21,7 +21,7 @@ import { EventType } from 'matrix-js-sdk/src/@types/event';
import { ensureVirtualRoomExists } from './createRoom';
import { MatrixClientPeg } from "./MatrixClientPeg";
import DMRoomMap from "./utils/DMRoomMap";
import CallHandler from './CallHandler';
import LegacyCallHandler from './LegacyCallHandler';
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import { findDMForUser } from './utils/dm/findDMForUser';
@ -39,7 +39,7 @@ export default class VoipUserMapper {
}
private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.instance.sipVirtualLookup(userId);
const results = await LegacyCallHandler.instance.sipVirtualLookup(userId);
if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid;
}
@ -118,11 +118,11 @@ export default class VoipUserMapper {
}
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.instance.getSupportsVirtualRooms()) return;
if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter();
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.instance.sipNativeLookup(inviterId);
const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId);
if (result.length === 0) {
return;
}

View file

@ -21,7 +21,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList from "../views/rooms/RoomList";
import CallHandler from "../../CallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import RoomSearch from "./RoomSearch";
@ -325,7 +325,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
// If we have dialer support, show a button to bring up the dial pad
// to start a new call
if (CallHandler.instance.getSupportsPstnProtocol()) {
if (LegacyCallHandler.instance.getSupportsPstnProtocol()) {
dialPadButton =
<AccessibleTooltipButton
className={classNames("mx_LeftPanel_dialPadButton", {})}

View file

@ -19,10 +19,10 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { EventEmitter } from 'events';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import { MatrixClientPeg } from "../../MatrixClientPeg";
export enum CallEventGrouperEvent {
export enum LegacyCallEventGrouperEvent {
StateChanged = "state_changed",
SilencedChanged = "silenced_changed",
LengthChanged = "length_changed",
@ -44,10 +44,10 @@ export enum CustomCallState {
Missed = "missed",
}
export function buildCallEventGroupers(
callEventGroupers: Map<string, CallEventGrouper>,
export function buildLegacyCallEventGroupers(
callEventGroupers: Map<string, LegacyCallEventGrouper>,
events?: MatrixEvent[],
): Map<string, CallEventGrouper> {
): Map<string, LegacyCallEventGrouper> {
const newCallEventGroupers = new Map();
events?.forEach(ev => {
if (!ev.getType().startsWith("m.call.") && !ev.getType().startsWith("org.matrix.call.")) {
@ -57,10 +57,10 @@ export function buildCallEventGroupers(
const callId = ev.getContent().call_id;
if (!newCallEventGroupers.has(callId)) {
if (callEventGroupers.has(callId)) {
// reuse the CallEventGrouper object where possible
// reuse the LegacyCallEventGrouper object where possible
newCallEventGroupers.set(callId, callEventGroupers.get(callId));
} else {
newCallEventGroupers.set(callId, new CallEventGrouper());
newCallEventGroupers.set(callId, new LegacyCallEventGrouper());
}
}
newCallEventGroupers.get(callId).add(ev);
@ -68,7 +68,7 @@ export function buildCallEventGroupers(
return newCallEventGroupers;
}
export default class CallEventGrouper extends EventEmitter {
export default class LegacyCallEventGrouper extends EventEmitter {
private events: Set<MatrixEvent> = new Set<MatrixEvent>();
private call: MatrixCall;
public state: CallState | CustomCallState;
@ -76,8 +76,10 @@ export default class CallEventGrouper extends EventEmitter {
constructor() {
super();
CallHandler.instance.addListener(CallHandlerEvent.CallsChanged, this.setCall);
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallsChanged, this.setCall);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
}
private get invite(): MatrixEvent {
@ -138,31 +140,31 @@ export default class CallEventGrouper extends EventEmitter {
}
private onSilencedCallsChanged = () => {
const newState = CallHandler.instance.isCallSilenced(this.callId);
this.emit(CallEventGrouperEvent.SilencedChanged, newState);
const newState = LegacyCallHandler.instance.isCallSilenced(this.callId);
this.emit(LegacyCallEventGrouperEvent.SilencedChanged, newState);
};
private onLengthChanged = (length: number): void => {
this.emit(CallEventGrouperEvent.LengthChanged, length);
this.emit(LegacyCallEventGrouperEvent.LengthChanged, length);
};
public answerCall = (): void => {
CallHandler.instance.answerCall(this.roomId);
LegacyCallHandler.instance.answerCall(this.roomId);
};
public rejectCall = (): void => {
CallHandler.instance.hangupOrReject(this.roomId, true);
LegacyCallHandler.instance.hangupOrReject(this.roomId, true);
};
public callBack = (): void => {
CallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
LegacyCallHandler.instance.placeCall(this.roomId, this.isVoice ? CallType.Voice : CallType.Video);
};
public toggleSilenced = () => {
const silenced = CallHandler.instance.isCallSilenced(this.callId);
const silenced = LegacyCallHandler.instance.isCallSilenced(this.callId);
silenced ?
CallHandler.instance.unSilenceCall(this.callId) :
CallHandler.instance.silenceCall(this.callId);
LegacyCallHandler.instance.unSilenceCall(this.callId) :
LegacyCallHandler.instance.silenceCall(this.callId);
};
private setCallListeners() {
@ -182,13 +184,13 @@ export default class CallEventGrouper extends EventEmitter {
else if (this.hangup) this.state = CallState.Ended;
else if (this.invite && this.call) this.state = CallState.Connecting;
}
this.emit(CallEventGrouperEvent.StateChanged, this.state);
this.emit(LegacyCallEventGrouperEvent.StateChanged, this.state);
};
private setCall = () => {
if (this.call) return;
this.call = CallHandler.instance.getCallById(this.callId);
this.call = LegacyCallHandler.instance.getCallById(this.callId);
this.setCallListeners();
this.setState();
};

View file

@ -51,8 +51,8 @@ import HostSignupContainer from '../views/host_signup/HostSignupContainer';
import { getKeyBindingsManager } from '../../KeyBindingsManager';
import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import AudioFeedArrayForLegacyCall from '../views/voip/AudioFeedArrayForLegacyCall';
import { OwnProfileStore } from '../../stores/OwnProfileStore';
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import RoomView from './RoomView';
@ -146,7 +146,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false,
activeCalls: CallHandler.instance.getAllActiveCalls(),
activeCalls: LegacyCallHandler.instance.getAllActiveCalls(),
};
// stash the MatrixClient in case we log out before we are unmounted
@ -163,7 +163,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() {
document.addEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this.updateServerNoticeEvents();
@ -195,7 +195,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() {
document.removeEventListener('keydown', this.onNativeKeyDown, false);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
this._matrixClient.removeListener(ClientEvent.AccountData, this.onAccountData);
this._matrixClient.removeListener(ClientEvent.Sync, this.onSync);
this._matrixClient.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
@ -207,7 +207,7 @@ class LoggedInView extends React.Component<IProps, IState> {
}
private onCallState = (): void => {
const activeCalls = CallHandler.instance.getAllActiveCalls();
const activeCalls = LegacyCallHandler.instance.getAllActiveCalls();
if (activeCalls === this.state.activeCalls) return;
this.setState({ activeCalls });
};
@ -658,7 +658,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
<AudioFeedArrayForLegacyCall call={call} key={call.callId} />
);
});

View file

@ -114,7 +114,7 @@ import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
import { PosthogAnalytics } from '../../PosthogAnalytics';
import { initSentry } from "../../sentry";
import CallHandler from "../../CallHandler";
import LegacyCallHandler from "../../LegacyCallHandler";
import { showSpaceInvite } from "../../utils/space";
import AccessibleButton from "../views/elements/AccessibleButton";
import { ActionPayload } from "../../dispatcher/payloads";
@ -128,7 +128,7 @@ import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStart
import { IConfigOptions } from "../../IConfigOptions";
import { SnakedObject } from "../../utils/SnakedObject";
import { leaveRoomBehaviour } from "../../utils/leave-behaviour";
import VideoChannelStore from "../../stores/VideoChannelStore";
import { CallStore } from "../../stores/CallStore";
import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators";
import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases";
@ -576,9 +576,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
break;
case 'logout':
CallHandler.instance.hangupAllCalls();
if (VideoChannelStore.instance.connected) VideoChannelStore.instance.setDisconnected();
Lifecycle.logout();
LegacyCallHandler.instance.hangupAllCalls();
Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect()))
.finally(() => Lifecycle.logout());
break;
case 'require_registration':
startAnyRegistrationFlow(payload as any);

View file

@ -40,7 +40,7 @@ import DMRoomMap from "../../utils/DMRoomMap";
import NewRoomIntro from "../views/rooms/NewRoomIntro";
import HistoryTile from "../views/rooms/HistoryTile";
import defaultDispatcher from '../../dispatcher/dispatcher';
import CallEventGrouper from "./CallEventGrouper";
import LegacyCallEventGrouper from "./LegacyCallEventGrouper";
import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile';
import ScrollPanel, { IScrollState } from "./ScrollPanel";
import GenericEventListSummary from '../views/elements/GenericEventListSummary';
@ -188,7 +188,7 @@ interface IProps {
hideThreadedMessages?: boolean;
disableGrouping?: boolean;
callEventGroupers: Map<string, CallEventGrouper>;
callEventGroupers: Map<string, LegacyCallEventGrouper>;
}
interface IState {

View file

@ -45,7 +45,7 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
import ResizeNotifier from '../../utils/ResizeNotifier';
import ContentMessages from '../../ContentMessages';
import Modal from '../../Modal';
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../LegacyCallHandler';
import dis, { defaultDispatcher } from '../../dispatcher/dispatcher';
import * as Rooms from '../../Rooms';
import eventSearch, { searchPagination } from '../../Searching';
@ -78,7 +78,7 @@ import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from '../../effects/utils';
import { CHAT_EFFECTS } from '../../effects';
import WidgetStore from "../../stores/WidgetStore";
import VideoRoomView from "./VideoRoomView";
import { VideoRoomView } from "./VideoRoomView";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast";
@ -810,7 +810,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
callState: callState,
});
CallHandler.instance.on(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.on(LegacyCallHandlerEvent.CallState, this.onCallState);
window.addEventListener('beforeunload', this.onPageUnload);
}
@ -847,7 +847,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState);
// update the scroll map before we get unmounted
if (this.state.roomId) {
@ -896,7 +896,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
);
}
CallHandler.instance.off(CallHandlerEvent.CallState, this.onCallState);
LegacyCallHandler.instance.off(LegacyCallHandlerEvent.CallState, this.onCallState);
// cancel any pending calls to the throttled updated
this.updateRoomMembers.cancel();
@ -1655,7 +1655,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
}
private onCallPlaced = (type: CallType): void => {
CallHandler.instance.placeCall(this.state.room?.roomId, type);
LegacyCallHandler.instance.placeCall(this.state.room?.roomId, type);
};
private onAppsClick = () => {
@ -1872,7 +1872,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
if (!this.state.room) {
return null;
}
return CallHandler.instance.getCallForRoom(this.state.room.roomId);
return LegacyCallHandler.instance.getCallForRoom(this.state.room.roomId);
}
// this has to be a proper method rather than an unnamed function,

View file

@ -52,7 +52,7 @@ import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Spinner from "../views/elements/Spinner";
import EditorStateTransfer from '../../utils/EditorStateTransfer';
import ErrorDialog from '../views/dialogs/ErrorDialog';
import CallEventGrouper, { buildCallEventGroupers } from "./CallEventGrouper";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "./LegacyCallEventGrouper";
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
@ -240,8 +240,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
private readReceiptActivityTimer: Timer;
private readMarkerActivityTimer: Timer;
// A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>();
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
constructor(props, context) {
super(props, context);
@ -493,7 +493,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.timelineWindow.unpaginate(count, backwards);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const newState: Partial<IState> = {
events,
liveEvents,
@ -555,7 +555,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
debuglog("paginate complete backwards:"+backwards+"; success:"+r);
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const newState: Partial<IState> = {
[paginatingKey]: false,
[canPaginateKey]: r,
@ -686,7 +686,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) { return; }
const { events, liveEvents, firstVisibleEventIndex } = this.getEvents();
this.buildCallEventGroupers(events);
this.buildLegacyCallEventGroupers(events);
const lastLiveEvent = liveEvents[liveEvents.length - 1];
const updatedState: Partial<IState> = {
@ -855,7 +855,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
// TODO: We should restrict this to only events in our timeline,
// but possibly the event tile itself should just update when this
// happens to save us re-rendering the whole timeline.
this.buildCallEventGroupers(this.state.events);
this.buildLegacyCallEventGroupers(this.state.events);
this.forceUpdate();
};
@ -1405,7 +1405,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
onLoaded();
} else {
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
this.buildCallEventGroupers();
this.buildLegacyCallEventGroupers();
this.setState({
events: [],
liveEvents: [],
@ -1426,7 +1426,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return;
const state = this.getEvents();
this.buildCallEventGroupers(state.events);
this.buildLegacyCallEventGroupers(state.events);
this.setState(state);
}
@ -1707,8 +1707,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
eventType: EventType | string,
) => this.props.timelineSet.relations?.getChildEventsForEvent(eventId, relationType, eventType);
private buildCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildCallEventGroupers(this.callEventGroupers, events);
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildLegacyCallEventGroupers(this.callEventGroupers, events);
}
render() {

View file

@ -14,79 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useContext, useState, useMemo, useEffect } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { Room } from "matrix-js-sdk/src/models/room";
import React, { FC, useContext, useEffect } from "react";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { Call } from "../../models/Call";
import { useCall, useConnectionState } from "../../hooks/useCall";
import { isConnected } from "../../models/Call";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitter } from "../../hooks/useEventEmitter";
import WidgetUtils from "../../utils/WidgetUtils";
import { addVideoChannel, getVideoChannel, fixStuckDevices } from "../../utils/VideoChannelUtils";
import WidgetStore, { IApp } from "../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import VideoChannelStore, { VideoChannelEvent } from "../../stores/VideoChannelStore";
import AppTile from "../views/elements/AppTile";
import VideoLobby from "../views/voip/VideoLobby";
import { CallLobby } from "../views/voip/CallLobby";
interface IProps {
interface Props {
room: Room;
resizing: boolean;
}
const VideoRoomView: FC<IProps> = ({ room, resizing }) => {
const LoadedVideoRoomView: FC<Props & { call: Call }> = ({ room, resizing, call }) => {
const cli = useContext(MatrixClientContext);
const store = VideoChannelStore.instance;
const connected = isConnected(useConnectionState(call));
// In case we mount before the WidgetStore knows about our Jitsi widget
const [widgetStoreReady, setWidgetStoreReady] = useState(Boolean(WidgetStore.instance.matrixClient));
const [widgetLoaded, setWidgetLoaded] = useState(false);
useEventEmitter(WidgetStore.instance, UPDATE_EVENT, (roomId: string) => {
if (roomId === null) setWidgetStoreReady(true);
if (roomId === null || roomId === room.roomId) {
setWidgetLoaded(Boolean(getVideoChannel(room.roomId)));
}
});
// We'll take this opportunity to tidy up our room state
useEffect(() => { call?.clean(); }, [call]);
const app: IApp = useMemo(() => {
if (widgetStoreReady) {
const app = getVideoChannel(room.roomId);
if (!app) {
logger.warn(`No video channel for room ${room.roomId}`);
// Since widgets in video rooms are mutable, we'll take this opportunity to
// reinstate the Jitsi widget in case another client removed it
if (WidgetUtils.canUserModifyWidgets(room.roomId)) {
addVideoChannel(room.roomId, room.name);
}
}
return app;
}
}, [room, widgetStoreReady, widgetLoaded]); // eslint-disable-line react-hooks/exhaustive-deps
// We'll also take this opportunity to fix any stuck devices.
// The linter thinks that store.connected should be a dependency, but we explicitly
// *only* want this to happen at mount to avoid racing with normal device updates.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fixStuckDevices(room, store.connected); }, [room]);
const [connected, setConnected] = useState(store.connected && store.roomId === room.roomId);
useEventEmitter(store, VideoChannelEvent.Connect, () => setConnected(store.roomId === room.roomId));
useEventEmitter(store, VideoChannelEvent.Disconnect, () => setConnected(false));
if (!app) return null;
if (!call) return null;
return <div className="mx_VideoRoomView">
{ connected ? null : <VideoLobby room={room} /> }
{ connected ? null : <CallLobby room={room} call={call} /> }
{ /* We render the widget even if we're disconnected, so it stays loaded */ }
<AppTile
app={app}
app={call.widget}
room={room}
userId={cli.credentials.userId}
creatorUserId={app.creatorUserId}
waitForIframeLoad={app.waitForIframeLoad}
creatorUserId={call.widget.creatorUserId}
waitForIframeLoad={call.widget.waitForIframeLoad}
showMenubar={false}
pointerEvents={resizing ? "none" : null}
pointerEvents={resizing ? "none" : undefined}
/>
</div>;
};
export default VideoRoomView;
export const VideoRoomView: FC<Props> = ({ room, resizing }) => {
const call = useCall(room.roomId);
return call ? <LoadedVideoRoomView room={room} resizing={resizing} call={call} /> : null;
};

View file

@ -20,13 +20,13 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { _t } from '../../../languageHandler';
import ContextMenu, { IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu';
import CallHandler from '../../../CallHandler';
import LegacyCallHandler from '../../../LegacyCallHandler';
interface IProps extends IContextMenuProps {
call: MatrixCall;
}
export default class CallContextMenu extends React.Component<IProps> {
export default class LegacyCallContextMenu extends React.Component<IProps> {
static propTypes = {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
@ -42,13 +42,13 @@ export default class CallContextMenu extends React.Component<IProps> {
};
onUnholdClick = () => {
CallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
LegacyCallHandler.instance.setActiveCallRoomId(this.props.call.roomId);
this.props.onFinished();
};
onTransferClick = () => {
CallHandler.instance.showTransferDialog(this.props.call);
LegacyCallHandler.instance.showTransferDialog(this.props.call);
this.props.onFinished();
};
@ -58,13 +58,13 @@ export default class CallContextMenu extends React.Component<IProps> {
let transferItem;
if (this.props.call.opponentCanBeTransferred()) {
transferItem = <MenuItem className="mx_CallContextMenu_item" onClick={this.onTransferClick}>
transferItem = <MenuItem className="mx_LegacyCallContextMenu_item" onClick={this.onTransferClick}>
{ _t("Transfer") }
</MenuItem>;
}
return <ContextMenu {...this.props}>
<MenuItem className="mx_CallContextMenu_item" onClick={handler}>
<MenuItem className="mx_LegacyCallContextMenu_item" onClick={handler}>
{ holdUnholdCaption }
</MenuItem>
{ transferItem }

View file

@ -56,7 +56,7 @@ import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
import UserIdentifierCustomisations from '../../../customisations/UserIdentifier';
import CopyableText from "../elements/CopyableText";
import { ScreenName } from '../../../PosthogTrackers';
@ -510,13 +510,13 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return;
}
CallHandler.instance.startTransferToMatrixID(
LegacyCallHandler.instance.startTransferToMatrixID(
this.props.call,
targetIds[0],
this.state.consultFirst,
);
} else {
CallHandler.instance.startTransferToPhoneNumber(
LegacyCallHandler.instance.startTransferToPhoneNumber(
this.props.call,
this.state.dialPadValue,
this.state.consultFirst,

View file

@ -36,10 +36,9 @@ import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu";
import PersistedElement, { getPersistKey } from "./PersistedElement";
import { WidgetType } from "../../../widgets/WidgetType";
import { ElementWidget, StopGapWidget } from "../../../stores/widgets/StopGapWidget";
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import WidgetAvatar from "../avatars/WidgetAvatar";
import CallHandler from '../../../CallHandler';
import LegacyCallHandler from '../../../LegacyCallHandler';
import { IApp } from "../../../stores/WidgetStore";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { OwnProfileStore } from '../../../stores/OwnProfileStore';
@ -305,7 +304,6 @@ export default class AppTile extends React.Component<IProps, IState> {
private setupSgListeners() {
this.sgWidget.on("preparing", this.onWidgetPreparing);
this.sgWidget.on("ready", this.onWidgetReady);
// emits when the capabilities have been set up or changed
this.sgWidget.on("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
}
@ -313,7 +311,6 @@ export default class AppTile extends React.Component<IProps, IState> {
private stopSgListeners() {
if (!this.sgWidget) return;
this.sgWidget.off("preparing", this.onWidgetPreparing);
this.sgWidget.off("ready", this.onWidgetReady);
this.sgWidget.off("capabilitiesNotified", this.onWidgetCapabilitiesNotified);
}
@ -393,7 +390,7 @@ export default class AppTile extends React.Component<IProps, IState> {
}
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.room) {
CallHandler.instance.hangupCallApp(this.props.room.roomId);
LegacyCallHandler.instance.hangupCallApp(this.props.room.roomId);
}
// Delete the widget from the persisted store for good measure.
@ -407,12 +404,6 @@ export default class AppTile extends React.Component<IProps, IState> {
this.setState({ loading: false });
};
private onWidgetReady = (): void => {
if (WidgetType.JITSI.matches(this.props.app.type)) {
this.sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
};
private onWidgetCapabilitiesNotified = (): void => {
this.setState({
requiresClient: this.sgWidget.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient),

View file

@ -181,7 +181,7 @@ export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
style.display = this.props.visible ? "block" : "none";
const tooltip = (
<div className={tooltipClasses} style={style}>
<div role="tooltip" className={tooltipClasses} style={style}>
<div className="mx_Tooltip_chevron" />
{ this.props.label }
</div>

View file

@ -21,7 +21,10 @@ import classNames from 'classnames';
import { _t } from '../../../languageHandler';
import MemberAvatar from '../avatars/MemberAvatar';
import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper';
import LegacyCallEventGrouper, {
LegacyCallEventGrouperEvent,
CustomCallState,
} from '../../structures/LegacyCallEventGrouper';
import AccessibleButton from '../elements/AccessibleButton';
import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
@ -32,7 +35,7 @@ const MAX_NON_NARROW_WIDTH = 450 / 70 * 100;
interface IProps {
mxEvent: MatrixEvent;
callEventGrouper: CallEventGrouper;
callEventGrouper: LegacyCallEventGrouper;
timestamp?: JSX.Element;
}
@ -43,7 +46,7 @@ interface IState {
length: number;
}
export default class CallEvent extends React.PureComponent<IProps, IState> {
export default class LegacyCallEvent extends React.PureComponent<IProps, IState> {
private wrapperElement = createRef<HTMLDivElement>();
private resizeObserver: ResizeObserver;
@ -59,18 +62,18 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
componentDidMount() {
this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.addListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.props.callEventGrouper.addListener(LegacyCallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.addListener(LegacyCallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.addListener(LegacyCallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver = new ResizeObserver(this.resizeObserverCallback);
this.wrapperElement.current && this.resizeObserver.observe(this.wrapperElement.current);
}
componentWillUnmount() {
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.removeListener(CallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.props.callEventGrouper.removeListener(LegacyCallEventGrouperEvent.StateChanged, this.onStateChanged);
this.props.callEventGrouper.removeListener(LegacyCallEventGrouperEvent.SilencedChanged, this.onSilencedChanged);
this.props.callEventGrouper.removeListener(LegacyCallEventGrouperEvent.LengthChanged, this.onLengthChanged);
this.resizeObserver.disconnect();
}
@ -97,7 +100,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
private renderCallBackButton(text: string): JSX.Element {
return (
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_callBack"
className="mx_LegacyCallEvent_content_button mx_LegacyCallEvent_content_button_callBack"
onClick={this.props.callEventGrouper.callBack}
kind="primary"
>
@ -108,9 +111,9 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
private renderSilenceIcon(): JSX.Element {
const silenceClass = classNames({
"mx_CallEvent_iconButton": true,
"mx_CallEvent_unSilence": this.state.silenced,
"mx_CallEvent_silence": !this.state.silenced,
"mx_LegacyCallEvent_iconButton": true,
"mx_LegacyCallEvent_unSilence": this.state.silenced,
"mx_LegacyCallEvent_silence": !this.state.silenced,
});
return (
@ -130,17 +133,17 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ silenceIcon }
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_reject"
className="mx_LegacyCallEvent_content_button mx_LegacyCallEvent_content_button_reject"
onClick={this.props.callEventGrouper.rejectCall}
kind="danger"
>
<span> { _t("Decline") } </span>
</AccessibleButton>
<AccessibleButton
className="mx_CallEvent_content_button mx_CallEvent_content_button_answer"
className="mx_LegacyCallEvent_content_button mx_LegacyCallEvent_content_button_answer"
onClick={this.props.callEventGrouper.answerCall}
kind="primary"
>
@ -156,7 +159,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
if (gotRejected) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("Call declined") }
{ this.renderCallBackButton(_t("Call back")) }
{ this.props.timestamp }
@ -175,14 +178,14 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
text += " • " + formatCallTime(duration);
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ text }
{ this.props.timestamp }
</div>
);
} else if (hangupReason === CallErrorCode.InviteTimeout) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("No answer") }
{ this.renderCallBackButton(_t("Call back")) }
{ this.props.timestamp }
@ -212,10 +215,10 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
<InfoTooltip
tooltip={reason}
className="mx_CallEvent_content_tooltip"
className="mx_LegacyCallEvent_content_tooltip"
kind={InfoTooltipKind.Warning}
/>
{ _t("Connection failed") }
@ -226,7 +229,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
if (state === CallState.Connected) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
<Clock seconds={this.state.length} aria-live="off" />
{ this.props.timestamp }
</div>
@ -234,7 +237,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
if (state === CallState.Connecting) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("Connecting") }
{ this.props.timestamp }
</div>
@ -242,7 +245,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
if (state === CustomCallState.Missed) {
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("Missed call") }
{ this.renderCallBackButton(_t("Call back")) }
{ this.props.timestamp }
@ -251,7 +254,7 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_content">
<div className="mx_LegacyCallEvent_content">
{ _t("The call is in an unknown state!") }
{ this.props.timestamp }
</div>
@ -266,13 +269,13 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
const callState = this.state.callState;
const hangupReason = this.props.callEventGrouper.hangupReason;
const content = this.renderContent(callState);
const className = classNames("mx_CallEvent", {
mx_CallEvent_voice: isVoice,
mx_CallEvent_video: !isVoice,
mx_CallEvent_narrow: this.state.narrow,
mx_CallEvent_missed: callState === CustomCallState.Missed,
mx_CallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
mx_CallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
const className = classNames("mx_LegacyCallEvent", {
mx_LegacyCallEvent_voice: isVoice,
mx_LegacyCallEvent_video: !isVoice,
mx_LegacyCallEvent_narrow: this.state.narrow,
mx_LegacyCallEvent_missed: callState === CustomCallState.Missed,
mx_LegacyCallEvent_noAnswer: callState === CallState.Ended && hangupReason === CallErrorCode.InviteTimeout,
mx_LegacyCallEvent_rejected: callState === CallState.Ended && this.props.callEventGrouper.gotRejected,
});
let silenceIcon;
if (this.state.narrow && this.state.callState === CallState.Ringing) {
@ -280,21 +283,21 @@ export default class CallEvent extends React.PureComponent<IProps, IState> {
}
return (
<div className="mx_CallEvent_wrapper" ref={this.wrapperElement}>
<div className="mx_LegacyCallEvent_wrapper" ref={this.wrapperElement}>
<div className={className}>
{ silenceIcon }
<div className="mx_CallEvent_info">
<div className="mx_LegacyCallEvent_info">
<MemberAvatar
member={event.sender}
width={32}
height={32}
/>
<div className="mx_CallEvent_info_basic">
<div className="mx_CallEvent_sender">
<div className="mx_LegacyCallEvent_info_basic">
<div className="mx_LegacyCallEvent_sender">
{ sender }
</div>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon" />
<div className="mx_LegacyCallEvent_type">
<div className="mx_LegacyCallEvent_type_icon" />
{ callType }
</div>
</div>

View file

@ -27,7 +27,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { UIFeature } from "../../../settings/UIFeature";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import CallViewForRoom from '../voip/CallViewForRoom';
import LegacyCallViewForRoom from '../voip/LegacyCallViewForRoom';
import { objectHasDiff } from "../../../utils/objects";
interface IProps {
@ -123,7 +123,7 @@ export default class AuxPanel extends React.Component<IProps, IState> {
render() {
const callView = (
<CallViewForRoom
<LegacyCallViewForRoom
roomId={this.props.room.roomId}
resizeNotifier={this.props.resizeNotifier}
showApps={this.props.showApps}

View file

@ -47,7 +47,7 @@ import EditorStateTransfer from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
import CallEventGrouper from "../../structures/CallEventGrouper";
import LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { Action } from '../../../dispatcher/actions';
import PlatformPeg from '../../../PlatformPeg';
@ -200,8 +200,8 @@ interface IProps {
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
// CallEventGrouper for this event
callEventGrouper?: CallEventGrouper;
// LegacyCallEventGrouper for this event
callEventGrouper?: LegacyCallEventGrouper;
// Symbol of the root node
as?: string;

View file

@ -19,11 +19,11 @@ import React, { createRef } from "react";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import type { Call } from "../../../models/Call";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import defaultDispatcher from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
@ -45,8 +45,9 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomViewStore } from "../../../stores/RoomViewStore";
import VideoRoomSummary from "./VideoRoomSummary";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
interface IProps {
room: Room;
@ -61,6 +62,7 @@ interface IState {
selected: boolean;
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
call: Call | null;
messagePreview?: string;
}
@ -79,7 +81,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
private isVideoRoom: boolean;
constructor(props: IProps) {
super(props);
@ -88,6 +89,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
call: CallStore.instance.get(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: "",
};
@ -95,7 +97,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isElementVideoRoom();
}
private onRoomNameUpdate = (room: Room) => {
@ -154,6 +155,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged);
// Recalculate the call for this room, since it could've changed between
// construction and mounting
this.setState({ call: CallStore.instance.get(this.props.room.roomId) });
}
public componentWillUnmount() {
@ -166,6 +172,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
}
private onAction = (payload: ActionPayload) => {
@ -185,6 +192,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
};
private onCallChanged = (call: Call, roomId: string) => {
if (roomId === this.props.room?.roomId) this.setState({ call });
};
private async generatePreview() {
if (!this.showMessagePreview) {
return null;
@ -362,10 +373,10 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
}
let subtitle;
if (this.isVideoRoom) {
if (this.state.call) {
subtitle = (
<div className="mx_RoomTile_subtitle">
<VideoRoomSummary room={this.props.room} />
<RoomTileCallSummary call={this.state.call} />
</div>
);
} else if (this.showMessagePreview && this.state.messagePreview) {

View file

@ -16,66 +16,56 @@ limitations under the License.
import React, { FC } from "react";
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room";
import type { Call } from "../../../models/Call";
import { _t, TranslatedString } from "../../../languageHandler";
import {
ConnectionState,
useConnectionState,
useConnectedMembers,
useJitsiParticipants,
} from "../../../utils/VideoChannelUtils";
import { useConnectionState, useParticipants } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call";
interface IProps {
room: Room;
interface Props {
call: Call;
}
const VideoRoomSummary: FC<IProps> = ({ room }) => {
const connectionState = useConnectionState(room);
const videoMembers = useConnectedMembers(room, connectionState === ConnectionState.Connected);
const jitsiParticipants = useJitsiParticipants(room);
export const RoomTileCallSummary: FC<Props> = ({ call }) => {
const connectionState = useConnectionState(call);
const participants = useParticipants(call);
let indicator: TranslatedString;
let text: TranslatedString;
let active: boolean;
let participantCount: number;
switch (connectionState) {
case ConnectionState.Disconnected:
indicator = _t("Video");
text = _t("Video");
active = false;
participantCount = videoMembers.size;
break;
case ConnectionState.Connecting:
indicator = _t("Joining…");
text = _t("Joining…");
active = true;
participantCount = videoMembers.size;
break;
case ConnectionState.Connected:
indicator = _t("Joined");
case ConnectionState.Disconnecting:
text = _t("Joined");
active = true;
participantCount = jitsiParticipants.length;
break;
}
return <span className="mx_VideoRoomSummary">
return <span className="mx_RoomTileCallSummary">
<span
className={classNames(
"mx_VideoRoomSummary_indicator",
{ "mx_VideoRoomSummary_indicator_active": active },
"mx_RoomTileCallSummary_text",
{ "mx_RoomTileCallSummary_text_active": active },
)}
>
{ indicator }
{ text }
</span>
{ participantCount ? <>
{ participants.size ? <>
{ " · " }
<span
className="mx_VideoRoomSummary_participants"
aria-label={_t("%(count)s participants", { count: participantCount })}
className="mx_RoomTileCallSummary_participants"
aria-label={_t("%(count)s participants", { count: participants.size })}
>
{ participantCount }
{ participants.size }
</span>
</> : null }
</span>;
};
export default VideoRoomSummary;

View file

@ -26,7 +26,7 @@ import DateSeparator from "../messages/DateSeparator";
import EventTile from "./EventTile";
import { shouldFormContinuation } from "../../structures/MessagePanel";
import { wantsDateSeparator } from "../../../DateUtils";
import CallEventGrouper, { buildCallEventGroupers } from "../../structures/CallEventGrouper";
import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "../../structures/LegacyCallEventGrouper";
import { haveRendererForEvent } from "../../../events/EventTileFactory";
interface IProps {
@ -44,17 +44,17 @@ export default class SearchResultTile extends React.Component<IProps> {
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
// A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, CallEventGrouper>();
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
constructor(props, context) {
super(props, context);
this.buildCallEventGroupers(this.props.searchResult.context.getTimeline());
this.buildLegacyCallEventGroupers(this.props.searchResult.context.getTimeline());
}
private buildCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildCallEventGroupers(this.callEventGroupers, events);
private buildLegacyCallEventGroupers(events?: MatrixEvent[]): void {
this.callEventGroupers = buildLegacyCallEventGroupers(this.callEventGroupers, events);
}
public render() {

View file

@ -28,7 +28,7 @@ interface IState {
feeds: Array<CallFeed>;
}
export default class AudioFeedArrayForCall extends React.Component<IProps, IState> {
export default class AudioFeedArrayForLegacyCall extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);

View file

@ -14,15 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useState, useMemo, useRef, useEffect } from "react";
import React, { FC, useState, useMemo, useRef, useEffect, useCallback } from "react";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import { useConnectedMembers } from "../../../utils/VideoChannelUtils";
import VideoChannelStore from "../../../stores/VideoChannelStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
import { useParticipants } from "../../../hooks/useCall";
import { CallStore } from "../../../stores/CallStore";
import { Call } from "../../../models/Call";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
@ -34,25 +36,22 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import FacePile from "../elements/FacePile";
import MemberAvatar from "../avatars/MemberAvatar";
interface IDeviceButtonProps {
interface DeviceButtonProps {
kind: string;
devices: MediaDeviceInfo[];
setDevice: (device: MediaDeviceInfo) => void;
deviceListLabel: string;
active: boolean;
fallbackDeviceLabel: (n: number) => string;
muted: boolean;
disabled: boolean;
toggle: () => void;
activeTitle: string;
inactiveTitle: string;
unmutedTitle: string;
mutedTitle: string;
}
const DeviceButton: FC<IDeviceButtonProps> = ({
kind, devices, setDevice, deviceListLabel, active, disabled, toggle, activeTitle, inactiveTitle,
const DeviceButton: FC<DeviceButtonProps> = ({
kind, devices, setDevice, deviceListLabel, fallbackDeviceLabel, muted, disabled, toggle, unmutedTitle, mutedTitle,
}) => {
// Depending on permissions, the browser might not let us know device labels,
// in which case there's nothing helpful we can display
const labelledDevices = useMemo(() => devices.filter(d => d.label.length), [devices]);
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
@ -61,13 +60,13 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
closeMenu();
};
const buttonRect = buttonRef.current.getBoundingClientRect();
const buttonRect = buttonRef.current!.getBoundingClientRect();
contextMenu = <IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
{ labelledDevices.map(d =>
{ devices.map((d, index) =>
<IconizedContextMenuOption
key={d.deviceId}
label={d.label}
label={d.label || fallbackDeviceLabel(index + 1)}
onClick={() => selectDevice(d)}
/>,
) }
@ -78,21 +77,20 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
if (!devices.length) return null;
return <div
className={classNames({
"mx_VideoLobby_deviceButtonWrapper": true,
"mx_VideoLobby_deviceButtonWrapper_active": active,
className={classNames("mx_CallLobby_deviceButtonWrapper", {
"mx_CallLobby_deviceButtonWrapper_muted": muted,
})}
>
<AccessibleTooltipButton
className={`mx_VideoLobby_deviceButton mx_VideoLobby_deviceButton_${kind}`}
title={active ? activeTitle : inactiveTitle}
className={`mx_CallLobby_deviceButton mx_CallLobby_deviceButton_${kind}`}
title={muted ? mutedTitle : unmutedTitle}
alignment={Alignment.Top}
onClick={toggle}
disabled={disabled}
/>
{ labelledDevices.length > 1 ? (
{ devices.length > 1 ? (
<ContextMenuButton
className="mx_VideoLobby_deviceListButton"
className="mx_CallLobby_deviceListButton"
inputRef={buttonRef}
onClick={openMenu}
isExpanded={menuDisplayed}
@ -106,57 +104,65 @@ const DeviceButton: FC<IDeviceButtonProps> = ({
const MAX_FACES = 8;
const VideoLobby: FC<{ room: Room }> = ({ room }) => {
const store = VideoChannelStore.instance;
interface Props {
room: Room;
call: Call;
}
export const CallLobby: FC<Props> = ({ room, call }) => {
const [connecting, setConnecting] = useState(false);
const me = useMemo(() => room.getMember(room.myUserId), [room]);
const connectedMembers = useConnectedMembers(room, false);
const videoRef = useRef<HTMLVideoElement>();
const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
const participants = useParticipants(call);
const videoRef = useRef<HTMLVideoElement>(null);
const devices = useAsyncMemo(async () => {
const [audioInputs, videoInputs] = useAsyncMemo(async () => {
try {
return await navigator.mediaDevices.enumerateDevices();
const devices = await MediaDeviceHandler.getDevices();
return [devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
} catch (e) {
logger.warn(`Failed to get media device list: ${e}`);
return [];
logger.warn(`Failed to get media device list`, e);
return [[], []];
}
}, [], []);
const audioDevices = useMemo(() => devices.filter(d => d.kind === "audioinput"), [devices]);
const videoDevices = useMemo(() => devices.filter(d => d.kind === "videoinput"), [devices]);
}, [], [[], []]);
const [selectedAudioDevice, selectAudioDevice] = useState<MediaDeviceInfo>(null);
const [selectedVideoDevice, selectVideoDevice] = useState<MediaDeviceInfo>(null);
const [videoInputId, setVideoInputId] = useState<string>(() => MediaDeviceHandler.getVideoInput());
const audioDevice = selectedAudioDevice ?? audioDevices[0];
const videoDevice = selectedVideoDevice ?? videoDevices[0];
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setAudioInput(device.deviceId);
}, []);
const setVideoInput = useCallback((device: MediaDeviceInfo) => {
MediaDeviceHandler.instance.setVideoInput(device.deviceId);
setVideoInputId(device.deviceId);
}, []);
const [audioActive, setAudioActive] = useState(!store.audioMuted);
const [videoActive, setVideoActive] = useState(!store.videoMuted);
const toggleAudio = () => {
store.audioMuted = audioActive;
setAudioActive(!audioActive);
};
const toggleVideo = () => {
store.videoMuted = videoActive;
setVideoActive(!videoActive);
};
const [audioMuted, setAudioMuted] = useState(() => MediaDeviceHandler.startWithAudioMuted);
const [videoMuted, setVideoMuted] = useState(() => MediaDeviceHandler.startWithVideoMuted);
const toggleAudio = useCallback(() => {
MediaDeviceHandler.startWithAudioMuted = !audioMuted;
setAudioMuted(!audioMuted);
}, [audioMuted, setAudioMuted]);
const toggleVideo = useCallback(() => {
MediaDeviceHandler.startWithVideoMuted = !videoMuted;
setVideoMuted(!videoMuted);
}, [videoMuted, setVideoMuted]);
const videoStream = useAsyncMemo(async () => {
if (videoDevice && videoActive) {
if (videoInputId && !videoMuted) {
try {
return await navigator.mediaDevices.getUserMedia({
video: { deviceId: videoDevice.deviceId },
video: { deviceId: videoInputId },
});
} catch (e) {
logger.error(`Failed to get stream for device ${videoDevice.deviceId}: ${e}`);
logger.error(`Failed to get stream for device ${videoInputId}`, e);
}
}
return null;
}, [videoDevice, videoActive]);
}, [videoInputId, videoMuted]);
useEffect(() => {
if (videoStream) {
const videoElement = videoRef.current;
const videoElement = videoRef.current!;
videoElement.srcObject = videoStream;
videoElement.play();
@ -167,67 +173,69 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => {
}
}, [videoStream]);
const connect = async () => {
const connect = useCallback(async () => {
setConnecting(true);
try {
await store.connect(
room.roomId, audioActive ? audioDevice : null, videoActive ? videoDevice : null,
);
// Disconnect from any other active calls first, since we don't yet support holding
await Promise.all([...CallStore.instance.activeCalls].map(call => call.disconnect()));
await call.connect();
} catch (e) {
logger.error(e);
setConnecting(false);
}
};
}, [call, setConnecting]);
let facePile;
if (connectedMembers.size) {
const shownMembers = [...connectedMembers].slice(0, MAX_FACES);
const overflow = connectedMembers.size > shownMembers.length;
let facePile: JSX.Element | null = null;
if (participants.size) {
const shownMembers = [...participants].slice(0, MAX_FACES);
const overflow = participants.size > shownMembers.length;
facePile = <div className="mx_VideoLobby_connectedMembers">
{ _t("%(count)s people joined", { count: connectedMembers.size }) }
facePile = <div className="mx_CallLobby_participants">
{ _t("%(count)s people joined", { count: participants.size }) }
<FacePile members={shownMembers} faceSize={24} overflow={overflow} />
</div>;
}
return <div className="mx_VideoLobby">
return <div className="mx_CallLobby">
{ facePile }
<div className="mx_VideoLobby_preview">
<div className="mx_CallLobby_preview">
<MemberAvatar key={me.userId} member={me} width={200} height={200} resizeMethod="scale" />
<video
ref={videoRef}
style={{ visibility: videoActive ? null : "hidden" }}
style={{ visibility: videoMuted ? "hidden" : undefined }}
muted
playsInline
disablePictureInPicture
/>
<div className="mx_VideoLobby_controls">
<div className="mx_CallLobby_controls">
<DeviceButton
kind="audio"
devices={audioDevices}
setDevice={selectAudioDevice}
devices={audioInputs}
setDevice={setAudioInput}
deviceListLabel={_t("Audio devices")}
active={audioActive}
fallbackDeviceLabel={n => _t("Audio input %(n)s", { n })}
muted={audioMuted}
disabled={connecting}
toggle={toggleAudio}
activeTitle={_t("Mute microphone")}
inactiveTitle={_t("Unmute microphone")}
unmutedTitle={_t("Mute microphone")}
mutedTitle={_t("Unmute microphone")}
/>
<DeviceButton
kind="video"
devices={videoDevices}
setDevice={selectVideoDevice}
devices={videoInputs}
setDevice={setVideoInput}
deviceListLabel={_t("Video devices")}
active={videoActive}
fallbackDeviceLabel={n => _t("Video input %(n)s", { n })}
muted={videoMuted}
disabled={connecting}
toggle={toggleVideo}
activeTitle={_t("Turn off camera")}
inactiveTitle={_t("Turn on camera")}
unmutedTitle={_t("Turn off camera")}
mutedTitle={_t("Turn on camera")}
/>
</div>
</div>
<AccessibleButton
className="mx_VideoLobby_joinButton"
className="mx_CallLobby_connectButton"
kind="primary"
disabled={connecting}
onClick={connect}
@ -236,5 +244,3 @@ const VideoLobby: FC<{ room: Room }> = ({ room }) => {
</AccessibleButton>
</div>;
};
export default VideoLobby;

View file

@ -21,7 +21,7 @@ import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import Field from "../elements/Field";
import DialPad from './DialPad';
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
interface IProps {
onFinished: (boolean) => void;
@ -78,7 +78,7 @@ export default class DialpadModal extends React.PureComponent<IProps, IState> {
};
onDialPress = async () => {
CallHandler.instance.dialNumber(this.state.value);
LegacyCallHandler.instance.dialNumber(this.state.value);
this.props.onFinished(true);
};

View file

@ -23,7 +23,7 @@ import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler';
import LegacyCallHandler from '../../../LegacyCallHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler';
import VideoFeed from './VideoFeed';
@ -32,9 +32,9 @@ import AccessibleButton from '../elements/AccessibleButton';
import { avatarUrlForMember } from '../../../Avatar';
import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker";
import Modal from '../../../Modal';
import CallViewSidebar from './CallViewSidebar';
import CallViewHeader from './CallView/CallViewHeader';
import CallViewButtons from "./CallView/CallViewButtons";
import LegacyCallViewSidebar from './LegacyCallViewSidebar';
import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader';
import LegacyCallViewButtons from "./LegacyCallView/LegacyCallViewButtons";
import PlatformPeg from "../../../PlatformPeg";
import { ActionPayload } from "../../../dispatcher/payloads";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
@ -47,7 +47,7 @@ interface IProps {
// Another ongoing call to display information about
secondaryCall?: MatrixCall;
// a callback which is called when the content in the CallView changes
// a callback which is called when the content in the LegacyCallView changes
// in a way that is likely to cause a resize.
onResize?: (event: Event) => void;
@ -57,7 +57,7 @@ interface IProps {
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// Used for dragging the PiP CallView
// Used for dragging the PiP LegacyCallView
onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
showApps?: boolean;
@ -104,15 +104,15 @@ function exitFullscreen() {
if (exitMethod) exitMethod.call(document);
}
export default class CallView extends React.Component<IProps, IState> {
export default class LegacyCallView extends React.Component<IProps, IState> {
private dispatcherRef: string;
private contentWrapperRef = createRef<HTMLDivElement>();
private buttonsRef = createRef<CallViewButtons>();
private buttonsRef = createRef<LegacyCallViewButtons>();
constructor(props: IProps) {
super(props);
const { primary, secondary, sidebar } = CallView.getOrderedFeeds(this.props.call.getFeeds());
const { primary, secondary, sidebar } = LegacyCallView.getOrderedFeeds(this.props.call.getFeeds());
this.state = {
isLocalOnHold: this.props.call.isLocalOnHold(),
@ -146,7 +146,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
static getDerivedStateFromProps(props: IProps): Partial<IState> {
const { primary, secondary, sidebar } = CallView.getOrderedFeeds(props.call.getFeeds());
const { primary, secondary, sidebar } = LegacyCallView.getOrderedFeeds(props.call.getFeeds());
return {
primaryFeed: primary,
@ -209,7 +209,7 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onFeedsChanged = (newFeeds: Array<CallFeed>): void => {
const { primary, secondary, sidebar } = CallView.getOrderedFeeds(newFeeds);
const { primary, secondary, sidebar } = LegacyCallView.getOrderedFeeds(newFeeds);
this.setState({
primaryFeed: primary,
secondaryFeed: secondary,
@ -310,8 +310,8 @@ export default class CallView extends React.Component<IProps, IState> {
};
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this
// Note that this assumes we always have a LegacyCallView on screen at any given time
// LegacyCallHandler would probably be a better place for this
private onNativeKeyDown = (ev): void => {
let handled = false;
@ -339,17 +339,17 @@ export default class CallView extends React.Component<IProps, IState> {
};
private onCallResumeClick = (): void => {
const userFacingRoomId = CallHandler.instance.roomIdForCall(this.props.call);
CallHandler.instance.setActiveCallRoomId(userFacingRoomId);
const userFacingRoomId = LegacyCallHandler.instance.roomIdForCall(this.props.call);
LegacyCallHandler.instance.setActiveCallRoomId(userFacingRoomId);
};
private onTransferClick = (): void => {
const transfereeCall = CallHandler.instance.getTransfereeForCallId(this.props.call.callId);
const transfereeCall = LegacyCallHandler.instance.getTransfereeForCallId(this.props.call.callId);
this.props.call.transferToCall(transfereeCall);
};
private onHangupClick = (): void => {
CallHandler.instance.hangupOrReject(CallHandler.instance.roomIdForCall(this.props.call));
LegacyCallHandler.instance.hangupOrReject(LegacyCallHandler.instance.roomIdForCall(this.props.call));
};
private onToggleSidebar = (): void => {
@ -380,7 +380,7 @@ export default class CallView extends React.Component<IProps, IState> {
);
return (
<CallViewButtons
<LegacyCallViewButtons
ref={this.buttonsRef}
call={call}
pipMode={pipMode}
@ -431,7 +431,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
return (
<div className="mx_CallView_toast">
<div className="mx_LegacyCallView_toast">
{ text }
</div>
);
@ -443,7 +443,7 @@ export default class CallView extends React.Component<IProps, IState> {
const callRoom = MatrixClientPeg.get().getRoom(call.roomId);
const avatarSize = pipMode ? 76 : 160;
const transfereeCall = CallHandler.instance.getTransfereeForCallId(call.callId);
const transfereeCall = LegacyCallHandler.instance.getTransfereeForCallId(call.callId);
const isOnHold = isLocalOnHold || isRemoteOnHold;
let secondaryFeedElement: React.ReactNode;
@ -460,23 +460,23 @@ export default class CallView extends React.Component<IProps, IState> {
}
if (transfereeCall || isOnHold) {
const containerClasses = classNames("mx_CallView_content", {
mx_CallView_content_hold: isOnHold,
const containerClasses = classNames("mx_LegacyCallView_content", {
mx_LegacyCallView_content_hold: isOnHold,
});
const backgroundAvatarUrl = avatarUrlForMember(call.getOpponentMember(), 1024, 1024, 'crop');
let holdTransferContent: React.ReactNode;
if (transfereeCall) {
const transferTargetRoom = MatrixClientPeg.get().getRoom(
CallHandler.instance.roomIdForCall(call),
LegacyCallHandler.instance.roomIdForCall(call),
);
const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person");
const transfereeRoom = MatrixClientPeg.get().getRoom(
CallHandler.instance.roomIdForCall(transfereeCall),
LegacyCallHandler.instance.roomIdForCall(transfereeCall),
);
const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person");
holdTransferContent = <div className="mx_CallView_status">
holdTransferContent = <div className="mx_LegacyCallView_status">
{ _t(
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
{
@ -494,7 +494,7 @@ export default class CallView extends React.Component<IProps, IState> {
let onHoldText: React.ReactNode;
if (isRemoteOnHold) {
onHoldText = _t(
CallHandler.instance.hasAnyUnheldCall()
LegacyCallHandler.instance.hasAnyUnheldCall()
? _td("You held the call <a>Switch</a>")
: _td("You held the call <a>Resume</a>"),
{},
@ -511,7 +511,7 @@ export default class CallView extends React.Component<IProps, IState> {
}
holdTransferContent = (
<div className="mx_CallView_status">
<div className="mx_LegacyCallView_status">
{ onHoldText }
</div>
);
@ -519,16 +519,16 @@ export default class CallView extends React.Component<IProps, IState> {
return (
<div className={containerClasses} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_holdBackground" style={{ backgroundImage: 'url(' + backgroundAvatarUrl + ')' }} />
<div className="mx_LegacyCallView_holdBackground" style={{ backgroundImage: 'url(' + backgroundAvatarUrl + ')' }} />
{ holdTransferContent }
</div>
);
} else if (call.noIncomingFeeds()) {
return (
<div className="mx_CallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_CallView_avatarsContainer">
<div className="mx_LegacyCallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_LegacyCallView_avatarsContainer">
<div
className="mx_CallView_avatarContainer"
className="mx_LegacyCallView_avatarContainer"
style={{ width: avatarSize, height: avatarSize }}
>
<RoomAvatar
@ -538,14 +538,14 @@ export default class CallView extends React.Component<IProps, IState> {
/>
</div>
</div>
<div className="mx_CallView_status">{ _t("Connecting") }</div>
<div className="mx_LegacyCallView_status">{ _t("Connecting") }</div>
{ secondaryFeedElement }
</div>
);
} else if (pipMode) {
return (
<div
className="mx_CallView_content"
className="mx_LegacyCallView_content"
onMouseMove={this.onMouseMove}
>
<VideoFeed
@ -559,7 +559,7 @@ export default class CallView extends React.Component<IProps, IState> {
);
} else if (secondaryFeed) {
return (
<div className="mx_CallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_LegacyCallView_content" onMouseMove={this.onMouseMove}>
<VideoFeed
feed={primaryFeed}
call={call}
@ -572,7 +572,7 @@ export default class CallView extends React.Component<IProps, IState> {
);
} else {
return (
<div className="mx_CallView_content" onMouseMove={this.onMouseMove}>
<div className="mx_LegacyCallView_content" onMouseMove={this.onMouseMove}>
<VideoFeed
feed={primaryFeed}
call={call}
@ -580,7 +580,7 @@ export default class CallView extends React.Component<IProps, IState> {
onResize={onResize}
primary={true}
/>
{ sidebarShown && <CallViewSidebar
{ sidebarShown && <LegacyCallViewSidebar
feeds={sidebarFeeds}
call={call}
pipMode={pipMode}
@ -604,27 +604,27 @@ export default class CallView extends React.Component<IProps, IState> {
} = this.state;
const client = MatrixClientPeg.get();
const callRoomId = CallHandler.instance.roomIdForCall(call);
const secondaryCallRoomId = CallHandler.instance.roomIdForCall(secondaryCall);
const callRoomId = LegacyCallHandler.instance.roomIdForCall(call);
const secondaryCallRoomId = LegacyCallHandler.instance.roomIdForCall(secondaryCall);
const callRoom = client.getRoom(callRoomId);
const secCallRoom = secondaryCall ? client.getRoom(secondaryCallRoomId) : null;
const callViewClasses = classNames({
mx_CallView: true,
mx_CallView_pip: pipMode,
mx_CallView_large: !pipMode,
mx_CallView_sidebar: sidebarShown && sidebarFeeds.length !== 0 && !pipMode,
mx_CallView_belowWidget: showApps, // css to correct the margins if the call is below the AppsDrawer.
mx_LegacyCallView: true,
mx_LegacyCallView_pip: pipMode,
mx_LegacyCallView_large: !pipMode,
mx_LegacyCallView_sidebar: sidebarShown && sidebarFeeds.length !== 0 && !pipMode,
mx_LegacyCallView_belowWidget: showApps, // css to correct the margins if the call is below the AppsDrawer.
});
return <div className={callViewClasses}>
<CallViewHeader
<LegacyCallViewHeader
onPipMouseDown={onMouseDownOnHeader}
pipMode={pipMode}
callRooms={[callRoom, secCallRoom]}
onMaximize={this.onMaximizeClick}
/>
<div className="mx_CallView_content_wrapper" ref={this.contentWrapperRef}>
<div className="mx_LegacyCallView_content_wrapper" ref={this.contentWrapperRef}>
{ this.renderToast() }
{ this.renderContent() }
{ this.renderCallControls() }

View file

@ -21,7 +21,7 @@ import classNames from "classnames";
import { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
import CallContextMenu from "../../context_menus/CallContextMenu";
import LegacyCallContextMenu from "../../context_menus/LegacyCallContextMenu";
import DialpadContextMenu from "../../context_menus/DialpadContextMenu";
import { Alignment } from "../../elements/Tooltip";
import {
@ -49,7 +49,7 @@ interface IButtonProps extends Omit<React.ComponentProps<typeof AccessibleToolti
onClick: (event: React.MouseEvent) => void;
}
const CallViewToggleButton: React.FC<IButtonProps> = ({
const LegacyCallViewToggleButton: React.FC<IButtonProps> = ({
children,
state: isOn,
className,
@ -57,9 +57,9 @@ const CallViewToggleButton: React.FC<IButtonProps> = ({
offLabel,
...props
}) => {
const classes = classNames("mx_CallViewButtons_button", className, {
mx_CallViewButtons_button_on: isOn,
mx_CallViewButtons_button_off: !isOn,
const classes = classNames("mx_LegacyCallViewButtons_button", className, {
mx_LegacyCallViewButtons_button_on: isOn,
mx_LegacyCallViewButtons_button_off: !isOn,
});
return (
@ -78,12 +78,12 @@ interface IDropdownButtonProps extends IButtonProps {
deviceKinds: MediaDeviceKindEnum[];
}
const CallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceKinds, ...props }) => {
const LegacyCallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceKinds, ...props }) => {
const [menuDisplayed, buttonRef, openMenu, closeMenu] = useContextMenu();
const [hoveringDropdown, setHoveringDropdown] = useState(false);
const classes = classNames("mx_CallViewButtons_button", "mx_CallViewButtons_dropdownButton", {
mx_CallViewButtons_dropdownButton_collapsed: !menuDisplayed,
const classes = classNames("mx_LegacyCallViewButtons_button", "mx_LegacyCallViewButtons_dropdownButton", {
mx_LegacyCallViewButtons_dropdownButton_collapsed: !menuDisplayed,
});
const onClick = (event: React.MouseEvent): void => {
@ -92,8 +92,8 @@ const CallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceK
};
return (
<CallViewToggleButton inputRef={buttonRef} forceHide={menuDisplayed || hoveringDropdown} state={state} {...props}>
<CallViewToggleButton
<LegacyCallViewToggleButton inputRef={buttonRef} forceHide={menuDisplayed || hoveringDropdown} state={state} {...props}>
<LegacyCallViewToggleButton
className={classes}
onClick={onClick}
onHover={(hovering) => setHoveringDropdown(hovering)}
@ -105,7 +105,7 @@ const CallViewDropdownButton: React.FC<IDropdownButtonProps> = ({ state, deviceK
onFinished={closeMenu}
deviceKinds={deviceKinds}
/> }
</CallViewToggleButton>
</LegacyCallViewToggleButton>
);
};
@ -141,7 +141,7 @@ interface IState {
showMoreMenu: boolean;
}
export default class CallViewButtons extends React.Component<IProps, IState> {
export default class LegacyCallViewButtons extends React.Component<IProps, IState> {
private dialpadButton = createRef<HTMLDivElement>();
private contextMenuButton = createRef<HTMLDivElement>();
private controlsHideTimer: number = null;
@ -212,8 +212,8 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
};
public render(): JSX.Element {
const callControlsClasses = classNames("mx_CallViewButtons", {
mx_CallViewButtons_hidden: !this.state.visible,
const callControlsClasses = classNames("mx_LegacyCallViewButtons", {
mx_LegacyCallViewButtons_hidden: !this.state.visible,
});
let dialPad;
@ -236,7 +236,7 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
let contextMenu;
if (this.state.showMoreMenu) {
contextMenu = <CallContextMenu
contextMenu = <LegacyCallContextMenu
{...alwaysAboveLeftOf(
this.contextMenuButton.current.getBoundingClientRect(),
ChevronFace.None,
@ -258,45 +258,45 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
{ contextMenu }
{ this.props.buttonsVisibility.dialpad && <ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_dialpad"
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_dialpad"
inputRef={this.dialpadButton}
onClick={this.onDialpadClick}
isExpanded={this.state.showDialpad}
title={_t("Dialpad")}
alignment={Alignment.Top}
/> }
<CallViewDropdownButton
<LegacyCallViewDropdownButton
state={!this.props.buttonsState.micMuted}
className="mx_CallViewButtons_button_mic"
className="mx_LegacyCallViewButtons_button_mic"
onLabel={_t("Mute the microphone")}
offLabel={_t("Unmute the microphone")}
onClick={this.props.handlers.onMicMuteClick}
deviceKinds={[MediaDeviceKindEnum.AudioInput, MediaDeviceKindEnum.AudioOutput]}
/>
{ this.props.buttonsVisibility.vidMute && <CallViewDropdownButton
{ this.props.buttonsVisibility.vidMute && <LegacyCallViewDropdownButton
state={!this.props.buttonsState.vidMuted}
className="mx_CallViewButtons_button_vid"
className="mx_LegacyCallViewButtons_button_vid"
onLabel={_t("Stop the camera")}
offLabel={_t("Start the camera")}
onClick={this.props.handlers.onVidMuteClick}
deviceKinds={[MediaDeviceKindEnum.VideoInput]}
/> }
{ this.props.buttonsVisibility.screensharing && <CallViewToggleButton
{ this.props.buttonsVisibility.screensharing && <LegacyCallViewToggleButton
state={this.props.buttonsState.screensharing}
className="mx_CallViewButtons_button_screensharing"
className="mx_LegacyCallViewButtons_button_screensharing"
onLabel={_t("Stop sharing your screen")}
offLabel={_t("Start sharing your screen")}
onClick={this.props.handlers.onScreenshareClick}
/> }
{ this.props.buttonsVisibility.sidebar && <CallViewToggleButton
{ this.props.buttonsVisibility.sidebar && <LegacyCallViewToggleButton
state={this.props.buttonsState.sidebarShown}
className="mx_CallViewButtons_button_sidebar"
className="mx_LegacyCallViewButtons_button_sidebar"
onLabel={_t("Hide sidebar")}
offLabel={_t("Show sidebar")}
onClick={this.props.handlers.onToggleSidebarClick}
/> }
{ this.props.buttonsVisibility.contextMenu && <ContextMenuTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_more"
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_button_more"
onClick={this.onMoreClick}
inputRef={this.contextMenuButton}
isExpanded={this.state.showMoreMenu}
@ -304,7 +304,7 @@ export default class CallViewButtons extends React.Component<IProps, IState> {
alignment={Alignment.Top}
/> }
<AccessibleTooltipButton
className="mx_CallViewButtons_button mx_CallViewButtons_button_hangup"
className="mx_LegacyCallViewButtons_button mx_LegacyCallViewButtons_button_hangup"
onClick={this.props.handlers.onHangupClick}
title={_t("Hangup")}
alignment={Alignment.Top}

View file

@ -21,26 +21,26 @@ import { _t } from '../../../../languageHandler';
import RoomAvatar from '../../avatars/RoomAvatar';
import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
interface CallControlsProps {
interface LegacyCallControlsProps {
onExpand?: () => void;
onPin?: () => void;
onMaximize?: () => void;
}
const CallViewHeaderControls: React.FC<CallControlsProps> = ({ onExpand, onPin, onMaximize }) => {
return <div className="mx_CallViewHeader_controls">
const LegacyCallViewHeaderControls: React.FC<LegacyCallControlsProps> = ({ onExpand, onPin, onMaximize }) => {
return <div className="mx_LegacyCallViewHeader_controls">
{ onMaximize && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen"
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_fullscreen"
onClick={onMaximize}
title={_t("Fill Screen")}
/> }
{ onPin && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_pin"
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_pin"
onClick={onPin}
title={_t("Pin")}
/> }
{ onExpand && <AccessibleTooltipButton
className="mx_CallViewHeader_button mx_CallViewHeader_button_expand"
className="mx_LegacyCallViewHeader_button mx_LegacyCallViewHeader_button_expand"
onClick={onExpand}
title={_t("Return to call")}
/> }
@ -52,15 +52,15 @@ interface ISecondaryCallInfoProps {
}
const SecondaryCallInfo: React.FC<ISecondaryCallInfoProps> = ({ callRoom }) => {
return <span className="mx_CallViewHeader_secondaryCallInfo">
return <span className="mx_LegacyCallViewHeader_secondaryCallInfo">
<RoomAvatar room={callRoom} height={16} width={16} />
<span className="mx_CallView_secondaryCall_roomName">
<span className="mx_LegacyCallView_secondaryCall_roomName">
{ _t("%(name)s on hold", { name: callRoom.name }) }
</span>
</span>;
};
interface CallViewHeaderProps {
interface LegacyCallViewHeaderProps {
pipMode: boolean;
callRooms?: Room[];
onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
@ -69,7 +69,7 @@ interface CallViewHeaderProps {
onMaximize?: () => void;
}
const CallViewHeader: React.FC<CallViewHeaderProps> = ({
const LegacyCallViewHeader: React.FC<LegacyCallViewHeaderProps> = ({
pipMode = false,
callRooms = [],
onPipMouseDown,
@ -81,25 +81,25 @@ const CallViewHeader: React.FC<CallViewHeaderProps> = ({
const callRoomName = callRoom.name;
if (!pipMode) {
return <div className="mx_CallViewHeader">
<div className="mx_CallViewHeader_icon" />
<span className="mx_CallViewHeader_text">{ _t("Call") }</span>
<CallViewHeaderControls onMaximize={onMaximize} />
return <div className="mx_LegacyCallViewHeader">
<div className="mx_LegacyCallViewHeader_icon" />
<span className="mx_LegacyCallViewHeader_text">{ _t("Call") }</span>
<LegacyCallViewHeaderControls onMaximize={onMaximize} />
</div>;
}
return (
<div
className="mx_CallViewHeader mx_CallViewHeader_pip"
className="mx_LegacyCallViewHeader mx_LegacyCallViewHeader_pip"
onMouseDown={onPipMouseDown}
>
<RoomAvatar room={callRoom} height={32} width={32} />
<div className="mx_CallViewHeader_callInfo">
<div className="mx_CallViewHeader_roomName">{ callRoomName }</div>
<div className="mx_LegacyCallViewHeader_callInfo">
<div className="mx_LegacyCallViewHeader_roomName">{ callRoomName }</div>
{ onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
</div>
<CallViewHeaderControls onExpand={onExpand} onPin={onPin} onMaximize={onMaximize} />
<LegacyCallViewHeaderControls onExpand={onExpand} onPin={onPin} onMaximize={onMaximize} />
</div>
);
};
export default CallViewHeader;
export default LegacyCallViewHeader;

View file

@ -18,8 +18,8 @@ import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react';
import { Resizable } from "re-resizable";
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import CallView from './CallView';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler';
import LegacyCallView from './LegacyCallView';
import ResizeNotifier from "../../../utils/ResizeNotifier";
interface IProps {
@ -32,14 +32,14 @@ interface IProps {
}
interface IState {
call: MatrixCall;
call: MatrixCall | null;
}
/*
* Wrapper for CallView that always display the call in a given room,
* Wrapper for LegacyCallView that always display the call in a given room,
* or nothing if there is no call in that room.
*/
export default class CallViewForRoom extends React.Component<IProps, IState> {
export default class LegacyCallViewForRoom extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
@ -48,13 +48,13 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
}
public componentDidMount() {
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.updateCall);
CallHandler.instance.addListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCall);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall);
}
public componentWillUnmount() {
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.updateCall);
CallHandler.instance.removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCall);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCall);
}
private updateCall = () => {
@ -64,8 +64,8 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
}
};
private getCall(): MatrixCall {
const call = CallHandler.instance.getCallForRoom(this.props.roomId);
private getCall(): MatrixCall | null {
const call = LegacyCallHandler.instance.getCallForRoom(this.props.roomId);
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
return call;
@ -87,7 +87,7 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
if (!this.state.call) return null;
return (
<div className="mx_CallViewForRoom">
<div className="mx_LegacyCallViewForRoom">
<Resizable
minHeight={380}
maxHeight="80vh"
@ -104,10 +104,10 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
onResizeStart={this.onResizeStart}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
className="mx_CallViewForRoom_ResizeWrapper"
handleClasses={{ bottom: "mx_CallViewForRoom_ResizeHandle" }}
className="mx_LegacyCallViewForRoom_ResizeWrapper"
handleClasses={{ bottom: "mx_LegacyCallViewForRoom_ResizeHandle" }}
>
<CallView
<LegacyCallView
call={this.state.call}
pipMode={false}
showApps={this.props.showApps}

View file

@ -27,7 +27,7 @@ interface IProps {
pipMode: boolean;
}
export default class CallViewSidebar extends React.Component<IProps> {
export default class LegacyCallViewSidebar extends React.Component<IProps> {
render() {
const feeds = this.props.feeds.map((feed) => {
return (
@ -41,8 +41,8 @@ export default class CallViewSidebar extends React.Component<IProps> {
);
});
const className = classNames("mx_CallViewSidebar", {
mx_CallViewSidebar_pipMode: this.props.pipMode,
const className = classNames("mx_LegacyCallViewSidebar", {
mx_LegacyCallViewSidebar_pipMode: this.props.pipMode,
});
return (

View file

@ -21,9 +21,9 @@ import { logger } from "matrix-js-sdk/src/logger";
import classNames from 'classnames';
import { Room } from "matrix-js-sdk/src/models/room";
import CallView from "./CallView";
import LegacyCallView from "./LegacyCallView";
import { RoomViewStore } from '../../../stores/RoomViewStore';
import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler';
import PersistentApp from "../elements/PersistentApp";
import SettingsStore from "../../../settings/SettingsStore";
import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -31,7 +31,7 @@ import PictureInPictureDragger from './PictureInPictureDragger';
import dis from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions";
import { Container, WidgetLayoutStore } from '../../../stores/widgets/WidgetLayoutStore';
import CallViewHeader from './CallView/CallViewHeader';
import LegacyCallViewHeader from './LegacyCallView/LegacyCallViewHeader';
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/ActiveWidgetStore';
import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@ -81,7 +81,7 @@ const getRoomAndAppForWidget = (widgetId: string, roomId: string): [Room, IApp]
// The primary will be the one not on hold, or an arbitrary one
// if they're all on hold)
function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall[]] {
const calls = CallHandler.instance.getAllActiveCallsForPip(roomId);
const calls = LegacyCallHandler.instance.getAllActiveCallsForPip(roomId);
let primary: MatrixCall = null;
let secondaries: MatrixCall[] = [];
@ -110,7 +110,7 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall
}
/**
* PipView shows a small version of the CallView or a sticky widget hovering over the UI in 'picture-in-picture'
* PipView shows a small version of the LegacyCallView or a sticky widget hovering over the UI in 'picture-in-picture'
* (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing
* and all widgets that are active but not shown in any other possible container.
*/
@ -139,8 +139,8 @@ export default class PipView extends React.Component<IProps, IState> {
}
public componentDidMount() {
CallHandler.instance.addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
CallHandler.instance.addListener(CallHandlerEvent.CallState, this.updateCalls);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.addListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
this.roomStoreToken = RoomViewStore.instance.addListener(this.onRoomViewStoreUpdate);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
const room = MatrixClientPeg.get()?.getRoom(this.state.viewedRoomId);
@ -154,8 +154,8 @@ export default class PipView extends React.Component<IProps, IState> {
}
public componentWillUnmount() {
CallHandler.instance.removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
CallHandler.instance.removeListener(CallHandlerEvent.CallState, this.updateCalls);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
this.roomStoreToken?.remove();
SettingsStore.unwatchSetting(this.settingsWatcherRef);
@ -308,7 +308,7 @@ export default class PipView extends React.Component<IProps, IState> {
if (this.state.primaryCall) {
pipContent = ({ onStartMoving, onResize }) =>
<CallView
<LegacyCallView
onMouseDownOnHeader={onStartMoving}
call={this.state.primaryCall}
secondaryCall={this.state.secondaryCall}
@ -329,7 +329,7 @@ export default class PipView extends React.Component<IProps, IState> {
pipContent = ({ onStartMoving, _onResize }) =>
<div className={pipViewClasses}>
<CallViewHeader
<LegacyCallViewHeader
onPipMouseDown={(event) => { onStartMoving(event); this.onStartMoving.bind(this)(); }}
pipMode={pipMode}
callRooms={[roomForWidget]}

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixClient, ClientEvent } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType, RoomCreateTypeField, RoomType } from "matrix-js-sdk/src/@types/event";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
@ -37,7 +37,7 @@ import { getAddressType } from "./UserAddress";
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
import SpaceStore from "./stores/spaces/SpaceStore";
import { makeSpaceParentEvent } from "./utils/space";
import { VIDEO_CHANNEL_MEMBER, addVideoChannel } from "./utils/VideoChannelUtils";
import { JitsiCall } from "./models/Call";
import { Action } from "./dispatcher/actions";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import Spinner from "./components/views/elements/Spinner";
@ -131,8 +131,8 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
if (opts.roomType === RoomType.ElementVideo) {
createOpts.power_level_content_override = {
events: {
// Allow all users to send video member updates
[VIDEO_CHANNEL_MEMBER]: 0,
// Allow all users to send call membership updates
[JitsiCall.MEMBER_EVENT_TYPE]: 0,
// Make widgets immutable, even to admins
"im.vector.modular.widgets": 200,
// Annoyingly, we have to reiterate all the defaults here
@ -239,7 +239,8 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
let modal;
if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner');
let roomId;
let roomId: string;
let room: Promise<Room>;
return client.createRoom(createOpts).catch(function(err) {
// NB This checks for the Synapse-specific error condition of a room creation
// having been denied because the requesting user wanted to publish the room,
@ -254,32 +255,43 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
}
}).finally(function() {
if (modal) modal.close();
}).then(function(res) {
}).then(async res => {
roomId = res.room_id;
if (opts.dmUserId) {
return Rooms.setDMRoom(roomId, opts.dmUserId);
} else {
return Promise.resolve();
}
room = new Promise(resolve => {
const storedRoom = client.getRoom(roomId);
if (storedRoom) {
resolve(storedRoom);
} else {
// The room hasn't arrived down sync yet
const onRoom = (emittedRoom: Room) => {
if (emittedRoom.roomId === roomId) {
resolve(emittedRoom);
client.off(ClientEvent.Room, onRoom);
}
};
client.on(ClientEvent.Room, onRoom);
}
});
if (opts.dmUserId) await Rooms.setDMRoom(roomId, opts.dmUserId);
}).then(() => {
if (opts.parentSpace) {
return SpaceStore.instance.addRoomToSpace(opts.parentSpace, roomId, [client.getDomain()], opts.suggested);
}
}).then(async () => {
if (opts.roomType === RoomType.ElementVideo) {
// Set up video rooms with a Jitsi widget
await addVideoChannel(roomId, createOpts.name);
// Set up video rooms with a Jitsi call
await JitsiCall.create(await room);
// Reset our power level back to admin so that the widget becomes immutable
const room = client.getRoom(roomId);
const plEvent = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId(), 100, plEvent);
const plEvent = (await room)?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
}
}).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
// the room exists, causing things like
// https://github.com/vector-im/vector-web/issues/1813
// NB we haven't necessarily blocked on the room promise, so we race
// here with the client knowing that the room exists, causing things
// like https://github.com/vector-im/vector-web/issues/1813
// Even if we were to block on the echo, servers tend to split the room
// state over multiple syncs so we can't atomically know when we have the
// entire thing.

View file

@ -22,12 +22,12 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import EditorStateTransfer from "../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from "../utils/permalinks/Permalinks";
import CallEventGrouper from "../components/structures/CallEventGrouper";
import LegacyCallEventGrouper from "../components/structures/LegacyCallEventGrouper";
import { GetRelationsForEvent } from "../components/views/rooms/EventTile";
import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent";
import MKeyVerificationConclusion from "../components/views/messages/MKeyVerificationConclusion";
import CallEvent from "../components/views/messages/CallEvent";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import RoomCreate from "../components/views/messages/RoomCreate";
@ -57,7 +57,7 @@ export interface EventTileTypeProps {
editState?: EditorStateTransfer;
replacingEventId?: string;
permalinkCreator: RoomPermalinkCreator;
callEventGrouper?: CallEventGrouper;
callEventGrouper?: LegacyCallEventGrouper;
isSeeingThroughMessageHiddenForModeration?: boolean;
timestamp?: JSX.Element;
maxImageHeight?: number; // pixels
@ -71,8 +71,8 @@ type FactoryMap = Record<string, Factory>;
const MessageEventFactory: Factory = (ref, props) => <MessageEvent ref={ref} {...props} />;
const KeyVerificationConclFactory: Factory = (ref, props) => <MKeyVerificationConclusion ref={ref} {...props} />;
const CallEventFactory: Factory<FactoryProps & { callEventGrouper: CallEventGrouper }> = (ref, props) => (
<CallEvent ref={ref} {...props} />
const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => (
<LegacyCallEvent ref={ref} {...props} />
);
const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />;
const VerificationReqFactory: Factory = (ref, props) => <MKeyVerificationRequest ref={ref} {...props} />;
@ -89,7 +89,7 @@ const EVENT_TILE_TYPES: FactoryMap = {
[M_POLL_START.altName]: MessageEventFactory,
[EventType.KeyVerificationCancel]: KeyVerificationConclFactory,
[EventType.KeyVerificationDone]: KeyVerificationConclFactory,
[EventType.CallInvite]: CallEventFactory, // note that this requires a special factory type
[EventType.CallInvite]: LegacyCallEventFactory, // note that this requires a special factory type
};
const STATE_EVENT_TILE_TYPES: FactoryMap = {

46
src/hooks/useCall.ts Normal file
View file

@ -0,0 +1,46 @@
/*
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 { useState, useCallback } from "react";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { Call, ConnectionState } from "../models/Call";
import { useTypedEventEmitterState } from "./useEventEmitter";
import { CallEvent } from "../models/Call";
import { CallStore, CallStoreEvent } from "../stores/CallStore";
import { useEventEmitter } from "./useEventEmitter";
export const useCall = (roomId: string): Call | null => {
const [call, setCall] = useState(() => CallStore.instance.get(roomId));
useEventEmitter(CallStore.instance, CallStoreEvent.Call, (call: Call | null, forRoomId: string) => {
if (forRoomId === roomId) setCall(call);
});
return call;
};
export const useConnectionState = (call: Call): ConnectionState =>
useTypedEventEmitterState(
call,
CallEvent.ConnectionState,
useCallback(state => state ?? call.connectionState, [call]),
);
export const useParticipants = (call: Call): Set<RoomMember> =>
useTypedEventEmitterState(
call,
CallEvent.Participants,
useCallback(state => state ?? call.participants, [call]),
);

View file

@ -16,40 +16,6 @@
"Error": "Error",
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.",
"Dismiss": "Dismiss",
"Call Failed": "Call Failed",
"User Busy": "User Busy",
"The user you called is busy.": "The user you called is busy.",
"The call could not be established": "The call could not be established",
"Answered Elsewhere": "Answered Elsewhere",
"The call was answered on another device.": "The call was answered on another device.",
"Call failed due to misconfigured server": "Call failed due to misconfigured server",
"Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
"Try using turn.matrix.org": "Try using turn.matrix.org",
"OK": "OK",
"Unable to access microphone": "Unable to access microphone",
"Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
"Unable to access webcam / microphone": "Unable to access webcam / microphone",
"Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:",
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
"Already in call": "Already in call",
"You're already in a call with this person.": "You're already in a call with this person.",
"Calls are unsupported": "Calls are unsupported",
"You cannot place calls in this browser.": "You cannot place calls in this browser.",
"Connectivity to the server has been lost": "Connectivity to the server has been lost",
"You cannot place calls without a connection to the server.": "You cannot place calls without a connection to the server.",
"Too Many Calls": "Too Many Calls",
"You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Unable to transfer call": "Unable to transfer call",
"Transfer Failed": "Transfer Failed",
"Failed to transfer call": "Failed to transfer call",
"Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.",
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
"Upload Failed": "Upload Failed",
@ -92,6 +58,40 @@
"This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server <server /> to validate an email address or phone number, but the server does not have any terms of service.",
"Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.",
"Trust": "Trust",
"Call Failed": "Call Failed",
"User Busy": "User Busy",
"The user you called is busy.": "The user you called is busy.",
"The call could not be established": "The call could not be established",
"Answered Elsewhere": "Answered Elsewhere",
"The call was answered on another device.": "The call was answered on another device.",
"Call failed due to misconfigured server": "Call failed due to misconfigured server",
"Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.": "Please ask the administrator of your homeserver (<code>%(homeserverDomain)s</code>) to configure a TURN server in order for calls to work reliably.",
"Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.": "Alternatively, you can try to use the public server at <code>turn.matrix.org</code>, but this will not be as reliable, and it will share your IP address with that server. You can also manage this in Settings.",
"Try using turn.matrix.org": "Try using turn.matrix.org",
"OK": "OK",
"Unable to access microphone": "Unable to access microphone",
"Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.": "Call failed because microphone could not be accessed. Check that a microphone is plugged in and set up correctly.",
"Unable to access webcam / microphone": "Unable to access webcam / microphone",
"Call failed because webcam or microphone could not be accessed. Check that:": "Call failed because webcam or microphone could not be accessed. Check that:",
"A microphone and webcam are plugged in and set up correctly": "A microphone and webcam are plugged in and set up correctly",
"Permission is granted to use the webcam": "Permission is granted to use the webcam",
"No other application is using the webcam": "No other application is using the webcam",
"Already in call": "Already in call",
"You're already in a call with this person.": "You're already in a call with this person.",
"Calls are unsupported": "Calls are unsupported",
"You cannot place calls in this browser.": "You cannot place calls in this browser.",
"Connectivity to the server has been lost": "Connectivity to the server has been lost",
"You cannot place calls without a connection to the server.": "You cannot place calls without a connection to the server.",
"Too Many Calls": "Too Many Calls",
"You've reached the maximum number of simultaneous calls.": "You've reached the maximum number of simultaneous calls.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Unable to look up phone number": "Unable to look up phone number",
"There was an error looking up the phone number": "There was an error looking up the phone number",
"Unable to transfer call": "Unable to transfer call",
"Transfer Failed": "Transfer Failed",
"Failed to transfer call": "Failed to transfer call",
"Permission Required": "Permission Required",
"You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
"We couldn't log you in": "We couldn't log you in",
"We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.": "We asked the browser to remember which homeserver you use to let you sign in, but unfortunately your browser has forgotten it. Go to the sign in page and try again.",
"Try again": "Try again",
@ -1039,6 +1039,18 @@
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
"Send as message": "Send as message",
"%(count)s people joined|other": "%(count)s people joined",
"%(count)s people joined|one": "%(count)s person joined",
"Audio devices": "Audio devices",
"Audio input %(n)s": "Audio input %(n)s",
"Mute microphone": "Mute microphone",
"Unmute microphone": "Unmute microphone",
"Video devices": "Video devices",
"Video input %(n)s": "Video input %(n)s",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Join": "Join",
"Dial": "Dial",
"You are presenting": "You are presenting",
"%(sharerName)s is presenting": "%(sharerName)s is presenting",
"Your camera is turned off": "Your camera is turned off",
@ -1049,16 +1061,6 @@
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
"%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
"Dial": "Dial",
"%(count)s people joined|other": "%(count)s people joined",
"%(count)s people joined|one": "%(count)s person joined",
"Audio devices": "Audio devices",
"Mute microphone": "Mute microphone",
"Unmute microphone": "Unmute microphone",
"Video devices": "Video devices",
"Turn off camera": "Turn off camera",
"Turn on camera": "Turn on camera",
"Join": "Join",
"Dialpad": "Dialpad",
"Mute the microphone": "Mute the microphone",
"Unmute the microphone": "Unmute the microphone",
@ -1972,6 +1974,11 @@
"%(count)s unread messages.|other": "%(count)s unread messages.",
"%(count)s unread messages.|one": "1 unread message.",
"Unread messages.": "Unread messages.",
"Video": "Video",
"Joining…": "Joining…",
"Joined": "Joined",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.",
"This room has already been upgraded.": "This room has already been upgraded.",
"This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.": "This room is running room version <roomVersion />, which this homeserver has marked as <i>unstable</i>.",
@ -1993,11 +2000,6 @@
"Open thread": "Open thread",
"Jump to first unread message.": "Jump to first unread message.",
"Mark all as read": "Mark all as read",
"Video": "Video",
"Joining…": "Joining…",
"Joined": "Joined",
"%(count)s participants|other": "%(count)s participants",
"%(count)s participants|one": "1 participant",
"Unable to access your microphone": "Unable to access your microphone",
"We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
"No microphone found": "No microphone found",
@ -2155,17 +2157,6 @@
"%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.",
"You cancelled verification.": "You cancelled verification.",
"Verification cancelled": "Verification cancelled",
"Call declined": "Call declined",
"Call back": "Call back",
"No answer": "No answer",
"Could not connect media": "Could not connect media",
"Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred",
"Unknown failure: %(reason)s": "Unknown failure: %(reason)s",
"Retry": "Retry",
"Missed call": "Missed call",
"The call is in an unknown state!": "The call is in an unknown state!",
"Sunday": "Sunday",
"Monday": "Monday",
"Tuesday": "Tuesday",
@ -2196,6 +2187,17 @@
"Message pending moderation": "Message pending moderation",
"Pick a date to jump to": "Pick a date to jump to",
"Go": "Go",
"Call declined": "Call declined",
"Call back": "Call back",
"No answer": "No answer",
"Could not connect media": "Could not connect media",
"Connection failed": "Connection failed",
"Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone",
"An unknown error occurred": "An unknown error occurred",
"Unknown failure: %(reason)s": "Unknown failure: %(reason)s",
"Retry": "Retry",
"Missed call": "Missed call",
"The call is in an unknown state!": "The call is in an unknown state!",
"Error processing audio message": "Error processing audio message",
"View live location": "View live location",
"React": "React",
@ -3018,11 +3020,11 @@
"Observe only": "Observe only",
"No verification requests found": "No verification requests found",
"There was an error finding this widget.": "There was an error finding this widget.",
"Resume": "Resume",
"Hold": "Hold",
"Input devices": "Input devices",
"Output devices": "Output devices",
"Cameras": "Cameras",
"Resume": "Resume",
"Hold": "Hold",
"Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",
"Open in OpenStreetMap": "Open in OpenStreetMap",
"Forward": "Forward",

539
src/models/Call.ts Normal file
View file

@ -0,0 +1,539 @@
/*
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 { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomEvent } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { IWidgetApiRequest } from "matrix-widget-api";
import type EventEmitter from "events";
import type { IMyDevice } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
import type { IApp } from "../stores/WidgetStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType";
import { ElementWidgetActions } from "../stores/widgets/ElementWidgetActions";
import WidgetStore from "../stores/WidgetStore";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "../stores/widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidgetStore";
const TIMEOUT_MS = 16000;
// Waits until an event is emitted satisfying the given predicate
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
let listener: (...args) => void;
const wait = new Promise<void>(resolve => {
listener = (...args) => { if (pred(...args)) resolve(); };
emitter.on(event, listener);
});
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
emitter.off(event, listener);
if (timedOut) throw new Error("Timed out");
};
export enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
export const isConnected = (state: ConnectionState): boolean =>
state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
export enum CallEvent {
ConnectionState = "connection_state",
Participants = "participants",
Destroy = "destroy",
}
interface CallEventHandlerMap {
[CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
[CallEvent.Participants]: (participants: Set<RoomMember>) => void;
[CallEvent.Destroy]: () => void;
}
interface JitsiCallMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
/**
* A group call accessed through a widget.
*/
export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget);
private _messaging: ClientWidgetApi | null = null;
/**
* The widget's messaging, or null if disconnected.
*/
protected get messaging(): ClientWidgetApi | null {
return this._messaging;
}
private set messaging(value: ClientWidgetApi | null) {
this._messaging = value;
}
public get roomId(): string {
return this.widget.roomId;
}
private _connectionState: ConnectionState = ConnectionState.Disconnected;
public get connectionState(): ConnectionState {
return this._connectionState;
}
protected set connectionState(value: ConnectionState) {
const prevValue = this._connectionState;
this._connectionState = value;
this.emit(CallEvent.ConnectionState, value, prevValue);
}
public get connected(): boolean {
return isConnected(this.connectionState);
}
private _participants = new Set<RoomMember>();
public get participants(): Set<RoomMember> {
return this._participants;
}
protected set participants(value: Set<RoomMember>) {
this._participants = value;
this.emit(CallEvent.Participants, value);
}
constructor(
/**
* The widget used to access this call.
*/
public readonly widget: IApp,
) {
super();
}
/**
* Gets the call associated with the given room, if any.
* @param {Room} room The room.
* @returns {Call | null} The call.
*/
public static get(room: Room): Call | null {
// There's currently only one implementation
return JitsiCall.get(room);
}
/**
* Performs a routine check of the call's associated room state, cleaning up
* any data left over from an unclean disconnection.
*/
public abstract clean(): Promise<void>;
/**
* Contacts the widget to connect to the call.
* @param {MediaDeviceInfo | null} audioDevice The audio input to use, or
* null to start muted.
* @param {MediaDeviceInfo | null} audioDevice The video input to use, or
* null to start muted.
*/
protected abstract performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void>;
/**
* Contacts the widget to disconnect from the call.
*/
protected abstract performDisconnection(): Promise<void>;
/**
* Connects the user to the call using the media devices set in
* MediaDeviceHandler. The widget associated with the call must be active
* for this to succeed.
*/
public async connect(): Promise<void> {
this.connectionState = ConnectionState.Connecting;
const {
[MediaDeviceKindEnum.AudioInput]: audioInputs,
[MediaDeviceKindEnum.VideoInput]: videoInputs,
} = await MediaDeviceHandler.getDevices();
let audioInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithAudioMuted) {
const deviceId = MediaDeviceHandler.getAudioInput();
audioInput = audioInputs.find(d => d.deviceId === deviceId) ?? audioInputs[0] ?? null;
}
let videoInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithVideoMuted) {
const deviceId = MediaDeviceHandler.getVideoInput();
videoInput = videoInputs.find(d => d.deviceId === deviceId) ?? videoInputs[0] ?? null;
}
const messagingStore = WidgetMessagingStore.instance;
this.messaging = messagingStore.getMessagingForUid(this.widgetUid);
if (!this.messaging) {
// The widget might still be initializing, so wait for it
try {
await waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.StoreMessaging,
(uid: string, widgetApi: ClientWidgetApi) => {
if (uid === this.widgetUid) {
this.messaging = widgetApi;
return true;
}
return false;
},
);
} catch (e) {
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
}
}
try {
await this.performConnection(audioInput, videoInput);
} catch (e) {
this.connectionState = ConnectionState.Disconnected;
throw e;
}
this.connectionState = ConnectionState.Connected;
}
/**
* Disconnects the user from the call.
*/
public async disconnect(): Promise<void> {
if (this.connectionState !== ConnectionState.Connected) throw new Error("Not connected");
this.connectionState = ConnectionState.Disconnecting;
await this.performDisconnection();
this.setDisconnected();
}
/**
* Manually marks the call as disconnected and cleans up.
*/
public setDisconnected() {
this.messaging = null;
this.connectionState = ConnectionState.Disconnected;
}
/**
* Stops all internal timers and tasks to prepare for garbage collection.
*/
public destroy() {
if (this.connected) this.setDisconnected();
this.emit(CallEvent.Destroy);
}
}
/**
* A group call using Jitsi as a backend.
*/
export class JitsiCall extends Call {
public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
public static readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private room: Room = this.client.getRoom(this.roomId)!;
private resendDevicesTimer: number | null = null;
private participantsExpirationTimer: number | null = null;
private constructor(widget: IApp, private readonly client: MatrixClient) {
super(widget);
this.room.on(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.updateParticipants();
}
public static get(room: Room): JitsiCall | null {
const apps = WidgetStore.instance.getApps(room.roomId);
// The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets
const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel);
return jitsiWidget ? new JitsiCall(jitsiWidget, room.client) : null;
}
public static async create(room: Room): Promise<void> {
await WidgetUtils.addJitsiWidget(room.roomId, CallType.Video, "Group call", true, room.name);
}
private updateParticipants() {
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
const members = new Set<RoomMember>();
const now = Date.now();
let allExpireAt = Infinity;
for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) {
const member = this.room.getMember(e.getStateKey()!);
const content = e.getContent<JitsiCallMemberContent>();
let devices = Array.isArray(content.devices) ? content.devices : [];
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
// Apply local echo for the disconnected case
if (!this.connected && member?.userId === this.client.getUserId()) {
devices = devices.filter(d => d !== this.client.getDeviceId());
}
// Must have a connected device, be unexpired, and still be joined to the room
if (devices.length && expiresAt > now && member?.membership === "join") {
members.add(member);
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
}
}
// Apply local echo for the connected case
if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
this.participants = members;
if (allExpireAt < Infinity) {
this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
}
}
// Helper method that updates our member state with the devices returned by
// the given function. If it returns null, the update is skipped.
private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
if (this.room.getMyMembership() !== "join") return;
const devicesState = this.room.currentState.getStateEvents(
JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!,
);
const devices = devicesState?.getContent<JitsiCallMemberContent>().devices ?? [];
const newDevices = fn(devices);
if (newDevices) {
const content: JitsiCallMemberContent = {
devices: newDevices,
expires_ts: Date.now() + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
};
await this.client.sendStateEvent(
this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
);
}
}
private async addOurDevice(): Promise<void> {
await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
}
private async removeOurDevice(): Promise<void> {
await this.updateDevices(devices => {
const devicesSet = new Set(devices);
devicesSet.delete(this.client.getDeviceId());
return Array.from(devicesSet);
});
}
public async clean(): Promise<void> {
const now = Date.now();
const { devices: myDevices } = await this.client.getDevices();
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
// Clean up our member state by filtering out logged out devices,
// inactive devices, and our own device (if we're disconnected)
await this.updateDevices(devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d);
return device?.last_seen_ts
&& !(d === this.client.getDeviceId() && !this.connected)
&& (now - device.last_seen_ts) < JitsiCall.STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const messagingStore = WidgetMessagingStore.instance;
const listener = (uid: string) => {
if (uid === this.widgetUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = () => {
cleanup();
resolve();
};
const cleanup = () => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(CallEvent.ConnectionState, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(CallEvent.ConnectionState, done);
});
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we need
// to add the hangup listener now rather than later
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
// Actually perform the join
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
try {
await Promise.race([Promise.all([request, response]), dontStopMessaging]);
} catch (e) {
// If it timed out, clean up our advance preparations
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (this.messaging!.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true });
}
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
}
protected async performDisconnection(): Promise<void> {
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
try {
await Promise.all([request, response]);
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected() {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
super.setDisconnected();
}
public destroy() {
this.room.off(RoomStateEvent.Update, this.updateParticipants);
this.on(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
}
if (this.resendDevicesTimer !== null) {
clearInterval(this.resendDevicesTimer);
this.resendDevicesTimer = null;
}
super.destroy();
}
private onRoomState = () => this.updateParticipants();
private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
if (state === ConnectionState.Connected && prevState === ConnectionState.Connecting) {
this.updateParticipants();
// Tell others that we're connected, by adding our device to room state
await this.addOurDevice();
// Re-add this device every so often so our video member event doesn't become stale
this.resendDevicesTimer = setInterval(async () => {
logger.log(`Resending video member event for ${this.roomId}`);
await this.addOurDevice();
}, (JitsiCall.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
} else if (state === ConnectionState.Disconnected && isConnected(prevState)) {
this.updateParticipants();
clearInterval(this.resendDevicesTimer);
this.resendDevicesTimer = null;
// Tell others that we're disconnected, by removing our device from room state
await this.removeOurDevice();
}
};
private onDock = async () => {
// The widget is no longer a PiP, so let's restore the default layout
await this.messaging!.transport.send(ElementWidgetActions.TileLayout, {});
};
private onUndock = async () => {
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
// to only show the active speaker and economize on space
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
private onMyMembership = async (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private beforeUnload = () => this.setDisconnected();
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (this.connectionState === ConnectionState.Connecting) {
await waitForEvent(this, CallEvent.ConnectionState);
}
await this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
};
}

View file

@ -962,9 +962,9 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: false,
},
"videoChannelRoomId": {
"activeCallRoomIds": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: null,
default: [],
},
[UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE,

View file

@ -449,7 +449,12 @@ export default class SettingsStore {
*/
/* eslint-enable valid-jsdoc */
public static async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise<void> {
public static async setValue(
settingName: string,
roomId: string | null,
level: SettingLevel,
value: any,
): Promise<void> {
// Verify that the setting is actually a setting
const setting = SETTINGS[settingName];
if (!setting) {

View file

@ -44,6 +44,10 @@ export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<
})(dispatcher);
}
public async start(): Promise<void> {
await this.readyStore.start();
}
get matrixClient(): MatrixClient {
return this.readyStore.mxClient;
}

View file

@ -45,7 +45,11 @@ interface IState {
* reported.
*/
export default class AutoRageshakeStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new AutoRageshakeStore();
private static readonly internalInstance = (() => {
const instance = new AutoRageshakeStore();
instance.start();
return instance;
})();
private constructor() {
super(defaultDispatcher, {

View file

@ -37,7 +37,11 @@ interface IState {
}
export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new BreadcrumbsStore();
private static readonly internalInstance = (() => {
const instance = new BreadcrumbsStore();
instance.start();
return instance;
})();
private waitingRooms: { roomId: string, addedTs: number }[] = [];

185
src/stores/CallStore.ts Normal file
View file

@ -0,0 +1,185 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomState } from "matrix-js-sdk/src/models/room-state";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { UPDATE_EVENT } from "./AsyncStore";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import WidgetStore from "./WidgetStore";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import { Call, CallEvent, ConnectionState } from "../models/Call";
export enum CallStoreEvent {
// Signals a change in the call associated with a given room
Call = "call",
// Signals a change in the active calls
ActiveCalls = "active_calls",
}
export class CallStore extends AsyncStoreWithClient<{}> {
private static _instance: CallStore;
public static get instance(): CallStore {
if (!this._instance) {
this._instance = new CallStore();
this._instance.start();
}
return this._instance;
}
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
protected async onReady(): Promise<any> {
// We assume that the calls present in a room are a function of room
// state and room widgets, so we initialize the room map here and then
// update it whenever those change
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
this.matrixClient.on(ClientEvent.Room, this.onRoom);
this.matrixClient.on(RoomStateEvent.Events, this.onRoomState);
WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);
// If the room ID of a previously connected call is still in settings at
// this time, that's a sign that we failed to disconnect from it
// properly, and need to clean up after ourselves
const uncleanlyDisconnectedRoomIds = SettingsStore.getValue<string[]>("activeCallRoomIds");
if (uncleanlyDisconnectedRoomIds.length) {
await Promise.all([
...uncleanlyDisconnectedRoomIds.map(async uncleanlyDisconnectedRoomId => {
logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`);
await this.get(uncleanlyDisconnectedRoomId)?.clean();
}),
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []),
]);
}
}
protected async onNotReady(): Promise<any> {
for (const [call, listenerMap] of this.callListeners) {
// It's important that we remove the listeners before destroying the
// call, because otherwise the call's onDestroy callback would fire
// and immediately repopulate the map
for (const [event, listener] of listenerMap) call.off(event, listener);
call.destroy();
}
this.callListeners.clear();
this.calls.clear();
this.activeCalls = new Set();
this.matrixClient.off(ClientEvent.Room, this.onRoom);
this.matrixClient.off(RoomStateEvent.Events, this.onRoomState);
WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
}
private _activeCalls: Set<Call> = new Set();
/**
* The calls to which the user is currently connected.
*/
public get activeCalls(): Set<Call> {
return this._activeCalls;
}
private set activeCalls(value: Set<Call>) {
this._activeCalls = value;
this.emit(CallStoreEvent.ActiveCalls, value);
// The room IDs are persisted to settings so we can detect unclean disconnects
SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, [...value].map(call => call.roomId));
}
private calls = new Map<string, Call>(); // Key is room ID
private callListeners = new Map<Call, Map<CallEvent, (...args: unknown[]) => unknown>>();
private updateRoom(room: Room) {
if (!this.calls.has(room.roomId)) {
const call = Call.get(room);
if (call) {
const onConnectionState = (state: ConnectionState) => {
if (state === ConnectionState.Connected) {
this.activeCalls = new Set([...this.activeCalls, call]);
} else if (state === ConnectionState.Disconnected) {
this.activeCalls = new Set([...this.activeCalls].filter(c => c !== call));
}
};
const onDestroy = () => {
this.calls.delete(room.roomId);
for (const [event, listener] of this.callListeners.get(call)!) call.off(event, listener);
this.updateRoom(room);
};
call.on(CallEvent.ConnectionState, onConnectionState);
call.on(CallEvent.Destroy, onDestroy);
this.calls.set(room.roomId, call);
this.callListeners.set(call, new Map<CallEvent, (...args: unknown[]) => unknown>([
[CallEvent.ConnectionState, onConnectionState],
[CallEvent.Destroy, onDestroy],
]));
}
this.emit(CallStoreEvent.Call, call, room.roomId);
}
}
/**
* Gets the call associated with the given room, if any.
* @param {string} roomId The room's ID.
* @returns {Call | null} The call.
*/
public get(roomId: string): Call | null {
return this.calls.get(roomId) ?? null;
}
private onRoom = (room: Room) => this.updateRoom(room);
private onRoomState = (event: MatrixEvent, state: RoomState) => {
// If there's already a call stored for this room, it's understood to
// still be valid until destroyed
if (!this.calls.has(state.roomId)) {
const room = this.matrixClient.getRoom(state.roomId);
// State events can arrive before the room does, when creating a room
if (room !== null) this.updateRoom(room);
}
};
private onWidgets = (roomId: string | null) => {
if (roomId === null) {
// This store happened to start before the widget store was done
// loading all rooms, so we need to initialize each room again
for (const room of this.matrixClient.getRooms()) {
this.updateRoom(room);
}
} else {
const room = this.matrixClient.getRoom(roomId);
// Widget updates can arrive before the room does, empirically
if (room !== null) this.updateRoom(room);
}
};
}

View file

@ -30,10 +30,14 @@ interface IState {
}
export class ModalWidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new ModalWidgetStore();
private modalInstance: IHandle<void[]> = null;
private openSourceWidgetId: string = null;
private openSourceWidgetRoomId: string = null;
private static readonly internalInstance = (() => {
const instance = new ModalWidgetStore();
instance.start();
return instance;
})();
private modalInstance: IHandle<void[]> | null = null;
private openSourceWidgetId: string | null = null;
private openSourceWidgetRoomId: string | null = null;
private constructor() {
super(defaultDispatcher, {});

View file

@ -92,7 +92,11 @@ const getLocallyCreatedBeaconEventIds = (): string[] => {
return ids;
};
export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> {
private static internalInstance = new OwnBeaconStore();
private static readonly internalInstance = (() => {
const instance = new OwnBeaconStore();
instance.start();
return instance;
})();
// users beacons, keyed by event type
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
public readonly beaconsByRoomId = new Map<Room['roomId'], Set<BeaconIdentifier>>();

View file

@ -37,7 +37,11 @@ const KEY_DISPLAY_NAME = "mx_profile_displayname";
const KEY_AVATAR_URL = "mx_profile_avatar_url";
export class OwnProfileStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new OwnProfileStore();
private static readonly internalInstance = (() => {
const instance = new OwnProfileStore();
instance.start();
return instance;
})();
private monitoredUser: User;

View file

@ -26,18 +26,19 @@ import { Action } from "../dispatcher/actions";
export abstract class ReadyWatchingStore extends EventEmitter implements IDestroyable {
protected matrixClient: MatrixClient;
private readonly dispatcherRef: string;
private dispatcherRef: string | null = null;
constructor(protected readonly dispatcher: Dispatcher<ActionPayload>) {
super();
}
public async start(): Promise<void> {
this.dispatcherRef = this.dispatcher.register(this.onAction);
if (MatrixClientPeg.get()) {
this.matrixClient = MatrixClientPeg.get();
// noinspection JSIgnoredPromiseFromCall
this.onReady();
const matrixClient = MatrixClientPeg.get();
if (matrixClient) {
this.matrixClient = matrixClient;
await this.onReady();
}
}
@ -50,7 +51,7 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro
}
public destroy() {
this.dispatcher.unregister(this.dispatcherRef);
if (this.dispatcherRef !== null) this.dispatcher.unregister(this.dispatcherRef);
}
protected async onReady() {

View file

@ -1,355 +0,0 @@
/*
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 { logger } from "matrix-js-sdk/src/logger";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientWidgetApi, IWidgetApiRequest } from "matrix-widget-api";
import SettingsStore from "../settings/SettingsStore";
import { SettingLevel } from "../settings/SettingLevel";
import defaultDispatcher from "../dispatcher/dispatcher";
import { ActionPayload } from "../dispatcher/payloads";
import { ElementWidgetActions } from "./widgets/ElementWidgetActions";
import { WidgetMessagingStore, WidgetMessagingStoreEvent } from "./widgets/WidgetMessagingStore";
import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "./ActiveWidgetStore";
import { STUCK_DEVICE_TIMEOUT_MS, getVideoChannel, addOurDevice, removeOurDevice } from "../utils/VideoChannelUtils";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
export enum VideoChannelEvent {
StartConnect = "start_connect",
Connect = "connect",
Disconnect = "disconnect",
Participants = "participants",
}
export interface IJitsiParticipant {
avatarURL: string;
displayName: string;
formattedDisplayName: string;
participantId: string;
}
const TIMEOUT_MS = 16000;
// Wait until an event is emitted satisfying the given predicate
const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
let listener;
const wait = new Promise<void>(resolve => {
listener = (...args) => { if (pred(...args)) resolve(); };
emitter.on(event, listener);
});
const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
emitter.off(event, listener);
if (timedOut) throw new Error("Timed out");
};
/*
* Holds information about the currently active video channel.
*/
export default class VideoChannelStore extends AsyncStoreWithClient<null> {
private static _instance: VideoChannelStore;
public static get instance(): VideoChannelStore {
if (!VideoChannelStore._instance) {
VideoChannelStore._instance = new VideoChannelStore();
}
return VideoChannelStore._instance;
}
private constructor() {
super(defaultDispatcher);
}
protected async onAction(payload: ActionPayload): Promise<void> {
// nothing to do
}
private activeChannel: ClientWidgetApi;
private resendDevicesTimer: number;
// This is persisted to settings so we can detect unclean disconnects
public get roomId(): string | null { return SettingsStore.getValue("videoChannelRoomId"); }
private set roomId(value: string | null) {
SettingsStore.setValue("videoChannelRoomId", null, SettingLevel.DEVICE, value);
}
private get room(): Room { return this.matrixClient.getRoom(this.roomId); }
private _connected = false;
public get connected(): boolean { return this._connected; }
private set connected(value: boolean) { this._connected = value; }
private _participants: IJitsiParticipant[] = [];
public get participants(): IJitsiParticipant[] { return this._participants; }
private set participants(value: IJitsiParticipant[]) { this._participants = value; }
public get audioMuted(): boolean { return SettingsStore.getValue("audioInputMuted"); }
public set audioMuted(value: boolean) {
SettingsStore.setValue("audioInputMuted", null, SettingLevel.DEVICE, value);
}
public get videoMuted(): boolean { return SettingsStore.getValue("videoInputMuted"); }
public set videoMuted(value: boolean) {
SettingsStore.setValue("videoInputMuted", null, SettingLevel.DEVICE, value);
}
public connect = async (
roomId: string,
audioDevice: MediaDeviceInfo | null,
videoDevice: MediaDeviceInfo | null,
) => {
if (this.activeChannel) await this.disconnect();
const jitsi = getVideoChannel(roomId);
if (!jitsi) throw new Error(`No video channel in room ${roomId}`);
const jitsiUid = WidgetUtils.getWidgetUid(jitsi);
const messagingStore = WidgetMessagingStore.instance;
let messaging = messagingStore.getMessagingForUid(jitsiUid);
if (!messaging) {
// The widget might still be initializing, so wait for it
try {
await waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.StoreMessaging,
(uid: string, widgetApi: ClientWidgetApi) => {
if (uid === jitsiUid) {
messaging = widgetApi;
return true;
}
return false;
},
);
} catch (e) {
throw new Error(`Failed to bind video channel in room ${roomId}: ${e}`);
}
}
// Now that we got the messaging, we need a way to ensure that it doesn't get stopped
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const listener = (uid: string) => {
if (uid === jitsiUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = () => {
cleanup();
resolve();
};
const cleanup = () => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(VideoChannelEvent.Connect, done);
this.off(VideoChannelEvent.Disconnect, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(VideoChannelEvent.Connect, done);
this.on(VideoChannelEvent.Disconnect, done);
});
if (!messagingStore.isWidgetReady(jitsiUid)) {
// Wait for the widget to be ready to receive our join event
try {
await Promise.race([
waitForEvent(
messagingStore,
WidgetMessagingStoreEvent.WidgetReady,
(uid: string) => uid === jitsiUid,
),
dontStopMessaging,
]);
} catch (e) {
throw new Error(`Video channel in room ${roomId} never became ready: ${e}`);
}
}
// Participant data and mute state will come down the event pipeline quickly, so prepare in advance
this.activeChannel = messaging;
this.roomId = roomId;
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);
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we also
// need to add the hangup listener now rather than later
messaging.once(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.emit(VideoChannelEvent.StartConnect, roomId);
// Actually perform the join
const waitForJoin = waitForEvent(
messaging,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.ack(ev);
return true;
},
);
messaging.transport.send(ElementWidgetActions.JoinCall, {
audioDevice: audioDevice?.label ?? null,
videoDevice: videoDevice?.label ?? null,
});
try {
await Promise.race([waitForJoin, dontStopMessaging]);
} 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);
messaging.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (messaging.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
messaging.transport.send(ElementWidgetActions.ForceHangupCall, {});
}
this.emit(VideoChannelEvent.Disconnect, roomId);
throw new Error(`Failed to join call in room ${roomId}: ${e}`);
}
this.connected = true;
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.setDisconnected);
this.emit(VideoChannelEvent.Connect, roomId);
// Tell others that we're connected, by adding our device to room state
await addOurDevice(this.room);
// Re-add this device every so often so our video member event doesn't become stale
this.resendDevicesTimer = setInterval(async () => {
logger.log(`Resending video member event for ${this.roomId}`);
await addOurDevice(this.room);
}, (STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
};
public disconnect = async () => {
if (!this.activeChannel) throw new Error("Not connected to any video channel");
const waitForDisconnect = waitForEvent(this, VideoChannelEvent.Disconnect);
this.activeChannel.transport.send(ElementWidgetActions.HangupCall, {});
try {
await waitForDisconnect; // onHangup cleans up for us
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
};
public setDisconnected = async () => {
const roomId = this.roomId;
const room = this.room;
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.activeChannel.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.setDisconnected);
clearInterval(this.resendDevicesTimer);
this.activeChannel = null;
this.roomId = null;
this.connected = false;
this.participants = [];
this.emit(VideoChannelEvent.Disconnect, roomId);
// Tell others that we're disconnected, by removing our device from room state
await removeOurDevice(room);
};
private ack = (ev: CustomEvent<IWidgetApiRequest>, messaging = this.activeChannel) => {
// Even if we don't have a reply to a given widget action, we still need
// to give the widget API something to acknowledge receipt
messaging.transport.reply(ev.detail, {});
};
private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
const messaging = this.activeChannel;
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (!this.connected) await waitForEvent(this, VideoChannelEvent.Connect);
await this.setDisconnected();
this.ack(ev, messaging);
};
private onParticipants = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.participants = ev.detail.data.participants as IJitsiParticipant[];
this.emit(VideoChannelEvent.Participants, this.roomId, ev.detail.data.participants);
this.ack(ev);
};
private onMuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.audioMuted = true;
this.ack(ev);
};
private onUnmuteAudio = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.audioMuted = false;
this.ack(ev);
};
private onMuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.videoMuted = true;
this.ack(ev);
};
private onUnmuteVideo = (ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.videoMuted = false;
this.ack(ev);
};
private onMyMembership = (room: Room, membership: string) => {
if (membership !== "join") this.setDisconnected();
};
private onDock = async () => {
// The widget is no longer a PiP, so let's restore the default layout
await this.activeChannel.transport.send(ElementWidgetActions.TileLayout, {});
};
private onUndock = async () => {
// The widget has become a PiP, so let's switch Jitsi to spotlight mode
// to only show the active speaker and economize on space
await this.activeChannel.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
}

View file

@ -33,10 +33,11 @@ export class VoiceRecordingStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): VoiceRecordingStore {
if (!VoiceRecordingStore.internalInstance) {
VoiceRecordingStore.internalInstance = new VoiceRecordingStore();
if (!this.internalInstance) {
this.internalInstance = new VoiceRecordingStore();
this.internalInstance.start();
}
return VoiceRecordingStore.internalInstance;
return this.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<void> {

View file

@ -36,7 +36,7 @@ export interface IApp extends IWidget {
roomId: string;
eventId: string;
// eslint-disable-next-line camelcase
avatar_url: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
}
interface IRoomWidgets {
@ -46,7 +46,11 @@ interface IRoomWidgets {
// TODO consolidate WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new WidgetStore();
private static readonly internalInstance = (() => {
const instance = new WidgetStore();
instance.start();
return instance;
})();
private widgetMap = new Map<string, IApp>(); // Key is widget Unique ID (UID)
private roomMap = new Map<string, IRoomWidgets>(); // Key is room ID

View file

@ -44,10 +44,11 @@ export class EchoStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): EchoStore {
if (!EchoStore._instance) {
EchoStore._instance = new EchoStore();
if (!this._instance) {
this._instance = new EchoStore();
this._instance.start();
}
return EchoStore._instance;
return this._instance;
}
public get contexts(): EchoContext[] {

View file

@ -34,7 +34,11 @@ interface IState {}
export const UPDATE_STATUS_INDICATOR = Symbol("update-status-indicator");
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private static readonly internalInstance = (() => {
const instance = new RoomNotificationStateStore();
instance.start();
return instance;
})();
private roomMap = new Map<Room, RoomNotificationState>();
private roomThreadsMap = new Map<Room, ThreadsRoomNotificationState>();

View file

@ -394,10 +394,11 @@ export default class RightPanelStore extends ReadyWatchingStore {
}
public static get instance(): RightPanelStore {
if (!RightPanelStore.internalInstance) {
RightPanelStore.internalInstance = new RightPanelStore();
if (!this.internalInstance) {
this.internalInstance = new RightPanelStore();
this.internalInstance.start();
}
return RightPanelStore.internalInstance;
return this.internalInstance;
}
}

View file

@ -25,9 +25,9 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { PollStartEventPreview } from "./previews/PollStartEventPreview";
import { TagID } from "./models";
import { CallInviteEventPreview } from "./previews/CallInviteEventPreview";
import { CallAnswerEventPreview } from "./previews/CallAnswerEventPreview";
import { CallHangupEvent } from "./previews/CallHangupEvent";
import { LegacyCallInviteEventPreview } from "./previews/LegacyCallInviteEventPreview";
import { LegacyCallAnswerEventPreview } from "./previews/LegacyCallAnswerEventPreview";
import { LegacyCallHangupEvent } from "./previews/LegacyCallHangupEvent";
import { StickerEventPreview } from "./previews/StickerEventPreview";
import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
@ -47,15 +47,15 @@ const PREVIEWS: Record<string, {
},
'm.call.invite': {
isState: false,
previewer: new CallInviteEventPreview(),
previewer: new LegacyCallInviteEventPreview(),
},
'm.call.answer': {
isState: false,
previewer: new CallAnswerEventPreview(),
previewer: new LegacyCallAnswerEventPreview(),
},
'm.call.hangup': {
isState: false,
previewer: new CallHangupEvent(),
previewer: new LegacyCallHangupEvent(),
},
'm.sticker': {
isState: false,
@ -87,7 +87,11 @@ interface IState {
}
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new MessagePreviewStore();
private static readonly internalInstance = (() => {
const instance = new MessagePreviewStore();
instance.start();
return instance;
})();
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID|TAG_ANY, string|null>>();

View file

@ -34,8 +34,9 @@ export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
}
public static get instance(): RoomListLayoutStore {
if (!RoomListLayoutStore.internalInstance) {
RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
if (!this.internalInstance) {
this.internalInstance = new RoomListLayoutStore();
this.internalInstance.start();
}
return RoomListLayoutStore.internalInstance;
}

View file

@ -602,11 +602,13 @@ export default class RoomListStore {
private static internalInstance: Interface;
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
RoomListStore.internalInstance = new RoomListStoreClass();
if (!this.internalInstance) {
const instance = new RoomListStoreClass();
instance.start();
this.internalInstance = instance;
}
return RoomListStore.internalInstance;
return this.internalInstance;
}
}

View file

@ -34,7 +34,7 @@ import { EffectiveMembership, getEffectiveMembership, splitRoomsByMembership } f
import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering";
import { VisibilityProvider } from "../filters/VisibilityProvider";
import VideoChannelStore, { VideoChannelEvent } from "../../VideoChannelStore";
import { CallStore, CallStoreEvent } from "../../CallStore";
/**
* Fired when the Algorithm has determined a list has been updated.
@ -82,13 +82,11 @@ export class Algorithm extends EventEmitter {
public updatesInhibited = false;
public start() {
VideoChannelStore.instance.on(VideoChannelEvent.Connect, this.updateVideoRoom);
VideoChannelStore.instance.on(VideoChannelEvent.Disconnect, this.updateVideoRoom);
CallStore.instance.on(CallStoreEvent.ActiveCalls, this.onActiveCalls);
}
public stop() {
VideoChannelStore.instance.off(VideoChannelEvent.Connect, this.updateVideoRoom);
VideoChannelStore.instance.off(VideoChannelEvent.Disconnect, this.updateVideoRoom);
CallStore.instance.off(CallStoreEvent.ActiveCalls, this.onActiveCalls);
}
public get stickyRoom(): Room {
@ -106,7 +104,7 @@ export class Algorithm extends EventEmitter {
protected set cachedRooms(val: ITagMap) {
this._cachedRooms = val;
this.recalculateStickyRoom();
this.recalculateVideoRoom();
this.recalculateActiveCallRooms();
}
protected get cachedRooms(): ITagMap {
@ -143,7 +141,7 @@ export class Algorithm extends EventEmitter {
algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
public getListOrdering(tagId: TagID): ListAlgorithm {
@ -162,7 +160,7 @@ export class Algorithm extends EventEmitter {
algorithm.setRooms(this._cachedRooms[tagId]);
this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
private updateStickyRoom(val: Room) {
@ -279,22 +277,20 @@ export class Algorithm extends EventEmitter {
// a room while filtering and it'll disappear. We don't update the filter earlier in
// this function simply because we don't have to.
this.recalculateStickyRoom();
this.recalculateVideoRoom(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateVideoRoom(lastStickyRoom.tag);
this.recalculateActiveCallRooms(tag);
if (lastStickyRoom && lastStickyRoom.tag !== tag) this.recalculateActiveCallRooms(lastStickyRoom.tag);
// Finally, trigger an update
if (this.updatesInhibited) return;
this.emit(LIST_UPDATED_EVENT);
}
/**
* Update the stickiness of video rooms.
*/
public updateVideoRoom = () => {
// In case we're unsticking a video room, sort it back into natural order
private onActiveCalls = () => {
// In case we're unsticking a room, sort it back into natural order
this.recalculateStickyRoom();
this.recalculateVideoRoom();
// Update the stickiness of rooms with calls
this.recalculateActiveCallRooms();
if (this.updatesInhibited) return;
// This isn't in response to any particular RoomListStore update,
@ -358,16 +354,16 @@ export class Algorithm extends EventEmitter {
}
/**
* Recalculate the position of any video rooms. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
* the call.
* Recalculate the position of any rooms with calls. If this is being called in
* relation to a specific tag being updated, it should be given to this function to
* optimize the call.
*
* This expects to be called *after* the sticky rooms are updated, and sticks the
* currently connected video room to the top of its tag.
* room with the currently active call to the top of its tag.
*
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateVideoRoom(updatedTag: TagID = null): void {
protected recalculateActiveCallRooms(updatedTag: TagID = null): void {
if (!updatedTag) {
// Assume all tags need updating
// We're not modifying the map here, so can safely rely on the cached values
@ -376,24 +372,26 @@ export class Algorithm extends EventEmitter {
if (!tagId) {
throw new Error("Unexpected recursion: falsy tag");
}
this.recalculateVideoRoom(tagId);
this.recalculateActiveCallRooms(tagId);
}
return;
}
const videoRoomId = VideoChannelStore.instance.connected ? VideoChannelStore.instance.roomId : null;
if (videoRoomId) {
// We operate directly on the sticky rooms map
if (CallStore.instance.activeCalls.size) {
// We operate on the sticky rooms map
if (!this._cachedStickyRooms) this.initCachedStickyRooms();
const rooms = this._cachedStickyRooms[updatedTag];
const videoRoomIdxInTag = rooms.findIndex(r => r.roomId === videoRoomId);
if (videoRoomIdxInTag < 0) return; // no-op
const videoRoom = rooms[videoRoomIdxInTag];
rooms.splice(videoRoomIdxInTag, 1);
rooms.unshift(videoRoom);
this._cachedStickyRooms[updatedTag] = rooms; // re-set because references aren't always safe
const activeRoomIds = new Set([...CallStore.instance.activeCalls].map(call => call.roomId));
const activeRooms: Room[] = [];
const inactiveRooms: Room[] = [];
for (const room of rooms) {
(activeRoomIds.has(room.roomId) ? activeRooms : inactiveRooms).push(room);
}
// Stick rooms with active calls to the top
this._cachedStickyRooms[updatedTag] = [...activeRooms, ...inactiveRooms];
}
}
@ -666,7 +664,7 @@ export class Algorithm extends EventEmitter {
algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
this.recalculateVideoRoom(rmTag);
this.recalculateActiveCallRooms(rmTag);
}
for (const addTag of diff.added) {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
@ -742,7 +740,7 @@ export class Algorithm extends EventEmitter {
// Flag that we've done something
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
this.recalculateVideoRoom(tag);
this.recalculateActiveCallRooms(tag);
changed = true;
}

View file

@ -16,7 +16,7 @@
import { Room } from "matrix-js-sdk/src/models/room";
import CallHandler from "../../../CallHandler";
import LegacyCallHandler from "../../../LegacyCallHandler";
import { RoomListCustomisations } from "../../../customisations/RoomList";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import VoipUserMapper from "../../../VoipUserMapper";
@ -44,7 +44,7 @@ export class VisibilityProvider {
}
if (
CallHandler.instance.getSupportsVirtualRooms() &&
LegacyCallHandler.instance.getSupportsVirtualRooms() &&
VoipUserMapper.sharedInstance().isVirtualRoom(room)
) {
return false;

View file

@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallAnswerEventPreview implements IPreview {
export class LegacyCallAnswerEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {

View file

@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallHangupEvent implements IPreview {
export class LegacyCallHangupEvent implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {

View file

@ -21,7 +21,7 @@ import { TagID } from "../models";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import { _t } from "../../../languageHandler";
export class CallInviteEventPreview implements IPreview {
export class LegacyCallInviteEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
if (shouldPrefixMessagesIn(event.getRoomId(), tagId)) {
if (isSelf(event)) {

View file

@ -1284,7 +1284,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}
export default class SpaceStore {
private static internalInstance = new SpaceStoreClass();
private static readonly internalInstance = (() => {
const instance = new SpaceStoreClass();
instance.start();
return instance;
})();
public static get instance(): SpaceStoreClass {
return SpaceStore.internalInstance;

View file

@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020-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.
@ -17,13 +17,9 @@
import { IWidgetApiRequest } from "matrix-widget-api";
export enum ElementWidgetActions {
ClientReady = "im.vector.ready",
WidgetReady = "io.element.widget_ready",
// All of these actions are currently specific to Jitsi
JoinCall = "io.element.join",
HangupCall = "im.vector.hangup",
ForceHangupCall = "io.element.force_hangup",
CallParticipants = "io.element.participants",
MuteAudio = "io.element.mute_audio",
UnmuteAudio = "io.element.unmute_audio",

View file

@ -114,10 +114,11 @@ export class WidgetLayoutStore extends ReadyWatchingStore {
}
public static get instance(): WidgetLayoutStore {
if (!WidgetLayoutStore.internalInstance) {
WidgetLayoutStore.internalInstance = new WidgetLayoutStore();
if (!this.internalInstance) {
this.internalInstance = new WidgetLayoutStore();
this.internalInstance.start();
}
return WidgetLayoutStore.internalInstance;
return this.internalInstance;
}
public static emissionForRoom(room: Room): string {

View file

@ -14,9 +14,8 @@
* limitations under the License.
*/
import { ClientWidgetApi, Widget, IWidgetApiRequest } from "matrix-widget-api";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import { ElementWidgetActions } from "./ElementWidgetActions";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
@ -26,7 +25,6 @@ import WidgetUtils from "../../utils/WidgetUtils";
export enum WidgetMessagingStoreEvent {
StoreMessaging = "store_messaging",
StopMessaging = "stop_messaging",
WidgetReady = "widget_ready",
}
/**
@ -34,11 +32,14 @@ export enum WidgetMessagingStoreEvent {
* going to be merged with a more complete WidgetStore, but for now it's
* easiest to split this into a single place.
*/
export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
private static internalInstance = new WidgetMessagingStore();
export class WidgetMessagingStore extends AsyncStoreWithClient<{}> {
private static readonly internalInstance = (() => {
const instance = new WidgetMessagingStore();
instance.start();
return instance;
})();
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget UID, ClientWidgetAPi>
private readyWidgets = new Set<string>(); // widgets that have sent a WidgetReady event
public constructor() {
super(defaultDispatcher);
@ -62,12 +63,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
const uid = WidgetUtils.calcWidgetUid(widget.id, roomId);
this.widgetMap.set(uid, widgetApi);
widgetApi.once(`action:${ElementWidgetActions.WidgetReady}`, (ev: CustomEvent<IWidgetApiRequest>) => {
this.readyWidgets.add(uid);
this.emit(WidgetMessagingStoreEvent.WidgetReady, uid);
widgetApi.transport.reply(ev.detail, {}); // ack
});
this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, widgetApi);
}
@ -85,7 +80,6 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
*/
public stopMessagingByUid(widgetUid: string) {
this.widgetMap.remove(widgetUid)?.stop();
this.readyWidgets.delete(widgetUid);
this.emit(WidgetMessagingStoreEvent.StopMessaging, widgetUid);
}
@ -97,12 +91,4 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
public getMessagingForUid(widgetUid: string): ClientWidgetApi {
return this.widgetMap.get(widgetUid);
}
/**
* @param {string} widgetUid The widget UID.
* @returns {boolean} Whether the widget has issued an ElementWidgetActions.WidgetReady event.
*/
public isWidgetReady(widgetUid: string): boolean {
return this.readyWidgets.has(widgetUid);
}
}

View file

@ -21,14 +21,14 @@ import React from 'react';
import { CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames';
import CallHandler, { CallHandlerEvent } from '../CallHandler';
import LegacyCallHandler, { LegacyCallHandlerEvent } from '../LegacyCallHandler';
import { MatrixClientPeg } from '../MatrixClientPeg';
import { _t } from '../languageHandler';
import RoomAvatar from '../components/views/avatars/RoomAvatar';
import AccessibleTooltipButton from '../components/views/elements/AccessibleTooltipButton';
import AccessibleButton from '../components/views/elements/AccessibleButton';
export const getIncomingCallToastKey = (callId: string) => `call_${callId}`;
export const getIncomingLegacyCallToastKey = (callId: string) => `call_${callId}`;
interface IProps {
call: MatrixCall;
@ -38,83 +38,87 @@ interface IState {
silenced: boolean;
}
export default class IncomingCallToast extends React.Component<IProps, IState> {
export default class IncomingLegacyCallToast extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
silenced: CallHandler.instance.isCallSilenced(this.props.call.callId),
silenced: LegacyCallHandler.instance.isCallSilenced(this.props.call.callId),
};
}
public componentDidMount = (): void => {
CallHandler.instance.addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.addListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
};
public componentWillUnmount(): void {
CallHandler.instance.removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged);
LegacyCallHandler.instance.removeListener(
LegacyCallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged,
);
}
private onSilencedCallsChanged = (): void => {
this.setState({ silenced: CallHandler.instance.isCallSilenced(this.props.call.callId) });
this.setState({ silenced: LegacyCallHandler.instance.isCallSilenced(this.props.call.callId) });
};
private onAnswerClick = (e: React.MouseEvent): void => {
e.stopPropagation();
CallHandler.instance.answerCall(CallHandler.instance.roomIdForCall(this.props.call));
LegacyCallHandler.instance.answerCall(LegacyCallHandler.instance.roomIdForCall(this.props.call));
};
private onRejectClick= (e: React.MouseEvent): void => {
e.stopPropagation();
CallHandler.instance.hangupOrReject(CallHandler.instance.roomIdForCall(this.props.call), true);
LegacyCallHandler.instance.hangupOrReject(LegacyCallHandler.instance.roomIdForCall(this.props.call), true);
};
private onSilenceClick = (e: React.MouseEvent): void => {
e.stopPropagation();
const callId = this.props.call.callId;
this.state.silenced ?
CallHandler.instance.unSilenceCall(callId) :
CallHandler.instance.silenceCall(callId);
LegacyCallHandler.instance.unSilenceCall(callId) :
LegacyCallHandler.instance.silenceCall(callId);
};
public render() {
const call = this.props.call;
const room = MatrixClientPeg.get().getRoom(CallHandler.instance.roomIdForCall(call));
const room = MatrixClientPeg.get().getRoom(LegacyCallHandler.instance.roomIdForCall(call));
const isVoice = call.type === CallType.Voice;
const contentClass = classNames("mx_IncomingCallToast_content", {
"mx_IncomingCallToast_content_voice": isVoice,
"mx_IncomingCallToast_content_video": !isVoice,
const contentClass = classNames("mx_IncomingLegacyCallToast_content", {
"mx_IncomingLegacyCallToast_content_voice": isVoice,
"mx_IncomingLegacyCallToast_content_video": !isVoice,
});
const silenceClass = classNames("mx_IncomingCallToast_iconButton", {
"mx_IncomingCallToast_unSilence": this.state.silenced,
"mx_IncomingCallToast_silence": !this.state.silenced,
const silenceClass = classNames("mx_IncomingLegacyCallToast_iconButton", {
"mx_IncomingLegacyCallToast_unSilence": this.state.silenced,
"mx_IncomingLegacyCallToast_silence": !this.state.silenced,
});
return <React.Fragment>
<RoomAvatar
room={room}
room={room ?? undefined}
height={32}
width={32}
/>
<div className={contentClass}>
<span className="mx_CallEvent_caller">
<span className="mx_LegacyCallEvent_caller">
{ room ? room.name : _t("Unknown caller") }
</span>
<div className="mx_CallEvent_type">
<div className="mx_CallEvent_type_icon" />
<div className="mx_LegacyCallEvent_type">
<div className="mx_LegacyCallEvent_type_icon" />
{ isVoice ? _t("Voice call") : _t("Video call") }
</div>
<div className="mx_IncomingCallToast_buttons">
<div className="mx_IncomingLegacyCallToast_buttons">
<AccessibleButton
className="mx_IncomingCallToast_button mx_IncomingCallToast_button_decline"
className="mx_IncomingLegacyCallToast_button mx_IncomingLegacyCallToast_button_decline"
onClick={this.onRejectClick}
kind="danger"
>
<span> { _t("Decline") } </span>
</AccessibleButton>
<AccessibleButton
className="mx_IncomingCallToast_button mx_IncomingCallToast_button_accept"
className="mx_IncomingLegacyCallToast_button mx_IncomingLegacyCallToast_button_accept"
onClick={this.onAnswerClick}
kind="primary"
>

View file

@ -1,204 +0,0 @@
/*
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 { useState, useMemo, useEffect } from "react";
import { throttle } from "lodash";
import { Optional } from "matrix-events-sdk";
import { logger } from "matrix-js-sdk/src/logger";
import { IMyDevice } from "matrix-js-sdk/src/client";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter";
import WidgetStore, { IApp } from "../stores/WidgetStore";
import { WidgetType } from "../widgets/WidgetType";
import WidgetUtils from "./WidgetUtils";
import VideoChannelStore, { VideoChannelEvent, IJitsiParticipant } from "../stores/VideoChannelStore";
interface IVideoChannelMemberContent {
// Connected device IDs
devices: string[];
// Time at which this state event should be considered stale
expires_ts: number;
}
export const VIDEO_CHANNEL_MEMBER = "io.element.video.member";
export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
export enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
}
export const getVideoChannel = (roomId: string): IApp => {
const apps = WidgetStore.instance.getApps(roomId);
return apps.find(app => WidgetType.JITSI.matches(app.type) && app.data.isVideoChannel);
};
export const addVideoChannel = async (roomId: string, roomName: string) => {
await WidgetUtils.addJitsiWidget(roomId, CallType.Video, "Video channel", true, roomName);
};
// Gets the members connected to a given video room, along with a timestamp
// indicating when this data should be considered stale
const getConnectedMembers = (room: Room, connectedLocalEcho: boolean): [Set<RoomMember>, number] => {
const members = new Set<RoomMember>();
const now = Date.now();
let allExpireAt = Infinity;
for (const e of room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER)) {
const member = room.getMember(e.getStateKey());
const content = e.getContent<IVideoChannelMemberContent>();
let devices = Array.isArray(content.devices) ? content.devices : [];
const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
// Ignore events with a timeout that's way off in the future
const inTheFuture = (expiresAt - ((STUCK_DEVICE_TIMEOUT_MS * 5) / 4)) > now;
const expired = expiresAt <= now || inTheFuture;
// Apply local echo for the disconnected case
if (!connectedLocalEcho && member?.userId === room.client.getUserId()) {
devices = devices.filter(d => d !== room.client.getDeviceId());
}
// Must have a device connected, be unexpired, and still be joined to the room
if (devices.length && !expired && member?.membership === "join") {
members.add(member);
if (expiresAt < allExpireAt) allExpireAt = expiresAt;
}
}
// Apply local echo for the connected case
if (connectedLocalEcho) members.add(room.getMember(room.client.getUserId()));
return [members, allExpireAt];
};
export const useConnectedMembers = (
room: Room, connectedLocalEcho: boolean, throttleMs = 100,
): Set<RoomMember> => {
const [[members, expiresAt], setState] = useState(() => getConnectedMembers(room, connectedLocalEcho));
const updateState = useMemo(() => throttle(() => {
setState(getConnectedMembers(room, connectedLocalEcho));
}, throttleMs, { leading: true, trailing: true }), [setState, room, connectedLocalEcho, throttleMs]);
useTypedEventEmitter(room.currentState, RoomStateEvent.Update, updateState);
useEffect(() => {
if (expiresAt < Infinity) {
const timer = setTimeout(() => {
logger.log(`Refreshing video members for ${room.roomId}`);
updateState();
}, expiresAt - Date.now());
return () => clearTimeout(timer);
}
}, [expiresAt, updateState, room.roomId]);
return members;
};
export const useJitsiParticipants = (room: Room): IJitsiParticipant[] => {
const store = VideoChannelStore.instance;
const [participants, setParticipants] = useState(() =>
store.connected && store.roomId === room.roomId ? store.participants : [],
);
useEventEmitter(store, VideoChannelEvent.Disconnect, (roomId: string) => {
if (roomId === room.roomId) setParticipants([]);
});
useEventEmitter(store, VideoChannelEvent.Participants, (roomId: string, participants: IJitsiParticipant[]) => {
if (roomId === room.roomId) setParticipants(participants);
});
return participants;
};
const updateDevices = async (room: Optional<Room>, fn: (devices: string[] | null) => string[]) => {
if (room?.getMyMembership() !== "join") return;
const devicesState = room.currentState.getStateEvents(VIDEO_CHANNEL_MEMBER, room.client.getUserId());
const devices = devicesState?.getContent<IVideoChannelMemberContent>()?.devices ?? [];
const newDevices = fn(devices);
if (newDevices) {
const content: IVideoChannelMemberContent = {
devices: newDevices,
expires_ts: Date.now() + STUCK_DEVICE_TIMEOUT_MS,
};
await room.client.sendStateEvent(room.roomId, VIDEO_CHANNEL_MEMBER, content, room.client.getUserId());
}
};
export const addOurDevice = async (room: Room) => {
await updateDevices(room, devices => Array.from(new Set(devices).add(room.client.getDeviceId())));
};
export const removeOurDevice = async (room: Room) => {
await updateDevices(room, devices => {
const devicesSet = new Set(devices);
devicesSet.delete(room.client.getDeviceId());
return Array.from(devicesSet);
});
};
/**
* Fixes devices that may have gotten stuck in video channel member state after
* an unclean disconnection, by filtering out logged out devices, inactive
* devices, and our own device (if we're disconnected).
* @param {Room} room The room to fix
* @param {boolean} connectedLocalEcho Local echo of whether this device is connected
*/
export const fixStuckDevices = async (room: Room, connectedLocalEcho: boolean) => {
const now = Date.now();
const { devices: myDevices } = await room.client.getDevices();
const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
await updateDevices(room, devices => {
const newDevices = devices.filter(d => {
const device = deviceMap.get(d);
return device?.last_seen_ts
&& !(d === room.client.getDeviceId() && !connectedLocalEcho)
&& (now - device.last_seen_ts) < STUCK_DEVICE_TIMEOUT_MS;
});
// Skip the update if the devices are unchanged
return newDevices.length === devices.length ? null : newDevices;
});
};
export const useConnectionState = (room: Room): ConnectionState => {
const store = VideoChannelStore.instance;
const [state, setState] = useState(() =>
store.roomId === room.roomId
? store.connected
? ConnectionState.Connected
: ConnectionState.Connecting
: ConnectionState.Disconnected,
);
useEventEmitter(store, VideoChannelEvent.Disconnect, (roomId: string) => {
if (roomId === room.roomId) setState(ConnectionState.Disconnected);
});
useEventEmitter(store, VideoChannelEvent.StartConnect, (roomId: string) => {
if (roomId === room.roomId) setState(ConnectionState.Connecting);
});
useEventEmitter(store, VideoChannelEvent.Connect, (roomId: string) => {
if (roomId === room.roomId) setState(ConnectionState.Connected);
});
return state;
};