change(ui): change cursor icon for assist, add usernames to cursors for user and agent

This commit is contained in:
sylenien 2022-09-15 16:55:27 +02:00 committed by Delirium
parent 3d2107fe98
commit c56a0da63b
9 changed files with 292 additions and 197 deletions

View file

@ -5,8 +5,18 @@ import cn from 'classnames';
import { toggleChatWindow } from 'Duck/sessions';
import { connectPlayer } from 'Player/store';
import ChatWindow from '../../ChatWindow';
import { callPeer, setCallArgs, requestReleaseRemoteControl, toggleAnnotation } from 'Player';
import { CallingState, ConnectionStatus, RemoteControlStatus } from 'Player/MessageDistributor/managers/AssistManager';
import {
callPeer,
setCallArgs,
requestReleaseRemoteControl,
toggleAnnotation,
toggleUserName,
} from 'Player';
import {
CallingState,
ConnectionStatus,
RemoteControlStatus,
} from 'Player/MessageDistributor/managers/AssistManager';
import RequestLocalStream from 'Player/MessageDistributor/managers/LocalStream';
import type { LocalStream } from 'Player/MessageDistributor/managers/LocalStream';
@ -15,199 +25,228 @@ import { confirm } from 'UI';
import stl from './AassistActions.module.css';
function onReject() {
toast.info(`Call was rejected.`);
toast.info(`Call was rejected.`);
}
function onError(e: any) {
console.log(e)
toast.error(typeof e === 'string' ? e : e.message);
console.log(e);
toast.error(typeof e === 'string' ? e : e.message);
}
interface Props {
userId: string;
calling: CallingState;
annotating: boolean;
peerConnectionStatus: ConnectionStatus;
remoteControlStatus: RemoteControlStatus;
hasPermission: boolean;
isEnterprise: boolean;
isCallActive: boolean;
agentIds: string[];
livePlay: boolean;
userId: string;
calling: CallingState;
annotating: boolean;
peerConnectionStatus: ConnectionStatus;
remoteControlStatus: RemoteControlStatus;
hasPermission: boolean;
isEnterprise: boolean;
isCallActive: boolean;
agentIds: string[];
livePlay: boolean;
userDisplayName: string;
}
function AssistActions({
userId,
calling,
annotating,
peerConnectionStatus,
remoteControlStatus,
hasPermission,
isEnterprise,
isCallActive,
agentIds,
livePlay
userId,
calling,
annotating,
peerConnectionStatus,
remoteControlStatus,
hasPermission,
isEnterprise,
isCallActive,
agentIds,
livePlay,
userDisplayName,
}: Props) {
const [isPrestart, setPrestart] = useState(false);
const [incomeStream, setIncomeStream] = useState<MediaStream[] | null>([]);
const [localStream, setLocalStream] = useState<LocalStream | null>(null);
const [callObject, setCallObject] = useState<{ end: () => void } | null>(null);
const [isPrestart, setPrestart] = useState(false);
const [incomeStream, setIncomeStream] = useState<MediaStream[] | null>([]);
const [localStream, setLocalStream] = useState<LocalStream | null>(null);
const [callObject, setCallObject] = useState<{ end: () => void } | null>(null);
const onCall = calling === CallingState.OnCall || calling === CallingState.Reconnecting;
const callRequesting = calling === CallingState.Connecting
const cannotCall = peerConnectionStatus !== ConnectionStatus.Connected || (isEnterprise && !hasPermission);
const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled;
const onCall = calling === CallingState.OnCall || calling === CallingState.Reconnecting;
const callRequesting = calling === CallingState.Connecting;
const cannotCall =
peerConnectionStatus !== ConnectionStatus.Connected || (isEnterprise && !hasPermission);
const remoteActive = remoteControlStatus === RemoteControlStatus.Enabled;
useEffect(() => {
return callObject?.end()
}, [])
useEffect(() => {
if (peerConnectionStatus == ConnectionStatus.Disconnected) {
toast.info(`Live session was closed.`);
}
}, [peerConnectionStatus]);
const addIncomeStream = (stream: MediaStream) => {
setIncomeStream(oldState => {
if (!oldState.find(existingStream => existingStream.id === stream.id)) {
return [...oldState, stream]
}
return oldState
});
useEffect(() => {
if (!onCall && isCallActive && agentIds) {
setPrestart(true);
// call(agentIds); do not autocall on prestart, can change later
}
}, [agentIds, isCallActive]);
function call(additionalAgentIds?: string[]) {
RequestLocalStream().then(lStream => {
setLocalStream(lStream);
setCallArgs(
lStream,
addIncomeStream,
lStream.stop.bind(lStream),
onReject,
onError
)
setCallObject(callPeer());
if (additionalAgentIds) {
callPeer(additionalAgentIds)
}
}).catch(onError)
useEffect(() => {
if (!livePlay) {
if (annotating) {
toggleAnnotation(false);
}
if (remoteActive) {
requestReleaseRemoteControl();
}
}
}, [livePlay]);
React.useEffect(() => {
if (!onCall && isCallActive && agentIds) {
setPrestart(true);
// call(agentIds); do not autocall on prestart, can change later
}
}, [agentIds, isCallActive])
const confirmCall = async () => {
if (callRequesting) return;
if (
await confirm({
header: 'Start Call',
confirmButton: 'Call',
confirmation: `Are you sure you want to call ${userId ? userId : 'User'}?`,
})
) {
call(agentIds);
}
};
const requestControl = () => {
if (callRequesting) return;
requestReleaseRemoteControl()
useEffect(() => {
if (remoteActive) {
toggleUserName(userDisplayName);
} else {
toggleUserName();
}
}, [remoteActive]);
React.useEffect(() => {
if (!livePlay) {
if (annotating) {
toggleAnnotation(false);
}
if (remoteActive) {
requestReleaseRemoteControl()
}
useEffect(() => {
return callObject?.end();
}, []);
useEffect(() => {
if (peerConnectionStatus == ConnectionStatus.Disconnected) {
toast.info(`Live session was closed.`);
}
}, [peerConnectionStatus]);
const addIncomeStream = (stream: MediaStream) => {
setIncomeStream((oldState) => {
if (!oldState.find((existingStream) => existingStream.id === stream.id)) {
return [...oldState, stream];
}
return oldState;
});
};
function call(additionalAgentIds?: string[]) {
RequestLocalStream()
.then((lStream) => {
setLocalStream(lStream);
setCallArgs(lStream, addIncomeStream, lStream.stop.bind(lStream), onReject, onError);
setCallObject(callPeer());
if (additionalAgentIds) {
callPeer(additionalAgentIds);
}
}, [livePlay])
})
.catch(onError);
}
return (
<div className="flex items-center">
{(onCall || remoteActive) && (
<>
<div
className={cn('cursor-pointer p-2 flex items-center', { [stl.disabled]: cannotCall || !livePlay })}
onClick={() => toggleAnnotation(!annotating)}
role="button"
>
<Button
icon={annotating ? 'pencil-stop' : 'pencil'}
variant={annotating ? 'text-red' : 'text-primary'}
style={{ height: '28px' }}
>
Annotate
</Button>
{/* <IconButton label={`Annotate`} icon={annotating ? 'pencil-stop' : 'pencil'} primaryText redText={annotating} /> */}
</div>
<div className={stl.divider} />
</>
)}
<div
className={cn('cursor-pointer p-2 flex items-center', { [stl.disabled]: cannotCall || !livePlay || callRequesting })}
onClick={requestControl}
role="button"
const confirmCall = async () => {
if (callRequesting) return;
if (
await confirm({
header: 'Start Call',
confirmButton: 'Call',
confirmation: `Are you sure you want to call ${userId ? userId : 'User'}?`,
})
) {
call(agentIds);
}
};
const requestControl = () => {
if (callRequesting) return;
requestReleaseRemoteControl();
};
return (
<div className="flex items-center">
{(onCall || remoteActive) && (
<>
<div
className={cn('cursor-pointer p-2 flex items-center', {
[stl.disabled]: cannotCall || !livePlay,
})}
onClick={() => toggleAnnotation(!annotating)}
role="button"
>
<Button
icon={annotating ? 'pencil-stop' : 'pencil'}
variant={annotating ? 'text-red' : 'text-primary'}
style={{ height: '28px' }}
>
<Button
icon={remoteActive ? 'window-x' : 'remote-control'}
variant={remoteActive ? 'text-red' : 'text-primary'}
style={{ height: '28px' }}
>
Remote Control
</Button>
{/* <IconButton label={`Remote Control`} icon={remoteActive ? 'window-x' : 'remote-control'} primaryText redText={remoteActive} /> */}
</div>
<div className={stl.divider} />
Annotate
</Button>
{/* <IconButton label={`Annotate`} icon={annotating ? 'pencil-stop' : 'pencil'} primaryText redText={annotating} /> */}
</div>
<div className={stl.divider} />
</>
)}
<div
className={cn('cursor-pointer p-2 flex items-center', {
[stl.disabled]: cannotCall || !livePlay || callRequesting,
})}
onClick={requestControl}
role="button"
>
<Button
icon={remoteActive ? 'window-x' : 'remote-control'}
variant={remoteActive ? 'text-red' : 'text-primary'}
style={{ height: '28px' }}
>
Remote Control
</Button>
{/* <IconButton label={`Remote Control`} icon={remoteActive ? 'window-x' : 'remote-control'} primaryText redText={remoteActive} /> */}
</div>
<div className={stl.divider} />
<Popup content={cannotCall ? `You don't have the permissions to perform this action.` : `Call ${userId ? userId : 'User'}`}>
<div
className={cn('cursor-pointer p-2 flex items-center', { [stl.disabled]: cannotCall || callRequesting })}
onClick={onCall ? callObject?.end : confirmCall}
role="button"
>
<Button icon="headset" variant={onCall ? 'text-red' : isPrestart ? 'green' : 'primary'} style={{ height: '28px' }}>
{onCall ? 'End' : isPrestart ? 'Join Call' : 'Call'}
</Button>
{/* <IconButton size="small" primary={!onCall} red={onCall} label={onCall ? 'End' : 'Call'} icon="headset" /> */}
</div>
</Popup>
<div className="fixed ml-3 left-0 top-0" style={{ zIndex: 999 }}>
{onCall && callObject && (
<ChatWindow endCall={callObject.end} userId={userId} incomeStream={incomeStream} localStream={localStream} isPrestart={isPrestart} />
)}
</div>
<Popup
content={
cannotCall
? `You don't have the permissions to perform this action.`
: `Call ${userId ? userId : 'User'}`
}
>
<div
className={cn('cursor-pointer p-2 flex items-center', {
[stl.disabled]: cannotCall || callRequesting,
})}
onClick={onCall ? callObject?.end : confirmCall}
role="button"
>
<Button
icon="headset"
variant={onCall ? 'text-red' : isPrestart ? 'green' : 'primary'}
style={{ height: '28px' }}
>
{onCall ? 'End' : isPrestart ? 'Join Call' : 'Call'}
</Button>
{/* <IconButton size="small" primary={!onCall} red={onCall} label={onCall ? 'End' : 'Call'} icon="headset" /> */}
</div>
);
</Popup>
<div className="fixed ml-3 left-0 top-0" style={{ zIndex: 999 }}>
{onCall && callObject && (
<ChatWindow
endCall={callObject.end}
userId={userId}
incomeStream={incomeStream}
localStream={localStream}
isPrestart={isPrestart}
/>
)}
</div>
</div>
);
}
const con = connect(
(state) => {
const permissions = state.getIn(['user', 'account', 'permissions']) || [];
return {
hasPermission: permissions.includes('ASSIST_CALL'),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
};
},
{ toggleChatWindow }
(state) => {
const permissions = state.getIn(['user', 'account', 'permissions']) || [];
return {
hasPermission: permissions.includes('ASSIST_CALL'),
isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee',
userDisplayName: state.getIn(['sessions', 'current', 'userDisplayName']),
};
},
{ toggleChatWindow }
);
export default con(
connectPlayer((state) => ({
calling: state.calling,
annotating: state.annotating,
remoteControlStatus: state.remoteControl,
peerConnectionStatus: state.peerConnectionStatus,
livePlay: state.livePlay,
}))(AssistActions)
connectPlayer((state) => ({
calling: state.calling,
annotating: state.annotating,
remoteControlStatus: state.remoteControl,
peerConnectionStatus: state.peerConnectionStatus,
livePlay: state.livePlay,
}))(AssistActions)
);

View file

@ -4,6 +4,7 @@ import styles from './cursor.module.css';
export default class Cursor {
private readonly cursor: HTMLDivElement;
private nameElement: HTMLDivElement;
private readonly position: Point = { x: -1, y: -1 }
constructor(overlay: HTMLDivElement) {
this.cursor = document.createElement('div');
@ -19,6 +20,32 @@ export default class Cursor {
}
}
toggleUserName(name?: string) {
if (!this.nameElement) {
this.nameElement = document.createElement('div')
Object.assign(this.nameElement.style, {
position: 'absolute',
padding: '4px 6px',
borderRadius: '8px',
backgroundColor: 'rgb(57, 78, 255)',
color: 'white',
bottom: '-30px',
left: '100%',
fontSize: '16px',
whiteSpace: 'nowrap',
})
this.cursor.appendChild(this.nameElement)
}
if (!name) {
this.nameElement.style.display = 'none'
} else {
this.nameElement.style.display = 'block'
const nameStr = name ? name.length > 10 ? name.slice(0, 9) + '...' : name : 'User'
this.nameElement.innerHTML = `<span>${nameStr}</span>`
}
}
move({ x, y }: Point) {
this.position.x = x;
this.position.y = y;
@ -41,4 +68,4 @@ export default class Cursor {
return { x: this.position.x, y: this.position.y };
}
}
}

View file

@ -1,7 +1,7 @@
.cursor {
display: block;
position: absolute;
width: 20px;
width: 13px;
height: 20px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M302.189 329.126H196.105l55.831 135.993c3.889 9.428-.555 19.999-9.444 23.999l-49.165 21.427c-9.165 4-19.443-.571-23.332-9.714l-53.053-129.136-86.664 89.138C18.729 472.71 0 463.554 0 447.977V18.299C0 1.899 19.921-6.096 30.277 5.443l284.412 292.542c11.472 11.179 3.007 31.141-12.5 31.141z"/></svg>');
background-repeat: no-repeat;
@ -12,7 +12,7 @@
}
/* ====== *
Source: https://github.com/codrops/ClickEffects/blob/master/css/component.css
Source: https://github.com/codrops/ClickEffects/blob/master/css/component.css
* ======= */
.cursor::after {
position: absolute;

View file

@ -263,7 +263,11 @@ export default class Player extends MessageDistributor {
this._setTime(getState().endTime);
this._startAnimation();
update({ livePlay: true });
}
}
toggleUserName(name?: string) {
this.cursor.toggleUserName(name)
}
clean() {
this.pause();

View file

@ -82,6 +82,7 @@ export const toggleAnnotation = initCheck((...args) => instance.assistManager.to
/** @type {Player.toggleTimetravel} */
export const toggleTimetravel = initCheck((...args) => instance.toggleTimetravel(...args))
export const jumpToLive = initCheck((...args) => instance.jumpToLive(...args))
export const toggleUserName = initCheck((...args) => instance.toggleUserName(...args))
export const Controls = {
jump,

View file

@ -13,7 +13,7 @@ export default class AnnotationCanvas {
})
}
private resizeCanvas = () => {
private readonly resizeCanvas = () => {
this.canvas.width = window.innerWidth
this.canvas.height = window.innerHeight
}
@ -78,4 +78,4 @@ export default class AnnotationCanvas {
}
window.removeEventListener('resize', this.resizeCanvas)
}
}
}

View file

@ -156,6 +156,7 @@ export default class Assist {
this.emit('control_granted', id)
annot = new AnnotationCanvas()
annot.mount()
return callingAgents.get(id)
},
id => {
const cb = this.agents[id].onControlReleased
@ -211,7 +212,7 @@ export default class Assist {
})
socket.on('AGENT_DISCONNECTED', (id) => {
remoteControl.releaseControl(id)
remoteControl.releaseControl()
this.agents[id]?.onDisconnect?.()
delete this.agents[id]
@ -298,7 +299,9 @@ export default class Assist {
const handleCallEnd = () => { // Completle stop and clear all calls
// Streams
Object.values(calls).forEach(call => call.close())
Object.keys(calls).forEach(peerId => delete calls[peerId])
Object.keys(calls).forEach(peerId => {
delete calls[peerId]
})
Object.values(lStreams).forEach((stream) => { stream.stop() })
Object.keys(lStreams).forEach((peerId: string) => { delete lStreams[peerId] })
@ -312,6 +315,9 @@ export default class Assist {
this.emit('UPDATE_SESSION', { agentIds: [], isCallActive: false, })
this.setCallingState(CallingState.False)
sessionStorage.removeItem(this.options.session_calling_peer_key)
remoteControl.releaseControl()
callEndCallback?.()
}
const initiateCallEnd = () => {

View file

@ -4,16 +4,33 @@ type XY = [number, number]
export default class Mouse {
private readonly mouse: HTMLDivElement
private position: [number,number] = [0,0,]
constructor() {
constructor(private readonly agentName?: string) {
this.mouse = document.createElement('div')
const agentBubble = document.createElement('div')
const svg ='<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="32" height="32" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 28 28" enable-background="new 0 0 28 28" xml:space="preserve"><polygon fill="#FFFFFF" points="8.2,20.9 8.2,4.9 19.8,16.5 13,16.5 12.6,16.6 "/><polygon fill="#FFFFFF" points="17.3,21.6 13.7,23.1 9,12 12.7,10.5 "/><rect x="12.5" y="13.6" transform="matrix(0.9221 -0.3871 0.3871 0.9221 -5.7605 6.5909)" width="2" height="8"/><polygon points="9.2,7.3 9.2,18.5 12.2,15.6 12.6,15.5 17.4,15.5 "/></svg>'
this.mouse.innerHTML = svg
Object.assign(agentBubble.style, {
position: 'absolute',
padding: '4px 6px',
borderRadius: '8px',
backgroundColor: 'green',
color: 'white',
bottom: '-18px',
left: '50%',
fontSize: '16px',
whiteSpace: 'nowrap',
})
const agentNameStr = this.agentName ? this.agentName.length > 10 ? this.agentName.slice(0, 9) + '...' : this.agentName : 'Agent'
agentBubble.innerHTML = `<span>${agentNameStr}</span>`
this.mouse.appendChild(agentBubble)
Object.assign(this.mouse.style, {
width: '20px',
height: '20px',
opacity: '.4',
borderRadius: '50%',
position: 'absolute',
zIndex: '999998',
background: 'radial-gradient(red, transparent)',
})
}
@ -66,7 +83,7 @@ export default class Mouse {
let el = this.lastScrEl
// Scroll the same one
// Scroll the same one
if (el instanceof Element) {
el.scrollLeft += dX
el.scrollTop += dY
@ -78,12 +95,12 @@ export default class Mouse {
}
el = document.elementFromPoint(
mouseX-this.pScrEl.scrollLeft,
mouseX-this.pScrEl.scrollLeft,
mouseY-this.pScrEl.scrollTop,
)
while (el) {
// el.scrollTopMax > 0 // available in firefox
if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) {
if (el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth) {
const styles = getComputedStyle(el)
if (styles.overflow.indexOf('scroll') >= 0 || styles.overflow.indexOf('auto') >= 0) { // returns true for body in habr.com but it's not scrollable
const esl = el.scrollLeft

View file

@ -24,8 +24,8 @@ export default class RemoteControl {
constructor(
private readonly options: AssistOptions,
private readonly onGrand: (sting?) => void,
private readonly onRelease: (sting?) => void) {}
private readonly onGrand: (string?) => string | undefined,
private readonly onRelease: (string?) => void) {}
reconnect(ids: string[]) {
const storedID = sessionStorage.getItem(this.options.session_control_peer_key)
@ -39,12 +39,12 @@ export default class RemoteControl {
private confirm: ConfirmWindow | null = null
requestControl = (id: string) => {
if (this.agentID !== null) {
this.releaseControl(id)
this.releaseControl()
return
}
setTimeout(() =>{
if (this.status === RCStatus.Requesting) {
this.releaseControl(id)
this.releaseControl()
}
}, 30000)
this.agentID = id
@ -54,7 +54,7 @@ export default class RemoteControl {
if (allowed) {
this.grantControl(id)
} else {
this.releaseControl(id)
this.releaseControl()
}
})
.then(() => {
@ -65,23 +65,24 @@ export default class RemoteControl {
console.error(e)
})
}
grantControl = (id: string) => {
this.agentID = id
this.status = RCStatus.Enabled
this.mouse = new Mouse()
this.mouse.mount()
sessionStorage.setItem(this.options.session_control_peer_key, id)
this.onGrand(id)
const agentName = this.onGrand(id)
this.mouse = new Mouse(agentName)
this.mouse.mount()
}
releaseControl = (id: string) => {
if (this.agentID !== id) { return }
releaseControl = () => {
if (!this.agentID) { return }
this.mouse?.remove()
this.mouse = null
this.status = RCStatus.Disabled
this.agentID = null
sessionStorage.removeItem(this.options.session_control_peer_key)
this.onRelease(id)
this.onRelease(this.agentID)
this.agentID = null
}
scroll = (id, d) => { id === this.agentID && this.mouse?.scroll(d) }