Merge pull request #5798 from matrix-org/dbkr/attended_transfer

Attended transfer
This commit is contained in:
David Baker 2021-04-01 17:34:30 +01:00 committed by GitHub
commit cd39474d26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 114 additions and 34 deletions

View file

@ -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%;

View file

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

View file

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

View file

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

View file

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