Merge pull request #5798 from matrix-org/dbkr/attended_transfer
Attended transfer
This commit is contained in:
commit
cd39474d26
5 changed files with 114 additions and 34 deletions
|
@ -55,7 +55,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_voice_holdText {
|
.mx_CallView_holdTransferContent {
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
padding-bottom: 25px;
|
padding-bottom: 25px;
|
||||||
}
|
}
|
||||||
|
@ -82,7 +82,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_voice_hold {
|
.mx_CallView_voice .mx_CallView_holdTransferContent {
|
||||||
// This masks the avatar image so when it's blurred, the edge is still crisp
|
// This masks the avatar image so when it's blurred, the edge is still crisp
|
||||||
.mx_CallView_voice_avatarContainer {
|
.mx_CallView_voice_avatarContainer {
|
||||||
border-radius: 2000px;
|
border-radius: 2000px;
|
||||||
|
@ -91,7 +91,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_voice_holdText {
|
.mx_CallView_holdTransferContent {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
|
@ -142,7 +142,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_CallView_video_holdContent {
|
.mx_CallView_video .mx_CallView_holdTransferContent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|
|
@ -154,6 +154,9 @@ function getRemoteAudioElement(): HTMLAudioElement {
|
||||||
|
|
||||||
export default class CallHandler {
|
export default class CallHandler {
|
||||||
private calls = new Map<string, MatrixCall>(); // roomId -> call
|
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.
|
||||||
|
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
|
||||||
private audioPromises = new Map<AudioID, Promise<void>>();
|
private audioPromises = new Map<AudioID, Promise<void>>();
|
||||||
private dispatcherRef: string = null;
|
private dispatcherRef: string = null;
|
||||||
private supportsPstnProtocol = null;
|
private supportsPstnProtocol = null;
|
||||||
|
@ -325,6 +328,10 @@ export default class CallHandler {
|
||||||
return callsNotInThatRoom;
|
return callsNotInThatRoom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTransfereeForCallId(callId: string): MatrixCall {
|
||||||
|
return this.transferees[callId];
|
||||||
|
}
|
||||||
|
|
||||||
play(audioId: AudioID) {
|
play(audioId: AudioID) {
|
||||||
// TODO: Attach an invisible element for this instead
|
// TODO: Attach an invisible element for this instead
|
||||||
// which listens?
|
// which listens?
|
||||||
|
@ -622,6 +629,7 @@ export default class CallHandler {
|
||||||
private async placeCall(
|
private async placeCall(
|
||||||
roomId: string, type: PlaceCallType,
|
roomId: string, type: PlaceCallType,
|
||||||
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
|
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
|
||||||
|
transferee: MatrixCall,
|
||||||
) {
|
) {
|
||||||
Analytics.trackEvent('voip', 'placeCall', 'type', type);
|
Analytics.trackEvent('voip', 'placeCall', 'type', type);
|
||||||
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
|
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
|
||||||
|
@ -634,6 +642,9 @@ export default class CallHandler {
|
||||||
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
|
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
|
||||||
|
|
||||||
this.calls.set(roomId, call);
|
this.calls.set(roomId, call);
|
||||||
|
if (transferee) {
|
||||||
|
this.transferees[call.callId] = transferee;
|
||||||
|
}
|
||||||
|
|
||||||
this.setCallListeners(call);
|
this.setCallListeners(call);
|
||||||
this.setCallAudioElement(call);
|
this.setCallAudioElement(call);
|
||||||
|
@ -723,7 +734,10 @@ export default class CallHandler {
|
||||||
} else if (members.length === 2) {
|
} else if (members.length === 2) {
|
||||||
console.info(`Place ${payload.type} call in ${payload.room_id}`);
|
console.info(`Place ${payload.type} call in ${payload.room_id}`);
|
||||||
|
|
||||||
this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
|
this.placeCall(
|
||||||
|
payload.room_id, payload.type, payload.local_element, payload.remote_element,
|
||||||
|
payload.transferee,
|
||||||
|
);
|
||||||
} else { // > 2
|
} else { // > 2
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: "place_conference_call",
|
action: "place_conference_call",
|
||||||
|
|
|
@ -29,7 +29,9 @@ import dis from "../../../dispatcher/dispatcher";
|
||||||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import {humanizeTime} from "../../../utils/humanize";
|
import {humanizeTime} from "../../../utils/humanize";
|
||||||
import createRoom, {canEncryptToAllUsers, findDMForUser, privateShouldBeEncrypted} from "../../../createRoom";
|
import createRoom, {
|
||||||
|
canEncryptToAllUsers, ensureDMExists, findDMForUser, privateShouldBeEncrypted,
|
||||||
|
} from "../../../createRoom";
|
||||||
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
|
import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite";
|
||||||
import {Key} from "../../../Keyboard";
|
import {Key} from "../../../Keyboard";
|
||||||
import {Action} from "../../../dispatcher/actions";
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
@ -332,6 +334,7 @@ interface IInviteDialogState {
|
||||||
threepidResultsMixin: { user: Member, userId: string}[];
|
threepidResultsMixin: { user: Member, userId: string}[];
|
||||||
canUseIdentityServer: boolean;
|
canUseIdentityServer: boolean;
|
||||||
tryingIdentityServer: boolean;
|
tryingIdentityServer: boolean;
|
||||||
|
consultFirst: boolean;
|
||||||
|
|
||||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||||
busy: boolean,
|
busy: boolean,
|
||||||
|
@ -380,6 +383,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
threepidResultsMixin: [],
|
threepidResultsMixin: [],
|
||||||
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
|
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
|
||||||
tryingIdentityServer: false,
|
tryingIdentityServer: false,
|
||||||
|
consultFirst: false,
|
||||||
|
|
||||||
// These two flags are used for the 'Go' button to communicate what is going on.
|
// These two flags are used for the 'Go' button to communicate what is going on.
|
||||||
busy: false,
|
busy: false,
|
||||||
|
@ -395,6 +399,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onConsultFirstChange = (ev) => {
|
||||||
|
this.setState({consultFirst: ev.target.checked});
|
||||||
|
}
|
||||||
|
|
||||||
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
|
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] {
|
||||||
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
||||||
|
|
||||||
|
@ -745,16 +753,34 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({busy: true});
|
if (this.state.consultFirst) {
|
||||||
try {
|
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), targetIds[0]);
|
||||||
await this.props.call.transfer(targetIds[0]);
|
|
||||||
this.setState({busy: false});
|
dis.dispatch({
|
||||||
this.props.onFinished();
|
action: 'place_call',
|
||||||
} catch (e) {
|
type: this.props.call.type,
|
||||||
this.setState({
|
room_id: dmRoomId,
|
||||||
busy: false,
|
transferee: this.props.call,
|
||||||
errorText: _t("Failed to transfer call"),
|
|
||||||
});
|
});
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'view_room',
|
||||||
|
room_id: dmRoomId,
|
||||||
|
should_peek: false,
|
||||||
|
joining: false,
|
||||||
|
});
|
||||||
|
this.props.onFinished();
|
||||||
|
} else {
|
||||||
|
this.setState({busy: true});
|
||||||
|
try {
|
||||||
|
await this.props.call.transfer(targetIds[0]);
|
||||||
|
this.setState({busy: false});
|
||||||
|
this.props.onFinished();
|
||||||
|
} catch (e) {
|
||||||
|
this.setState({
|
||||||
|
busy: false,
|
||||||
|
errorText: _t("Failed to transfer call"),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1215,6 +1241,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
let helpText;
|
let helpText;
|
||||||
let buttonText;
|
let buttonText;
|
||||||
let goButtonFn;
|
let goButtonFn;
|
||||||
|
let consultSection;
|
||||||
let keySharingWarning = <span />;
|
let keySharingWarning = <span />;
|
||||||
|
|
||||||
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
|
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
|
||||||
|
@ -1339,6 +1366,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
title = _t("Transfer");
|
title = _t("Transfer");
|
||||||
buttonText = _t("Transfer");
|
buttonText = _t("Transfer");
|
||||||
goButtonFn = this._transferCall;
|
goButtonFn = this._transferCall;
|
||||||
|
consultSection = <div>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
|
||||||
|
{_t("Consult first")}
|
||||||
|
</label>
|
||||||
|
</div>;
|
||||||
} else {
|
} else {
|
||||||
console.error("Unknown kind of InviteDialog: " + this.props.kind);
|
console.error("Unknown kind of InviteDialog: " + this.props.kind);
|
||||||
}
|
}
|
||||||
|
@ -1375,6 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
||||||
{this._renderSection('recents')}
|
{this._renderSection('recents')}
|
||||||
{this._renderSection('suggestions')}
|
{this._renderSection('suggestions')}
|
||||||
</div>
|
</div>
|
||||||
|
{consultSection}
|
||||||
</div>
|
</div>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
);
|
);
|
||||||
|
|
|
@ -364,6 +364,11 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onTransferClick = () => {
|
||||||
|
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
|
||||||
|
this.props.call.transferToCall(transfereeCall);
|
||||||
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const client = MatrixClientPeg.get();
|
const client = MatrixClientPeg.get();
|
||||||
const callRoomId = CallHandler.roomIdForCall(this.props.call);
|
const callRoomId = CallHandler.roomIdForCall(this.props.call);
|
||||||
|
@ -479,25 +484,52 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
// for voice calls (fills the bg)
|
// for voice calls (fills the bg)
|
||||||
let contentView: React.ReactNode;
|
let contentView: React.ReactNode;
|
||||||
|
|
||||||
|
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
|
||||||
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
|
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
|
||||||
let onHoldText = null;
|
let holdTransferContent;
|
||||||
if (this.state.isRemoteOnHold) {
|
if (transfereeCall) {
|
||||||
const holdString = CallHandler.sharedInstance().hasAnyUnheldCall() ?
|
const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call));
|
||||||
_td("You held the call <a>Switch</a>") : _td("You held the call <a>Resume</a>");
|
const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person");
|
||||||
onHoldText = _t(holdString, {}, {
|
|
||||||
a: sub => <AccessibleButton kind="link" onClick={this.onCallResumeClick}>
|
const transfereeRoom = MatrixClientPeg.get().getRoom(
|
||||||
{sub}
|
CallHandler.roomIdForCall(transfereeCall),
|
||||||
</AccessibleButton>,
|
);
|
||||||
});
|
const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person");
|
||||||
} else if (this.state.isLocalOnHold) {
|
|
||||||
onHoldText = _t("%(peerName)s held the call", {
|
holdTransferContent = <div className="mx_CallView_holdTransferContent">
|
||||||
peerName: this.props.call.getOpponentMember().name,
|
{_t(
|
||||||
});
|
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||||
|
{
|
||||||
|
transferTarget: transferTargetName,
|
||||||
|
transferee: transfereeName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
a: sub => <AccessibleButton kind="link" onClick={this.onTransferClick}>{sub}</AccessibleButton>,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>;
|
||||||
|
} else if (isOnHold) {
|
||||||
|
let onHoldText = null;
|
||||||
|
if (this.state.isRemoteOnHold) {
|
||||||
|
const holdString = CallHandler.sharedInstance().hasAnyUnheldCall() ?
|
||||||
|
_td("You held the call <a>Switch</a>") : _td("You held the call <a>Resume</a>");
|
||||||
|
onHoldText = _t(holdString, {}, {
|
||||||
|
a: sub => <AccessibleButton kind="link" onClick={this.onCallResumeClick}>
|
||||||
|
{sub}
|
||||||
|
</AccessibleButton>,
|
||||||
|
});
|
||||||
|
} else if (this.state.isLocalOnHold) {
|
||||||
|
onHoldText = _t("%(peerName)s held the call", {
|
||||||
|
peerName: this.props.call.getOpponentMember().name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
holdTransferContent = <div className="mx_CallView_holdTransferContent">
|
||||||
|
{onHoldText}
|
||||||
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.props.call.type === CallType.Video) {
|
if (this.props.call.type === CallType.Video) {
|
||||||
let localVideoFeed = null;
|
let localVideoFeed = null;
|
||||||
let onHoldContent = null;
|
|
||||||
let onHoldBackground = null;
|
let onHoldBackground = null;
|
||||||
const backgroundStyle: CSSProperties = {};
|
const backgroundStyle: CSSProperties = {};
|
||||||
const containerClasses = classNames({
|
const containerClasses = classNames({
|
||||||
|
@ -505,9 +537,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
mx_CallView_video_hold: isOnHold,
|
mx_CallView_video_hold: isOnHold,
|
||||||
});
|
});
|
||||||
if (isOnHold) {
|
if (isOnHold) {
|
||||||
onHoldContent = <div className="mx_CallView_video_holdContent">
|
|
||||||
{onHoldText}
|
|
||||||
</div>;
|
|
||||||
const backgroundAvatarUrl = avatarUrlForMember(
|
const backgroundAvatarUrl = avatarUrlForMember(
|
||||||
// is it worth getting the size of the div to pass here?
|
// is it worth getting the size of the div to pass here?
|
||||||
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
|
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
|
||||||
|
@ -534,7 +563,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
maxHeight={maxVideoHeight}
|
maxHeight={maxVideoHeight}
|
||||||
/>
|
/>
|
||||||
{localVideoFeed}
|
{localVideoFeed}
|
||||||
{onHoldContent}
|
{holdTransferContent}
|
||||||
{callControls}
|
{callControls}
|
||||||
</div>;
|
</div>;
|
||||||
} else {
|
} else {
|
||||||
|
@ -554,7 +583,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx_CallView_voice_holdText">{onHoldText}</div>
|
{holdTransferContent}
|
||||||
{callControls}
|
{callControls}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -881,6 +881,8 @@
|
||||||
"sends fireworks": "sends fireworks",
|
"sends fireworks": "sends fireworks",
|
||||||
"Sends the given message with snowfall": "Sends the given message with snowfall",
|
"Sends the given message with snowfall": "Sends the given message with snowfall",
|
||||||
"sends snowfall": "sends snowfall",
|
"sends snowfall": "sends snowfall",
|
||||||
|
"unknown person": "unknown person",
|
||||||
|
"Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>": "Consulting with %(transferTarget)s. <a>Transfer to %(transferee)s</a>",
|
||||||
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
|
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
|
||||||
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
|
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
|
||||||
"%(peerName)s held the call": "%(peerName)s held the call",
|
"%(peerName)s held the call": "%(peerName)s held the call",
|
||||||
|
@ -2215,6 +2217,7 @@
|
||||||
"Invite someone using their name, username (like <userId/>) or <a>share this room</a>.": "Invite someone using their name, username (like <userId/>) or <a>share this room</a>.",
|
"Invite someone using their name, username (like <userId/>) or <a>share this room</a>.": "Invite someone using their name, username (like <userId/>) or <a>share this room</a>.",
|
||||||
"Invited people will be able to read old messages.": "Invited people will be able to read old messages.",
|
"Invited people will be able to read old messages.": "Invited people will be able to read old messages.",
|
||||||
"Transfer": "Transfer",
|
"Transfer": "Transfer",
|
||||||
|
"Consult first": "Consult first",
|
||||||
"a new master key signature": "a new master key signature",
|
"a new master key signature": "a new master key signature",
|
||||||
"a new cross-signing key signature": "a new cross-signing key signature",
|
"a new cross-signing key signature": "a new cross-signing key signature",
|
||||||
"a device cross-signing signature": "a device cross-signing signature",
|
"a device cross-signing signature": "a device cross-signing signature",
|
||||||
|
|
Loading…
Reference in a new issue