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

Attended transfer
pull/21833/head
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-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
.mx_CallView_voice_avatarContainer {
border-radius: 2000px;
@ -91,7 +91,7 @@ limitations under the License.
}
}
.mx_CallView_voice_holdText {
.mx_CallView_holdTransferContent {
height: 20px;
padding-top: 20px;
padding-bottom: 15px;
@ -142,7 +142,7 @@ limitations under the License.
}
}
.mx_CallView_video_holdContent {
.mx_CallView_video .mx_CallView_holdTransferContent {
position: absolute;
top: 50%;
left: 50%;

View File

@ -154,6 +154,9 @@ function getRemoteAudioElement(): HTMLAudioElement {
export default class CallHandler {
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 dispatcherRef: string = null;
private supportsPstnProtocol = null;
@ -325,6 +328,10 @@ export default class CallHandler {
return callsNotInThatRoom;
}
getTransfereeForCallId(callId: string): MatrixCall {
return this.transferees[callId];
}
play(audioId: AudioID) {
// TODO: Attach an invisible element for this instead
// which listens?
@ -622,6 +629,7 @@ export default class CallHandler {
private async placeCall(
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
transferee: MatrixCall,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
@ -634,6 +642,9 @@ export default class CallHandler {
const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
this.calls.set(roomId, call);
if (transferee) {
this.transferees[call.callId] = transferee;
}
this.setCallListeners(call);
this.setCallAudioElement(call);
@ -723,7 +734,10 @@ export default class CallHandler {
} else if (members.length === 2) {
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
dis.dispatch({
action: "place_conference_call",

View File

@ -29,7 +29,9 @@ import dis from "../../../dispatcher/dispatcher";
import IdentityAuthClient from "../../../IdentityAuthClient";
import Modal from "../../../Modal";
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 {Key} from "../../../Keyboard";
import {Action} from "../../../dispatcher/actions";
@ -332,6 +334,7 @@ interface IInviteDialogState {
threepidResultsMixin: { user: Member, userId: string}[];
canUseIdentityServer: boolean;
tryingIdentityServer: boolean;
consultFirst: boolean;
// These two flags are used for the 'Go' button to communicate what is going on.
busy: boolean,
@ -380,6 +383,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
threepidResultsMixin: [],
canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(),
tryingIdentityServer: false,
consultFirst: false,
// These two flags are used for the 'Go' button to communicate what is going on.
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}[] {
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});
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"),
if (this.state.consultFirst) {
const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), targetIds[0]);
dis.dispatch({
action: 'place_call',
type: this.props.call.type,
room_id: dmRoomId,
transferee: this.props.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 buttonText;
let goButtonFn;
let consultSection;
let keySharingWarning = <span />;
const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer);
@ -1339,6 +1366,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
title = _t("Transfer");
buttonText = _t("Transfer");
goButtonFn = this._transferCall;
consultSection = <div>
<label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
{_t("Consult first")}
</label>
</div>;
} else {
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('suggestions')}
</div>
{consultSection}
</div>
</BaseDialog>
);

View File

@ -364,6 +364,11 @@ export default class CallView extends React.Component<IProps, IState> {
CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
}
private onTransferClick = () => {
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
this.props.call.transferToCall(transfereeCall);
}
public render() {
const client = MatrixClientPeg.get();
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)
let contentView: React.ReactNode;
const transfereeCall = CallHandler.sharedInstance().getTransfereeForCallId(this.props.call.callId);
const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold;
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,
});
let holdTransferContent;
if (transfereeCall) {
const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call));
const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person");
const transfereeRoom = MatrixClientPeg.get().getRoom(
CallHandler.roomIdForCall(transfereeCall),
);
const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person");
holdTransferContent = <div className="mx_CallView_holdTransferContent">
{_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) {
let localVideoFeed = null;
let onHoldContent = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const containerClasses = classNames({
@ -505,9 +537,6 @@ export default class CallView extends React.Component<IProps, IState> {
mx_CallView_video_hold: isOnHold,
});
if (isOnHold) {
onHoldContent = <div className="mx_CallView_video_holdContent">
{onHoldText}
</div>;
const backgroundAvatarUrl = avatarUrlForMember(
// is it worth getting the size of the div to pass here?
this.props.call.getOpponentMember(), 1024, 1024, 'crop',
@ -534,7 +563,7 @@ export default class CallView extends React.Component<IProps, IState> {
maxHeight={maxVideoHeight}
/>
{localVideoFeed}
{onHoldContent}
{holdTransferContent}
{callControls}
</div>;
} else {
@ -554,7 +583,7 @@ export default class CallView extends React.Component<IProps, IState> {
/>
</div>
</div>
<div className="mx_CallView_voice_holdText">{onHoldText}</div>
{holdTransferContent}
{callControls}
</div>;
}

View File

@ -881,6 +881,8 @@
"sends fireworks": "sends fireworks",
"Sends the given message with snowfall": "Sends the given message with 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>Resume</a>": "You held the call <a>Resume</a>",
"%(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>.",
"Invited people will be able to read old messages.": "Invited people will be able to read old messages.",
"Transfer": "Transfer",
"Consult first": "Consult first",
"a new master key signature": "a new master key signature",
"a new cross-signing key signature": "a new cross-signing key signature",
"a device cross-signing signature": "a device cross-signing signature",