@calloop/agent-react 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +130 -3
- package/package.json +42 -6
- package/src/index.d.ts +81 -0
- package/src/index.js +251 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PCGLOBALCO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,130 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @calloop/agent-react
|
|
2
|
+
|
|
3
|
+
React bindings for [`@calloop/agent-sdk`](https://docs.calloop.net/sdks/js): a provider
|
|
4
|
+
that connects once, and hooks that re-render when calls, availability, queues
|
|
5
|
+
or the supervisor board change. The SDK's objects and actions are used as they
|
|
6
|
+
are — this package only wires them into React. React 18+.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @calloop/agent-react @calloop/agent-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```jsx
|
|
13
|
+
import { CalloopProvider, useAgent, useIncomingCalls, useActiveCall, useCall, RemoteAudio } from '@calloop/agent-react';
|
|
14
|
+
|
|
15
|
+
export default function App() {
|
|
16
|
+
return (
|
|
17
|
+
<CalloopProvider url="https://api.calloop.net" getToken={() => fetch('/api/calloop-token').then((r) => r.text())}>
|
|
18
|
+
<AgentScreen />
|
|
19
|
+
</CalloopProvider>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function AgentScreen() {
|
|
24
|
+
const { me, availability, setAvailability } = useAgent();
|
|
25
|
+
const ringing = useIncomingCalls();
|
|
26
|
+
const active = useActiveCall();
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<p>{me?.name} — {availability}</p>
|
|
30
|
+
<button onClick={() => setAvailability('AVAILABLE')}>Available</button>
|
|
31
|
+
{ringing.map((call) => <Ringing key={call.id} call={call} />)}
|
|
32
|
+
{active && <OnCall call={active} />}
|
|
33
|
+
</>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Ringing({ call }) {
|
|
38
|
+
const { customer, accept, decline } = useCall(call);
|
|
39
|
+
return <div>{customer.address} <button onClick={() => accept()}>Answer</button> <button onClick={decline}>Decline</button></div>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function OnCall({ call }) {
|
|
43
|
+
const { state, customer, remoteStream, muted, mute, hangup, transfer } = useCall(call);
|
|
44
|
+
return (
|
|
45
|
+
<div>
|
|
46
|
+
{customer.address} — {state}
|
|
47
|
+
<RemoteAudio stream={remoteStream} />
|
|
48
|
+
<button onClick={() => mute(!muted)}>{muted ? 'Unmute' : 'Mute'}</button>
|
|
49
|
+
<button onClick={() => transfer({ queueId: 3 })}>Transfer</button>
|
|
50
|
+
<button onClick={hangup}>Hang up</button>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Security
|
|
57
|
+
|
|
58
|
+
The rules of the [JS SDK](https://docs.calloop.net/sdks/js#security) apply: only
|
|
59
|
+
short-lived agent tokens from your backend in the browser (never an API key),
|
|
60
|
+
`getToken` for a fresh one on every connect, `role` on every token, https only.
|
|
61
|
+
|
|
62
|
+
## Provider
|
|
63
|
+
|
|
64
|
+
`<CalloopProvider>` takes `connect()`'s options (`url`, `token` | `getToken`,
|
|
65
|
+
`deviceId`, `webrtc`, `getUserMedia`, `purpose`, `io`) or `agent` — an already
|
|
66
|
+
connected `CalloopAgent`. `connect` replaces the SDK's `connect()` (e.g. in
|
|
67
|
+
tests). The latest `token` / `getToken` is used on every (re)connect,
|
|
68
|
+
so passing a fresh token doesn't reconnect; only another `url` or `deviceId`
|
|
69
|
+
does. A connection the provider made closes on unmount; an `agent` passed in
|
|
70
|
+
is left open — close it yourself.
|
|
71
|
+
|
|
72
|
+
## Hooks
|
|
73
|
+
|
|
74
|
+
| Hook | Returns |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `useCalloop()` | `{ agent, status, error }` — `status`: `connecting` · `ready` · `reconnecting` · `closed` · `error`. `closed`: Calloop ended the connection and won't take it back (e.g. the token's signing key was revoked; `error` says why) — remount the provider (e.g. change its `key`) to connect again |
|
|
77
|
+
| `useAgent()` | `{ agent, me, tenant, availability, isSupervisor, setAvailability(value, { agentId? }) }` — `agent` is the SDK's `CalloopAgent` |
|
|
78
|
+
| `useCalls()` | this agent's calls (SDK `Call` objects) |
|
|
79
|
+
| `useIncomingCalls()` | the ringing ones |
|
|
80
|
+
| `useActiveCall()` | the call being set up / on / dialing, or `null` |
|
|
81
|
+
| `useCall(call)` | `{ call, state, data, customer, remoteStream, muted, lastDigit, endReason, accept, decline, hangup, mute, transfer, setPrivate, switchHere }` — `lastDigit`: the customer's last key press |
|
|
82
|
+
| `useQueues()` | queue snapshots (supervisors, and agents whose tenant allows the team view; otherwise empty) |
|
|
83
|
+
| `useTeam()` | the tenant's agents' availability (likewise; otherwise only this agent) |
|
|
84
|
+
| `useBoard()` | supervisors: the tenant's live calls |
|
|
85
|
+
| `useMonitor(callId)` | supervisors: `{ monitor, state, mode, stream, error, start(), stop(), setMode(), mute() }` — `state`: `idle` · `connecting` · `active`, back to `idle` when monitoring ends; `stream` is the call as they hear it; stops on unmount. `error`: `start()` rejected, Calloop refused or failed the monitor (it then ended; `error.code` is Calloop's), or refused a `setMode()` (monitoring goes on in the current mode; cleared by the next mode change) |
|
|
86
|
+
|
|
87
|
+
`<RemoteAudio stream />` plays a stream (a call's `remoteStream`, a monitor's
|
|
88
|
+
`stream`); other props go to the `<audio>` element.
|
|
89
|
+
|
|
90
|
+
## Supervisor screen
|
|
91
|
+
|
|
92
|
+
```jsx
|
|
93
|
+
import { useEffect, useState } from 'react';
|
|
94
|
+
import { useBoard, useMonitor, RemoteAudio } from '@calloop/agent-react';
|
|
95
|
+
|
|
96
|
+
function Board() {
|
|
97
|
+
const calls = useBoard();
|
|
98
|
+
const [watching, setWatching] = useState(null);
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
{calls.map((c) => (
|
|
102
|
+
<div key={c.callId}>
|
|
103
|
+
{c.customer?.address} · {c.status} · {c.agentName ?? 'waiting'} {c.ivr && `· IVR ${c.ivr.nodeType}`}
|
|
104
|
+
<button onClick={() => setWatching(c.callId)}>Listen</button>
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
{watching && <Listening callId={watching} />}
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function Listening({ callId }) {
|
|
113
|
+
const { state, mode, stream, start, setMode, stop } = useMonitor(callId);
|
|
114
|
+
useEffect(() => { start(); }, [start]);
|
|
115
|
+
return (
|
|
116
|
+
<div>
|
|
117
|
+
{state} · {mode}
|
|
118
|
+
<RemoteAudio stream={stream} />
|
|
119
|
+
<button onClick={() => setMode('whisper')}>Whisper</button>
|
|
120
|
+
<button onClick={() => setMode('barge')}>Barge</button>
|
|
121
|
+
<button onClick={stop}>Stop</button>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## No build step
|
|
128
|
+
|
|
129
|
+
The package is plain ES modules (`React.createElement`, no JSX) with
|
|
130
|
+
TypeScript types, so any bundler takes it as is.
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@calloop/agent-react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@calloop/agent-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React bindings for @calloop/agent-sdk: a provider and hooks for agent and supervisor screens.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"calloop",
|
|
7
|
+
"react",
|
|
8
|
+
"hooks",
|
|
9
|
+
"call-center",
|
|
10
|
+
"webrtc",
|
|
11
|
+
"voip"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://docs.calloop.net/sdks/react",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/PCGlobalCo/calloop-sdks.git",
|
|
17
|
+
"directory": "agent-react"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/PCGlobalCo/calloop-sdks/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "src/index.js",
|
|
24
|
+
"types": "src/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./src/index.d.ts",
|
|
28
|
+
"default": "./src/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"src"
|
|
33
|
+
],
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@calloop/agent-sdk": "^0.2.0",
|
|
36
|
+
"react": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Types for @calloop/agent-react.
|
|
2
|
+
import type { ReactNode, ReactElement, AudioHTMLAttributes } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
CalloopAgent, Call, Monitor, ConnectOptions, Session, Availability, CallStateName, CallData, Customer,
|
|
5
|
+
QueueSnapshot, TeamMember, BoardCall, MonitorMode,
|
|
6
|
+
} from '@calloop/agent-sdk';
|
|
7
|
+
|
|
8
|
+
/** 'closed': Calloop ended the connection and won't reconnect it (e.g. a revoked signing key); error says why. */
|
|
9
|
+
export type ConnectionStatus = 'connecting' | 'ready' | 'reconnecting' | 'closed' | 'error';
|
|
10
|
+
|
|
11
|
+
export type CalloopProviderProps = Partial<ConnectOptions> & {
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
/** An already connected agent, instead of connecting here. */
|
|
14
|
+
agent?: CalloopAgent;
|
|
15
|
+
/** Replaces the SDK's connect (tests). */
|
|
16
|
+
connect?: (opts: ConnectOptions) => Promise<CalloopAgent>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Connects once and shares the agent with every hook below it. */
|
|
20
|
+
export function CalloopProvider(props: CalloopProviderProps): ReactElement;
|
|
21
|
+
|
|
22
|
+
export function useCalloop(): { agent: CalloopAgent | null; status: ConnectionStatus; error: Error | null };
|
|
23
|
+
|
|
24
|
+
export function useAgent(): {
|
|
25
|
+
agent: CalloopAgent | null;
|
|
26
|
+
me: Session['agent'] | null;
|
|
27
|
+
tenant: Session['tenant'] | null;
|
|
28
|
+
availability: Availability | null;
|
|
29
|
+
isSupervisor: boolean;
|
|
30
|
+
setAvailability(value: 'AVAILABLE' | 'OFFLINE', opts?: { agentId?: number | string }): void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** This agent's calls. */
|
|
34
|
+
export function useCalls(): Call[];
|
|
35
|
+
/** Calls ringing for this agent. */
|
|
36
|
+
export function useIncomingCalls(): Call[];
|
|
37
|
+
/** The call this agent is on (connecting, active or dialing). */
|
|
38
|
+
export function useActiveCall(): Call | null;
|
|
39
|
+
|
|
40
|
+
export function useCall(call: Call | null | undefined): {
|
|
41
|
+
call: Call | null | undefined;
|
|
42
|
+
state: CallStateName | null;
|
|
43
|
+
data: CallData | null;
|
|
44
|
+
customer: Customer | null;
|
|
45
|
+
remoteStream: MediaStream | null;
|
|
46
|
+
endReason: string | null;
|
|
47
|
+
muted: boolean;
|
|
48
|
+
/** The last key the customer pressed during the call. */
|
|
49
|
+
lastDigit: string | null;
|
|
50
|
+
accept(opts?: { stream?: MediaStream }): Promise<void> | undefined;
|
|
51
|
+
decline(): void;
|
|
52
|
+
hangup(): void;
|
|
53
|
+
mute(value?: boolean): void;
|
|
54
|
+
transfer(target: { agentId: number | string } | { queueId: number | string }): void;
|
|
55
|
+
setPrivate(active: boolean): void;
|
|
56
|
+
switchHere(opts?: { stream?: MediaStream }): Promise<void> | undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function useQueues(): QueueSnapshot[];
|
|
60
|
+
export function useTeam(): TeamMember[];
|
|
61
|
+
|
|
62
|
+
/** Supervisors: the tenant's live calls. */
|
|
63
|
+
export function useBoard(): BoardCall[];
|
|
64
|
+
|
|
65
|
+
/** Supervisors: monitoring one call. Stops on unmount or when callId changes. */
|
|
66
|
+
export function useMonitor(callId: number | string | null | undefined): {
|
|
67
|
+
monitor: Monitor | null;
|
|
68
|
+
state: 'idle' | 'connecting' | 'active';
|
|
69
|
+
mode: MonitorMode | null;
|
|
70
|
+
/** The call as the supervisor hears it: the customer and the agent, mixed. */
|
|
71
|
+
stream: MediaStream | null;
|
|
72
|
+
/** start() rejected, the monitor failed or was refused, or a setMode() was refused (code: Calloop's error code). */
|
|
73
|
+
error: (Error & { code?: string }) | null;
|
|
74
|
+
start(opts?: { stream?: MediaStream }): Promise<Monitor | null>;
|
|
75
|
+
stop(): void;
|
|
76
|
+
setMode(mode: MonitorMode): void;
|
|
77
|
+
mute(value?: boolean): void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Plays a MediaStream in an <audio> element. */
|
|
81
|
+
export function RemoteAudio(props: { stream: MediaStream | null | undefined; muted?: boolean } & AudioHTMLAttributes<HTMLAudioElement>): ReactElement;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// @calloop/agent-react — React bindings for @calloop/agent-sdk.
|
|
2
|
+
//
|
|
3
|
+
// <CalloopProvider url=… getToken={…}>
|
|
4
|
+
// <App />
|
|
5
|
+
// </CalloopProvider>
|
|
6
|
+
//
|
|
7
|
+
// Hooks re-render when the SDK's objects change; the objects themselves (the
|
|
8
|
+
// agent, calls, monitors) are the SDK's, and their actions are used as they are.
|
|
9
|
+
// No JSX, no build step: plain ES modules.
|
|
10
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react';
|
|
11
|
+
import { connect as sdkConnect } from '@calloop/agent-sdk';
|
|
12
|
+
|
|
13
|
+
const CalloopContext = createContext(null);
|
|
14
|
+
|
|
15
|
+
// Re-render when any of `events` fires on `emitter` (null emitter: never).
|
|
16
|
+
function useEmitter(emitter, events) {
|
|
17
|
+
const [, bump] = useReducer((n) => n + 1, 0);
|
|
18
|
+
const key = events.join('|');
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!emitter) return undefined;
|
|
21
|
+
const offs = events.map((e) => emitter.on(e, bump));
|
|
22
|
+
// Whatever changed between the render and subscribing would be missed: re-read once.
|
|
23
|
+
bump();
|
|
24
|
+
return () => offs.forEach((off) => off());
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
26
|
+
}, [emitter, key]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Connects once and shares the agent with every hook below it.
|
|
31
|
+
* Props: url, token | getToken, deviceId?, webrtc?, getUserMedia? (as connect()),
|
|
32
|
+
* or agent — an already connected CalloopAgent. connect: a replacement for the
|
|
33
|
+
* SDK's connect (tests).
|
|
34
|
+
*/
|
|
35
|
+
export function CalloopProvider({ children, agent: given, connect = sdkConnect, ...options }) {
|
|
36
|
+
const [state, setState] = useState(() => ({ agent: given ?? null, status: given ? 'ready' : 'connecting', error: null }));
|
|
37
|
+
const optionsRef = useRef(options);
|
|
38
|
+
optionsRef.current = options;
|
|
39
|
+
// A new token is not a new connection: the latest token / getToken is read
|
|
40
|
+
// on every (re)connect. Only another url or device reconnects.
|
|
41
|
+
const identity = `${options.url}|${options.deviceId ?? ''}`;
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (given) { setState({ agent: given, status: 'ready', error: null }); return undefined; }
|
|
45
|
+
let closed = false;
|
|
46
|
+
let agent = null;
|
|
47
|
+
setState({ agent: null, status: 'connecting', error: null });
|
|
48
|
+
const latestToken = () => {
|
|
49
|
+
const o = optionsRef.current;
|
|
50
|
+
return o.getToken ? o.getToken() : o.token;
|
|
51
|
+
};
|
|
52
|
+
connect({ ...optionsRef.current, token: undefined, getToken: latestToken }).then((a) => {
|
|
53
|
+
agent = a;
|
|
54
|
+
if (closed) { a.close(); return; }
|
|
55
|
+
setState({ agent: a, status: 'ready', error: null });
|
|
56
|
+
}, (error) => {
|
|
57
|
+
if (!closed) setState({ agent: null, status: 'error', error });
|
|
58
|
+
});
|
|
59
|
+
return () => { closed = true; agent?.close(); };
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
61
|
+
}, [given, identity]);
|
|
62
|
+
|
|
63
|
+
// Connection status after the first connect.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const agent = state.agent;
|
|
66
|
+
if (!agent) return undefined;
|
|
67
|
+
const offs = [
|
|
68
|
+
agent.on('disconnected', () => setState((s) => ({ ...s, status: 'reconnecting' }))),
|
|
69
|
+
agent.on('ready', () => setState((s) => ({ ...s, status: 'ready', error: null }))),
|
|
70
|
+
// Calloop ended the connection for good (e.g. its signing key was
|
|
71
|
+
// revoked): no reconnect will come. A remount (or a new key) connects again.
|
|
72
|
+
agent.on('closed', ({ reason }) => setState((s) => ({ ...s, status: 'closed', error: new Error(`Calloop closed the connection (${reason})`) }))),
|
|
73
|
+
];
|
|
74
|
+
if (agent.closedReason) setState((s) => ({ ...s, status: 'closed', error: new Error(`Calloop closed the connection (${agent.closedReason})`) }));
|
|
75
|
+
return () => offs.forEach((off) => off());
|
|
76
|
+
}, [state.agent]);
|
|
77
|
+
|
|
78
|
+
return createElement(CalloopContext.Provider, { value: state }, children);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* { agent, status: 'connecting' | 'ready' | 'reconnecting' | 'closed' | 'error', error }.
|
|
83
|
+
* 'closed': Calloop ended the connection and won't take it back (error says why).
|
|
84
|
+
*/
|
|
85
|
+
export function useCalloop() {
|
|
86
|
+
const ctx = useContext(CalloopContext);
|
|
87
|
+
if (!ctx) throw new Error('useCalloop must be used inside <CalloopProvider>');
|
|
88
|
+
return ctx;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** This agent: identity, availability, role, and setAvailability. */
|
|
92
|
+
export function useAgent() {
|
|
93
|
+
const { agent } = useCalloop();
|
|
94
|
+
useEmitter(agent, ['ready', 'availability', 'sessionRefreshed']);
|
|
95
|
+
const setAvailability = useCallback((value, opts) => agent?.setAvailability(value, opts), [agent]);
|
|
96
|
+
return {
|
|
97
|
+
agent,
|
|
98
|
+
me: agent?.agent ?? null,
|
|
99
|
+
tenant: agent?.session?.tenant ?? null,
|
|
100
|
+
availability: agent?.availability ?? null,
|
|
101
|
+
isSupervisor: Boolean(agent?.isSupervisor),
|
|
102
|
+
setAvailability,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CALL_LIST_EVENTS = ['incoming', 'elsewhere', 'callState', 'callEnded', 'ready'];
|
|
107
|
+
|
|
108
|
+
/** This agent's calls (the SDK's Call objects). */
|
|
109
|
+
export function useCalls() {
|
|
110
|
+
const { agent } = useCalloop();
|
|
111
|
+
useEmitter(agent, CALL_LIST_EVENTS);
|
|
112
|
+
return agent ? [...agent.calls.values()] : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Calls ringing for this agent. */
|
|
116
|
+
export function useIncomingCalls() {
|
|
117
|
+
return useCalls().filter((c) => c.state === 'ringing');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The call this agent is on (connecting, active or dialing), or null. */
|
|
121
|
+
export function useActiveCall() {
|
|
122
|
+
return useCalls().find((c) => ['connecting', 'active', 'dialing'].includes(c.state)) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const CALL_EVENTS = ['state', 'updated', 'remoteStream', 'ended', 'dtmf', 'customerMedia', 'supervisorMode', 'privateChanged'];
|
|
126
|
+
|
|
127
|
+
/** One call, kept current: its state, data, streams, and its actions. */
|
|
128
|
+
export function useCall(call) {
|
|
129
|
+
useEmitter(call, CALL_EVENTS);
|
|
130
|
+
const [muted, setMuted] = useState(call?.muted ?? false);
|
|
131
|
+
const [lastDigit, setLastDigit] = useState(null);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!call) return undefined;
|
|
134
|
+
setMuted(call.muted);
|
|
135
|
+
return call.on('dtmf', ({ digit }) => setLastDigit(digit));
|
|
136
|
+
}, [call]);
|
|
137
|
+
|
|
138
|
+
// Built on every render: the SDK updates call.data in place.
|
|
139
|
+
return {
|
|
140
|
+
call,
|
|
141
|
+
state: call?.state ?? null,
|
|
142
|
+
data: call?.data ?? null,
|
|
143
|
+
customer: call?.customer ?? null,
|
|
144
|
+
remoteStream: call?.remoteStream ?? null,
|
|
145
|
+
endReason: call?.endReason ?? null,
|
|
146
|
+
muted,
|
|
147
|
+
lastDigit,
|
|
148
|
+
accept: (opts) => call?.accept(opts),
|
|
149
|
+
decline: () => call?.decline(),
|
|
150
|
+
hangup: () => call?.hangup(),
|
|
151
|
+
mute: (value = true) => { call?.mute(value); setMuted(Boolean(value)); },
|
|
152
|
+
transfer: (target) => call?.transfer(target),
|
|
153
|
+
setPrivate: (active) => call?.setPrivate(active),
|
|
154
|
+
switchHere: (opts) => call?.switchHere(opts),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Queue snapshots. */
|
|
159
|
+
export function useQueues() {
|
|
160
|
+
const { agent } = useCalloop();
|
|
161
|
+
useEmitter(agent, ['queue', 'ready']);
|
|
162
|
+
return agent ? [...agent.queues.values()] : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The tenant's agents' availability, as reported since connecting. */
|
|
166
|
+
export function useTeam() {
|
|
167
|
+
const { agent } = useCalloop();
|
|
168
|
+
useEmitter(agent, ['team', 'ready']);
|
|
169
|
+
return agent ? [...agent.team.values()] : [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Supervisors: the tenant's live calls. */
|
|
173
|
+
export function useBoard() {
|
|
174
|
+
const { agent } = useCalloop();
|
|
175
|
+
useEmitter(agent, ['board', 'boardCall', 'boardCallEnded', 'ready']);
|
|
176
|
+
return agent ? [...agent.board.values()] : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const MONITOR_EVENTS = ['state', 'mode', 'stream', 'ended'];
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Supervisors: monitoring one call. start() asks for the microphone and
|
|
183
|
+
* listens; setMode('whisper' | 'barge'); stop(). Stops when the component
|
|
184
|
+
* unmounts or callId changes.
|
|
185
|
+
*/
|
|
186
|
+
export function useMonitor(callId) {
|
|
187
|
+
const { agent } = useCalloop();
|
|
188
|
+
const [monitor, setMonitor] = useState(null);
|
|
189
|
+
const [error, setError] = useState(null);
|
|
190
|
+
const current = useRef(null);
|
|
191
|
+
useEmitter(monitor, MONITOR_EVENTS);
|
|
192
|
+
// Refused or failed after start() resolved: Calloop's answer comes later.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!monitor) return undefined;
|
|
195
|
+
const failed = ({ code, message }) => setError(Object.assign(new Error(message ?? 'Monitoring failed'), { code }));
|
|
196
|
+
const offs = [
|
|
197
|
+
monitor.on('ended', ({ reason, code, message }) => {
|
|
198
|
+
if (reason === 'failed' || reason === 'not_allowed') failed({ code, message: message ?? `Monitoring ${reason}` });
|
|
199
|
+
}),
|
|
200
|
+
monitor.on('modeRefused', failed),
|
|
201
|
+
monitor.on('mode', () => setError(null)),
|
|
202
|
+
];
|
|
203
|
+
return () => offs.forEach((off) => off());
|
|
204
|
+
}, [monitor]);
|
|
205
|
+
|
|
206
|
+
const start = useCallback(async (opts) => {
|
|
207
|
+
if (!agent || callId == null) return null;
|
|
208
|
+
setError(null);
|
|
209
|
+
try {
|
|
210
|
+
const m = await agent.monitor(callId, opts);
|
|
211
|
+
current.current = m;
|
|
212
|
+
setMonitor(m);
|
|
213
|
+
return m;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
setError(err);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}, [agent, callId]);
|
|
219
|
+
|
|
220
|
+
// Leaving this call (another callId, or unmount) stops its monitor — only
|
|
221
|
+
// its own: this cleanup can run after a monitor for the next call started.
|
|
222
|
+
useEffect(() => () => {
|
|
223
|
+
const m = current.current;
|
|
224
|
+
if (m && String(m.callId) === String(callId)) m.stop();
|
|
225
|
+
}, [callId]);
|
|
226
|
+
|
|
227
|
+
const active = monitor && monitor.state !== 'ended' && String(monitor.callId) === String(callId) ? monitor : null;
|
|
228
|
+
return {
|
|
229
|
+
monitor: active,
|
|
230
|
+
state: active?.state ?? 'idle',
|
|
231
|
+
mode: active?.mode ?? null,
|
|
232
|
+
stream: active?.stream ?? null,
|
|
233
|
+
error,
|
|
234
|
+
start,
|
|
235
|
+
stop: () => active?.stop(),
|
|
236
|
+
setMode: (mode) => active?.setMode(mode),
|
|
237
|
+
mute: (value = true) => active?.mute(value),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Plays a MediaStream (a call's remoteStream, a monitor's agent/customer stream). */
|
|
242
|
+
export function RemoteAudio({ stream, muted = false, ...props }) {
|
|
243
|
+
const ref = useRef(null);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
const el = ref.current;
|
|
246
|
+
if (!el) return;
|
|
247
|
+
if (el.srcObject !== stream) el.srcObject = stream ?? null;
|
|
248
|
+
if (stream) el.play?.().catch(() => { /* autoplay policies: a user gesture starts it */ });
|
|
249
|
+
}, [stream]);
|
|
250
|
+
return createElement('audio', { ref, autoPlay: true, playsInline: true, muted, ...props });
|
|
251
|
+
}
|