@coventia/softphone 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/dist/3x2-2HQTJDKZ.js +6583 -0
- package/dist/3x2-2HQTJDKZ.js.map +1 -0
- package/dist/3x2-INKWPNLA.js +6585 -0
- package/dist/3x2-INKWPNLA.js.map +1 -0
- package/dist/call-BTYlT2SU.d.cts +680 -0
- package/dist/call-BTYlT2SU.d.ts +680 -0
- package/dist/chunk-2ZSOGGPO.js +169 -0
- package/dist/chunk-2ZSOGGPO.js.map +1 -0
- package/dist/chunk-3V2L6UTK.js +129 -0
- package/dist/chunk-3V2L6UTK.js.map +1 -0
- package/dist/chunk-5BGEHMPP.js +945 -0
- package/dist/chunk-5BGEHMPP.js.map +1 -0
- package/dist/chunk-5WRI5ZAA.js +29 -0
- package/dist/chunk-5WRI5ZAA.js.map +1 -0
- package/dist/chunk-KAXEMJ7E.js +11 -0
- package/dist/chunk-KAXEMJ7E.js.map +1 -0
- package/dist/chunk-MHOJHG5E.js +39 -0
- package/dist/chunk-MHOJHG5E.js.map +1 -0
- package/dist/chunk-MP7CSA24.js +299 -0
- package/dist/chunk-MP7CSA24.js.map +1 -0
- package/dist/chunk-N2N2EQQJ.js +29 -0
- package/dist/chunk-N2N2EQQJ.js.map +1 -0
- package/dist/element/index.d.ts +853 -0
- package/dist/element/index.js +9635 -0
- package/dist/element/index.js.map +1 -0
- package/dist/engine-D9nbeY08.d.cts +115 -0
- package/dist/engine-Doa6Brkm.d.ts +115 -0
- package/dist/index.cjs +2133 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +630 -0
- package/dist/index.js.map +1 -0
- package/dist/min-64GIMWWL.js +5456 -0
- package/dist/min-64GIMWWL.js.map +1 -0
- package/dist/min-ATXYR23R.js +5458 -0
- package/dist/min-ATXYR23R.js.map +1 -0
- package/dist/react/index.cjs +138 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +68 -0
- package/dist/react/index.d.ts +68 -0
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -0
- package/dist/softphone-DHmhotbf.d.ts +128 -0
- package/dist/softphone-DhuhcKCy.d.cts +128 -0
- package/dist/softphone.css +1923 -0
- package/dist/sources-N4BOEDRL.js +226 -0
- package/dist/sources-N4BOEDRL.js.map +1 -0
- package/dist/testing/index.cjs +345 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +66 -0
- package/dist/testing/index.d.ts +66 -0
- package/dist/testing/index.js +186 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/ui/index.cjs +15428 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +188 -0
- package/dist/ui/index.d.ts +188 -0
- package/dist/ui/index.js +2256 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/zadarma/index.cjs +1144 -0
- package/dist/zadarma/index.cjs.map +1 -0
- package/dist/zadarma/index.d.cts +86 -0
- package/dist/zadarma/index.d.ts +86 -0
- package/dist/zadarma/index.js +5 -0
- package/dist/zadarma/index.js.map +1 -0
- package/dist/zadarma-engine-DN2SZSQ3.js +5 -0
- package/dist/zadarma-engine-DN2SZSQ3.js.map +1 -0
- package/package.json +123 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared public types.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here may reference Zadarma, WhatsApp, Supabase or React. If a concept from any of those
|
|
7
|
+
* appears in this file, a boundary has been breached — see SPEC §5.4 and §10.1.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* A callable contact.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately NOT `wa_id`. Today's softphone keys contacts on a WhatsApp ID all the way down into
|
|
13
|
+
* the dial function (SPEC §4); a telephony SDK cannot ship that. The mapping from whatever the
|
|
14
|
+
* backend returns to this shape happens in the API client, at the boundary, and WhatsApp's data
|
|
15
|
+
* model stops there.
|
|
16
|
+
*/
|
|
17
|
+
interface Contact {
|
|
18
|
+
/**
|
|
19
|
+
* Opaque. Whatever the host's source produced — **the SDK never parses, formats, or reasons about
|
|
20
|
+
* it**, and neither should the UI; it is a React key and a thing to hand back to `onContactAction`.
|
|
21
|
+
*
|
|
22
|
+
* An earlier draft said "never a `wa_id`", which reality did not allow: the contacts collection has
|
|
23
|
+
* no other key (`_id` is projected away). What matters is that no WhatsApp SEMANTICS ride along —
|
|
24
|
+
* and they don't, because the backend serves callable contacts only (`wa_id: /^[0-9]+$/`), so the
|
|
25
|
+
* value there is literally the phone digits and tells you nothing `phone` doesn't. The boundary
|
|
26
|
+
* removes the SHAPE, not the digits. See `coventia/contacts.ts`.
|
|
27
|
+
*/
|
|
28
|
+
readonly id: string;
|
|
29
|
+
/** Display name, or null when only the number is known. */
|
|
30
|
+
readonly name: string | null;
|
|
31
|
+
/** E.164 where possible. */
|
|
32
|
+
readonly phone: string;
|
|
33
|
+
}
|
|
34
|
+
/** Who is on the other end. */
|
|
35
|
+
interface RemoteParty {
|
|
36
|
+
/** The number as the provider gave it to us. */
|
|
37
|
+
readonly number: string;
|
|
38
|
+
/**
|
|
39
|
+
* Best available name. Falls back to `number`.
|
|
40
|
+
*
|
|
41
|
+
* The provider's own SIP display name is the *line label*, not the caller — so we resolve the
|
|
42
|
+
* name from the host's contacts via `resolveContact` and prefer that.
|
|
43
|
+
*/
|
|
44
|
+
readonly displayName: string;
|
|
45
|
+
/** Populated asynchronously by `resolveContact`; null when unknown or not configured. */
|
|
46
|
+
readonly contact: Contact | null;
|
|
47
|
+
}
|
|
48
|
+
type CallDirection = 'inbound' | 'outbound';
|
|
49
|
+
interface CallOptions {
|
|
50
|
+
/** Extra SIP headers. Provider-specific; most consumers never need this. */
|
|
51
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Softphone lifecycle state.
|
|
55
|
+
*
|
|
56
|
+
* `online` means REACHABLE — the provider's presence channel is up. It does NOT mean SIP-registered.
|
|
57
|
+
* Zadarma registers on-demand, per call, in response to a push (SPEC §3.2); a persistent UA is
|
|
58
|
+
* ignored by its PBX. Leaking "registered" into the public API would force every future provider —
|
|
59
|
+
* including our own SIP infrastructure, which registers persistently — to fake it.
|
|
60
|
+
*/
|
|
61
|
+
type SoftphoneState = 'stopped' | 'starting' | 'online' | 'offline' | 'reconnecting' | 'destroyed';
|
|
62
|
+
interface ContactsPage {
|
|
63
|
+
readonly items: readonly Contact[];
|
|
64
|
+
/**
|
|
65
|
+
* The count across ALL pages for this `search` — not `items.length`. Drives the Contactos tab's
|
|
66
|
+
* scroll-to-load-more: it fetches the next page once `skip + items.length < total`. Too low (e.g.
|
|
67
|
+
* always returning `items.length`) silently strands the rest of the list; too high sends one
|
|
68
|
+
* wasted empty request per further scroll once the real data runs out.
|
|
69
|
+
*/
|
|
70
|
+
readonly total: number;
|
|
71
|
+
}
|
|
72
|
+
/** Load a page of contacts. `search` empty ⇒ the full (paged) list. */
|
|
73
|
+
type ContactsSource = (params: {
|
|
74
|
+
readonly search: string;
|
|
75
|
+
readonly skip: number;
|
|
76
|
+
readonly limit: number;
|
|
77
|
+
}) => Promise<ContactsPage>;
|
|
78
|
+
/**
|
|
79
|
+
* WHY a call went unanswered — the SDK's own vocabulary, never the PBX's raw disposition string
|
|
80
|
+
* ("no answer", "busy", "cancel", "congestion", …). `unavailable` is the open-ended catch-all: an
|
|
81
|
+
* unrecognised or future PBX value maps here rather than being dropped, the same reasoning
|
|
82
|
+
* `missed` itself already uses ("any disposition other than 'answered' counts").
|
|
83
|
+
*/
|
|
84
|
+
type CallOutcome = 'no-answer' | 'busy' | 'cancelled' | 'failed' | 'unavailable';
|
|
85
|
+
/** One hop that handled a call — the primary answerer, or one leg of a transfer, in order. */
|
|
86
|
+
interface CallHandler {
|
|
87
|
+
readonly extension: string;
|
|
88
|
+
/** `null` when the extension could not be resolved to an org member. */
|
|
89
|
+
readonly userId: string | null;
|
|
90
|
+
/** Resolved only via `CallHistoryEntry.loadDetail` — always `null` on the plain list entry. */
|
|
91
|
+
readonly name: string | null;
|
|
92
|
+
}
|
|
93
|
+
/** The call detail page's one enrichment fetch — see `CallHistoryEntry.loadDetail`. */
|
|
94
|
+
interface CallDetail {
|
|
95
|
+
readonly recordingUrls: readonly string[];
|
|
96
|
+
/** The same chain `CallHistoryEntry.handledBy` has, with `name` resolved for each hop. */
|
|
97
|
+
readonly handledBy: readonly CallHandler[];
|
|
98
|
+
}
|
|
99
|
+
interface CallHistoryEntry {
|
|
100
|
+
readonly id: string;
|
|
101
|
+
readonly direction: CallDirection;
|
|
102
|
+
/** E.164 where known. */
|
|
103
|
+
readonly number: string;
|
|
104
|
+
readonly name: string | null;
|
|
105
|
+
readonly at: Date;
|
|
106
|
+
readonly durationSec: number;
|
|
107
|
+
readonly missed: boolean;
|
|
108
|
+
/** Present only when `missed` is true — why. Absent for an answered call, not `undefined` noise. */
|
|
109
|
+
readonly outcome?: CallOutcome;
|
|
110
|
+
/** The org's own number that was dialled — present only (and only meaningful) for an inbound call. */
|
|
111
|
+
readonly did?: string;
|
|
112
|
+
/** True when Coventia has at least one recording for this call. */
|
|
113
|
+
readonly recorded: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* Who handled the call, in order — extension only, before any name is resolved. Empty when nobody
|
|
116
|
+
* did (a missed inbound call never got a chain) or the org has no chain recorded for it at all.
|
|
117
|
+
*/
|
|
118
|
+
readonly handledBy: readonly CallHandler[];
|
|
119
|
+
/**
|
|
120
|
+
* The detail page's one enrichment fetch: signed recording URLs, and the SAME `handledBy` chain
|
|
121
|
+
* with names resolved. Absent when there is nothing to enrich (`!recorded && handledBy.length ===
|
|
122
|
+
* 0`) — lazy for the same reason a single recording URL used to be: minting N signed URLs and
|
|
123
|
+
* resolving N sets of names for a page of 30 calls nobody opens is pure waste. Coventia only builds
|
|
124
|
+
* this from `/phone/calls/:id`, one call at a time; that is not an accident of our backend.
|
|
125
|
+
*/
|
|
126
|
+
readonly loadDetail?: () => Promise<CallDetail>;
|
|
127
|
+
}
|
|
128
|
+
type HistorySource = (params: {
|
|
129
|
+
readonly skip: number;
|
|
130
|
+
readonly limit: number;
|
|
131
|
+
/** Only calls that went unanswered (any disposition other than "answered") — the Historial tab's "Perdidas" filter. */
|
|
132
|
+
readonly missed?: boolean;
|
|
133
|
+
/** Only calls this person handled — the primary handler OR any hop in a transfer chain. From `PhoneAgent.userId`. */
|
|
134
|
+
readonly userId?: string;
|
|
135
|
+
}) => Promise<readonly CallHistoryEntry[]>;
|
|
136
|
+
/** One member of the org with a phone extension — an option in the Historial tab's "filter by person". */
|
|
137
|
+
interface PhoneAgent {
|
|
138
|
+
readonly userId: string;
|
|
139
|
+
readonly name: string | null;
|
|
140
|
+
}
|
|
141
|
+
interface AgentsDirectory {
|
|
142
|
+
/** The caller themselves, if they have a phone extension — drives "Mis llamadas". `null` otherwise. */
|
|
143
|
+
readonly me: PhoneAgent | null;
|
|
144
|
+
readonly people: readonly PhoneAgent[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Contacts, history, caller ID and the agents directory, all from one config. */
|
|
148
|
+
interface BackendSources {
|
|
149
|
+
readonly contacts: ContactsSource;
|
|
150
|
+
readonly history: HistorySource;
|
|
151
|
+
readonly resolveContact: (number: string) => Promise<Contact | null>;
|
|
152
|
+
/** The Historial tab's "filter by person" directory. Always Coventia's own org membership. */
|
|
153
|
+
readonly listAgents: () => Promise<AgentsDirectory>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The error taxonomy.
|
|
158
|
+
*
|
|
159
|
+
* Two things make this worth more than `onError(Error)`:
|
|
160
|
+
*
|
|
161
|
+
* 1. **Fatal vs non-fatal, as a discriminated union.** A fatal error means the call (or the engine)
|
|
162
|
+
* is over and the host must react. A non-fatal one is a warning the host may surface or ignore.
|
|
163
|
+
* Collapsing them into one channel — which is what almost every SDK does — forces every
|
|
164
|
+
* integrator to re-derive the distinction from string matching. Daily's split is the model.
|
|
165
|
+
*
|
|
166
|
+
* 2. **A coded catalogue with a `solution` per code.** Twilio and Telnyx both ship this and
|
|
167
|
+
* integrators love it, because the support conversation becomes "send me the code" instead of
|
|
168
|
+
* "send me your logs".
|
|
169
|
+
*
|
|
170
|
+
* The catalogue below is the SINGLE source of truth: `fatal` is derived from it at the type level,
|
|
171
|
+
* so a code cannot be fatal in the table and non-fatal in a type, and adding a code without deciding
|
|
172
|
+
* its severity is a compile error.
|
|
173
|
+
*/
|
|
174
|
+
declare const ERROR_CATALOGUE: {
|
|
175
|
+
readonly 'engine/duplicate-instance': {
|
|
176
|
+
readonly fatal: true;
|
|
177
|
+
readonly message: "A Softphone instance is already running on this page.";
|
|
178
|
+
readonly solution: "Construct one Softphone and share it. React StrictMode double-mounts in development — hold the instance above the tree, or in a ref, and call destroy() on unmount.";
|
|
179
|
+
};
|
|
180
|
+
readonly 'engine/not-started': {
|
|
181
|
+
readonly fatal: false;
|
|
182
|
+
readonly message: "The softphone has not been started.";
|
|
183
|
+
readonly solution: "Await softphone.start() before placing or answering a call.";
|
|
184
|
+
};
|
|
185
|
+
readonly 'engine/destroyed': {
|
|
186
|
+
readonly fatal: true;
|
|
187
|
+
readonly message: "This Softphone has been destroyed and cannot be reused.";
|
|
188
|
+
readonly solution: "Construct a new Softphone.";
|
|
189
|
+
};
|
|
190
|
+
readonly 'auth/token-fetch-failed': {
|
|
191
|
+
readonly fatal: true;
|
|
192
|
+
readonly message: "Could not obtain a voice token from the token endpoint.";
|
|
193
|
+
readonly solution: "Check that the token endpoint is reachable and that the headers callback returns a valid host-app session token.";
|
|
194
|
+
};
|
|
195
|
+
readonly 'auth/token-rejected': {
|
|
196
|
+
readonly fatal: true;
|
|
197
|
+
readonly message: "The voice token was rejected.";
|
|
198
|
+
readonly solution: "The token is expired, malformed, or scoped to a different organization. Mint a fresh one.";
|
|
199
|
+
};
|
|
200
|
+
readonly 'media/permission-denied': {
|
|
201
|
+
readonly fatal: true;
|
|
202
|
+
readonly message: "Microphone permission was denied.";
|
|
203
|
+
readonly solution: "The permission prompt names the TOP-LEVEL origin. Ask for it from a user gesture, and re-request via the browser UI if previously blocked.";
|
|
204
|
+
};
|
|
205
|
+
readonly 'media/no-input-device': {
|
|
206
|
+
readonly fatal: true;
|
|
207
|
+
readonly message: "No microphone is available.";
|
|
208
|
+
readonly solution: "Ask the user to connect a microphone, then retry.";
|
|
209
|
+
};
|
|
210
|
+
readonly 'media/webrtc-unsupported': {
|
|
211
|
+
readonly fatal: true;
|
|
212
|
+
readonly message: "This browser does not expose the WebRTC APIs.";
|
|
213
|
+
readonly solution: "Usually a privacy extension (uBlock Origin Extra, WebRTC Leak Prevent) disabling RTCPeerConnection — Twilio documents the same. Ask the user to disable it for this origin.";
|
|
214
|
+
};
|
|
215
|
+
readonly 'media/playback-blocked': {
|
|
216
|
+
readonly fatal: false;
|
|
217
|
+
readonly message: "The browser blocked audio playback (autoplay policy).";
|
|
218
|
+
readonly solution: "Call softphone.startAudio() from a click handler. Until then the user cannot hear the caller, and an INCOMING RINGTONE WILL BE SILENT — this is how calls get missed.";
|
|
219
|
+
};
|
|
220
|
+
readonly 'net/registration-failed': {
|
|
221
|
+
readonly fatal: true;
|
|
222
|
+
readonly message: "SIP registration failed.";
|
|
223
|
+
readonly solution: "Usually a stale credential. The engine refreshes and retries; if it persists, the extension may not exist.";
|
|
224
|
+
};
|
|
225
|
+
readonly 'net/transport-failed': {
|
|
226
|
+
readonly fatal: true;
|
|
227
|
+
readonly message: "The signalling transport could not be established.";
|
|
228
|
+
readonly solution: "Check the CSP connect-src directive and any corporate proxy blocking WSS.";
|
|
229
|
+
};
|
|
230
|
+
readonly 'net/ice-failed': {
|
|
231
|
+
readonly fatal: false;
|
|
232
|
+
readonly message: "Media connectivity was lost; attempting an ICE restart.";
|
|
233
|
+
readonly solution: "Usually a network handoff (wifi → cellular). The engine recovers on its own; surface a \"reconnecting\" banner rather than ending the call.";
|
|
234
|
+
};
|
|
235
|
+
readonly 'call/not-answerable': {
|
|
236
|
+
readonly fatal: false;
|
|
237
|
+
readonly message: "This call cannot be answered in its current state.";
|
|
238
|
+
readonly solution: "Only a call in `ringing` may be answered. Check call.state first.";
|
|
239
|
+
};
|
|
240
|
+
readonly 'call/busy': {
|
|
241
|
+
readonly fatal: false;
|
|
242
|
+
readonly message: "The remote party is busy.";
|
|
243
|
+
readonly solution: string;
|
|
244
|
+
};
|
|
245
|
+
readonly 'call/no-answer': {
|
|
246
|
+
readonly fatal: false;
|
|
247
|
+
readonly message: "The remote party did not answer.";
|
|
248
|
+
readonly solution: "Nothing to fix. Surface it to the user.";
|
|
249
|
+
};
|
|
250
|
+
readonly 'call/rejected': {
|
|
251
|
+
readonly fatal: false;
|
|
252
|
+
readonly message: "The remote party rejected the call.";
|
|
253
|
+
readonly solution: "Nothing to fix. Surface it to the user.";
|
|
254
|
+
};
|
|
255
|
+
readonly 'call/missed': {
|
|
256
|
+
readonly fatal: false;
|
|
257
|
+
readonly message: "An incoming call was cancelled before it was answered.";
|
|
258
|
+
readonly solution: "Nothing to fix — the caller gave up while it rang. Surface it, and log it as a missed call.";
|
|
259
|
+
};
|
|
260
|
+
readonly 'call/failed': {
|
|
261
|
+
readonly fatal: false;
|
|
262
|
+
readonly message: "The call could not be completed.";
|
|
263
|
+
readonly solution: string;
|
|
264
|
+
};
|
|
265
|
+
readonly 'call/transfer-failed': {
|
|
266
|
+
readonly fatal: false;
|
|
267
|
+
readonly message: "The transfer could not be completed.";
|
|
268
|
+
readonly solution: "The original call is still up. Let the user retry or resume.";
|
|
269
|
+
};
|
|
270
|
+
readonly 'call/unsupported': {
|
|
271
|
+
readonly fatal: false;
|
|
272
|
+
readonly message: "The provider does not support this operation.";
|
|
273
|
+
readonly solution: "Guard the UI on the operation before offering it.";
|
|
274
|
+
};
|
|
275
|
+
readonly 'provider/provision-failed': {
|
|
276
|
+
readonly fatal: true;
|
|
277
|
+
readonly message: "The provider could not issue SIP credentials.";
|
|
278
|
+
readonly solution: "The backend reached the carrier but got no usable credential. Check the org has a SIP extension provisioned.";
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
type SoftphoneErrorCode = keyof typeof ERROR_CATALOGUE;
|
|
282
|
+
/** Codes whose spec says `fatal: true`. Derived — cannot drift from the catalogue. */
|
|
283
|
+
type FatalErrorCode = {
|
|
284
|
+
[K in SoftphoneErrorCode]: (typeof ERROR_CATALOGUE)[K]['fatal'] extends true ? K : never;
|
|
285
|
+
}[SoftphoneErrorCode];
|
|
286
|
+
type NonFatalErrorCode = Exclude<SoftphoneErrorCode, FatalErrorCode>;
|
|
287
|
+
/**
|
|
288
|
+
* A fatal error: the call, or the engine, is over.
|
|
289
|
+
*
|
|
290
|
+
* `if (err.fatal)` narrows `err.code` to the fatal codes — which is the entire point of the split,
|
|
291
|
+
* and the thing JSDoc could not have expressed (SPEC §2.2).
|
|
292
|
+
*/
|
|
293
|
+
interface FatalSoftphoneError extends Error {
|
|
294
|
+
readonly fatal: true;
|
|
295
|
+
readonly code: FatalErrorCode;
|
|
296
|
+
readonly solution: string;
|
|
297
|
+
}
|
|
298
|
+
/** A non-fatal error: a warning. The call, if any, continues. */
|
|
299
|
+
interface NonFatalSoftphoneError extends Error {
|
|
300
|
+
readonly fatal: false;
|
|
301
|
+
readonly code: NonFatalErrorCode;
|
|
302
|
+
readonly solution: string;
|
|
303
|
+
}
|
|
304
|
+
type SoftphoneError = FatalSoftphoneError | NonFatalSoftphoneError;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Typed events.
|
|
308
|
+
*
|
|
309
|
+
* Enums rather than magic strings: autocomplete, and rename-safety. Daily's magic strings are the
|
|
310
|
+
* counter-example — a typo in `'participant-joined'` is a silent no-op forever.
|
|
311
|
+
*
|
|
312
|
+
* Each map narrows the listener payload PER EVENT, which is the thing that would have been painful
|
|
313
|
+
* to express in JSDoc and is a large part of why this package is TypeScript (SPEC §2.2).
|
|
314
|
+
*/
|
|
315
|
+
declare enum SoftphoneEvent {
|
|
316
|
+
/** Lifecycle. `online` = reachable, NOT SIP-registered — see SoftphoneState. */
|
|
317
|
+
StateChanged = "stateChanged",
|
|
318
|
+
/** An INVITE landed. The Call is `ringing`; answer or reject it. */
|
|
319
|
+
IncomingCall = "incomingCall",
|
|
320
|
+
/**
|
|
321
|
+
* The active call changed — a call began (inbound OR outbound) or ended. Fires for both
|
|
322
|
+
* directions, unlike IncomingCall. This is what a UI binds to in order to track "the current call"
|
|
323
|
+
* without threading the Call through every component.
|
|
324
|
+
*/
|
|
325
|
+
ActiveCallChanged = "activeCallChanged",
|
|
326
|
+
/**
|
|
327
|
+
* The browser refused to play audio (autoplay policy).
|
|
328
|
+
*
|
|
329
|
+
* This is the single most under-documented failure in the industry and it is how calls get MISSED:
|
|
330
|
+
* a blocked ringtone rings silently. Surface a button and call `startAudio()` from its click
|
|
331
|
+
* handler. Only Twilio and LiveKit document it properly.
|
|
332
|
+
*/
|
|
333
|
+
PlaybackBlocked = "playbackBlocked",
|
|
334
|
+
/**
|
|
335
|
+
* `softphone.audio` changed: a device was plugged/unplugged (`inputs`/`outputs`), or the selected
|
|
336
|
+
* input/output changed (`currentInputId`/`currentOutputId`). One event for all of it — re-read
|
|
337
|
+
* `softphone.audio` rather than inspecting the payload, which carries nothing (`useDevices` does
|
|
338
|
+
* exactly this).
|
|
339
|
+
*/
|
|
340
|
+
DeviceChange = "deviceChange",
|
|
341
|
+
/** Fatal or non-fatal — narrow on `error.fatal`. */
|
|
342
|
+
Error = "error",
|
|
343
|
+
/**
|
|
344
|
+
* This tab's leadership changed (`multi-tab-leader-election.md`) — a DIFFERENT axis from
|
|
345
|
+
* `StateChanged`: `state` is reachability, this is whether this tab is even allowed to be
|
|
346
|
+
* reachable at all while another tab holding the same extension stays open.
|
|
347
|
+
*/
|
|
348
|
+
LeaderChanged = "leaderChanged"
|
|
349
|
+
}
|
|
350
|
+
interface SoftphoneEventMap {
|
|
351
|
+
[SoftphoneEvent.StateChanged]: {
|
|
352
|
+
readonly state: SoftphoneState;
|
|
353
|
+
readonly previous: SoftphoneState;
|
|
354
|
+
};
|
|
355
|
+
[SoftphoneEvent.IncomingCall]: Call;
|
|
356
|
+
[SoftphoneEvent.ActiveCallChanged]: {
|
|
357
|
+
readonly call: Call | null;
|
|
358
|
+
};
|
|
359
|
+
[SoftphoneEvent.PlaybackBlocked]: void;
|
|
360
|
+
[SoftphoneEvent.DeviceChange]: void;
|
|
361
|
+
[SoftphoneEvent.Error]: SoftphoneError;
|
|
362
|
+
[SoftphoneEvent.LeaderChanged]: {
|
|
363
|
+
readonly isLeader: boolean;
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
declare enum CallEvent {
|
|
367
|
+
/** Any transition. The catch-all; the specific events below are conveniences. */
|
|
368
|
+
StateChanged = "stateChanged",
|
|
369
|
+
/** Media is flowing. */
|
|
370
|
+
Accepted = "accepted",
|
|
371
|
+
/** Terminal. Always fires exactly once, whatever the reason. */
|
|
372
|
+
Ended = "ended",
|
|
373
|
+
Muted = "muted",
|
|
374
|
+
/** `originator: 'remote'` means the OTHER party held US — new capability; today's softphone is blind to it. */
|
|
375
|
+
Held = "held",
|
|
376
|
+
Unheld = "unheld",
|
|
377
|
+
/** Media lost; an ICE restart is in flight. The call is NOT over — show a banner. */
|
|
378
|
+
Reconnecting = "reconnecting",
|
|
379
|
+
Reconnected = "reconnected",
|
|
380
|
+
DigitsSent = "digitsSent",
|
|
381
|
+
/** The contact name resolved after the call was already ringing. */
|
|
382
|
+
RemotePartyUpdated = "remotePartyUpdated",
|
|
383
|
+
/**
|
|
384
|
+
* The remote audio stream is available. Attach it to an `<audio>` element and play it — this is
|
|
385
|
+
* how the caller is actually HEARD. Core cannot do this itself (it must not touch `document` —
|
|
386
|
+
* SPEC §10.2); the UI layer or the host owns the audio element.
|
|
387
|
+
*/
|
|
388
|
+
RemoteStream = "remoteStream"
|
|
389
|
+
}
|
|
390
|
+
interface CallEventMap {
|
|
391
|
+
[CallEvent.StateChanged]: {
|
|
392
|
+
readonly state: CallState;
|
|
393
|
+
readonly previous: CallState;
|
|
394
|
+
};
|
|
395
|
+
[CallEvent.Accepted]: void;
|
|
396
|
+
[CallEvent.Ended]: {
|
|
397
|
+
readonly reason: HangupReason;
|
|
398
|
+
readonly error?: SoftphoneError;
|
|
399
|
+
};
|
|
400
|
+
[CallEvent.Muted]: {
|
|
401
|
+
readonly muted: boolean;
|
|
402
|
+
};
|
|
403
|
+
[CallEvent.Held]: {
|
|
404
|
+
readonly originator: HoldOriginator;
|
|
405
|
+
};
|
|
406
|
+
[CallEvent.Unheld]: {
|
|
407
|
+
readonly originator: HoldOriginator;
|
|
408
|
+
};
|
|
409
|
+
[CallEvent.Reconnecting]: void;
|
|
410
|
+
[CallEvent.Reconnected]: void;
|
|
411
|
+
[CallEvent.DigitsSent]: {
|
|
412
|
+
readonly digits: string;
|
|
413
|
+
};
|
|
414
|
+
[CallEvent.RemotePartyUpdated]: void;
|
|
415
|
+
[CallEvent.RemoteStream]: {
|
|
416
|
+
readonly stream: MediaStream;
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* `on()` returns an unsubscribe function.
|
|
421
|
+
*
|
|
422
|
+
* Deliberate: it makes the cleanup impossible to get wrong in a React effect
|
|
423
|
+
* (`useEffect(() => call.on(...), [call])`) and it avoids Vonage's design, where `on()` returns an
|
|
424
|
+
* opaque `symbol` you must retain in order to ever call `off()`.
|
|
425
|
+
*/
|
|
426
|
+
interface TypedEmitter<M> {
|
|
427
|
+
on<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;
|
|
428
|
+
once<E extends keyof M>(event: E, listener: (payload: M[E]) => void): () => void;
|
|
429
|
+
off<E extends keyof M>(event: E, listener: (payload: M[E]) => void): void;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* THE call state machine. Singular.
|
|
434
|
+
*
|
|
435
|
+
* Today there are three competing representations (SPEC §5.3):
|
|
436
|
+
* - `callStateRef` — a ref, read synchronously in guards to dodge stale closures
|
|
437
|
+
* - `status` — a useState that can lag a render behind it
|
|
438
|
+
* - `consultStatus` — the attended-transfer leg, tracked separately
|
|
439
|
+
* They can disagree. Collapsing them into one is the single biggest correctness win in this
|
|
440
|
+
* refactor, and the consult leg simply becomes its own Call.
|
|
441
|
+
*
|
|
442
|
+
*
|
|
443
|
+
* INBOUND OUTBOUND
|
|
444
|
+
* ─────── ────────
|
|
445
|
+
* ringing ──answer()──► answering dialing ──180/183──► early
|
|
446
|
+
* │ │ │ │
|
|
447
|
+
* │ ▼ └────────┬───────────┘
|
|
448
|
+
* │ reject() ┌───────┐ │ 200 OK
|
|
449
|
+
* └──────────────┐ │ ▼ ▼
|
|
450
|
+
* │ │ active ◄─────────────────────
|
|
451
|
+
* │ │ ▲ │ ▲ │
|
|
452
|
+
* │ │ │ │ │ └──hold()/remote──► held ──unhold()──┐
|
|
453
|
+
* │ │ │ │ └───────────────────────────────────────-┘
|
|
454
|
+
* │ │ │ └──ICE fail──► reconnecting ──recovered──┐
|
|
455
|
+
* │ │ └────────────────────────────────────────────┘
|
|
456
|
+
* ▼ ▼ │
|
|
457
|
+
* ┌──────────────▼────┐
|
|
458
|
+
* │ ended │ TERMINAL. Reachable from every state.
|
|
459
|
+
* └────────────────────┘
|
|
460
|
+
*
|
|
461
|
+
* NOT modelled here, deliberately:
|
|
462
|
+
*
|
|
463
|
+
* - **`rejecting`.** Today's engine has one, purely to swallow Zadarma's `return_timeout` retries
|
|
464
|
+
* for 35 s after a decline (SPEC §3.4). That is a PROVIDER quirk — it belongs in
|
|
465
|
+
* `providers/zadarma.ts` as `quirks.rejectGuardMs`, not in the shared state machine. A rejected
|
|
466
|
+
* call is simply `ended`. Our own SIP infrastructure would not need it at all (SPEC §2.3).
|
|
467
|
+
*
|
|
468
|
+
* - **A pre-INVITE state.** A Call exists only once an INVITE has landed. The "user answered
|
|
469
|
+
* before the INVITE arrived" case (SPEC §3.1) is an *intent* held on `Softphone`, above the
|
|
470
|
+
* call — because there is no call yet to hold it. Same for a pre-INVITE reject, which registers
|
|
471
|
+
* a UA specifically so it has something to send the 486 from.
|
|
472
|
+
*/
|
|
473
|
+
type CallState =
|
|
474
|
+
/** Inbound, INVITE received, not yet answered. */
|
|
475
|
+
'ringing'
|
|
476
|
+
/** Outbound, INVITE sent, no provisional response yet. */
|
|
477
|
+
| 'dialing'
|
|
478
|
+
/** Outbound, remote is ringing (180/183). May carry early media. */
|
|
479
|
+
| 'early'
|
|
480
|
+
/** Local answer in flight — 200 OK not yet confirmed. */
|
|
481
|
+
| 'answering'
|
|
482
|
+
/** Media flowing. */
|
|
483
|
+
| 'active'
|
|
484
|
+
/** Real SIP hold (`a=sendonly`), by either party. */
|
|
485
|
+
| 'held'
|
|
486
|
+
/** Media lost, ICE restart in flight. The call is NOT over. */
|
|
487
|
+
| 'reconnecting'
|
|
488
|
+
/** Terminal. */
|
|
489
|
+
| 'ended';
|
|
490
|
+
type HoldOriginator = 'local' | 'remote';
|
|
491
|
+
type HangupReason = 'local' | 'remote' | 'rejected' | 'busy' | 'no-answer' | 'transferred' | 'failed';
|
|
492
|
+
/**
|
|
493
|
+
* A single call. Exists only while there is one — there is no `idle`.
|
|
494
|
+
*
|
|
495
|
+
* The attended-transfer consult leg is *also* a Call. That is what lets one state machine cover
|
|
496
|
+
* both, instead of today's separate `consultStatus`.
|
|
497
|
+
*
|
|
498
|
+
* Implementation lands in S2 (against MockProvider) and S3–S4 (against JsSIP/Zadarma).
|
|
499
|
+
*/
|
|
500
|
+
interface Call extends TypedEmitter<CallEventMap> {
|
|
501
|
+
readonly id: string;
|
|
502
|
+
readonly direction: CallDirection;
|
|
503
|
+
readonly state: CallState;
|
|
504
|
+
readonly remote: RemoteParty;
|
|
505
|
+
/** Wall-clock start of media. Null until `active`. */
|
|
506
|
+
readonly answeredAt: Date | null;
|
|
507
|
+
readonly isMuted: boolean;
|
|
508
|
+
readonly isOnHold: boolean;
|
|
509
|
+
/**
|
|
510
|
+
* The remote party's audio stream, once media is flowing (null before). Attach it to an
|
|
511
|
+
* `<audio autoplay>` element to hear the call. Also delivered via `CallEvent.RemoteStream`.
|
|
512
|
+
*/
|
|
513
|
+
readonly remoteStream: MediaStream | null;
|
|
514
|
+
/** Only legal from `ringing`. Throws `call/not-answerable` otherwise. */
|
|
515
|
+
answer(): Promise<void>;
|
|
516
|
+
/** Only legal from `ringing`. Sends 486 Busy Here. */
|
|
517
|
+
reject(): Promise<void>;
|
|
518
|
+
/** Legal from any non-terminal state. Idempotent. */
|
|
519
|
+
hangup(): Promise<void>;
|
|
520
|
+
mute(muted: boolean): void;
|
|
521
|
+
/**
|
|
522
|
+
* Real SIP hold — a re-INVITE with `a=sendonly`, so the PBX can play hold music.
|
|
523
|
+
*
|
|
524
|
+
* Today this is FAKED by muting (SPEC §5.6): the held caller hears dead silence and cannot tell
|
|
525
|
+
* hold from a dropped call. JsSIP implements the real thing natively, so this is a handful of
|
|
526
|
+
* lines rather than the SIP work it looks like.
|
|
527
|
+
*/
|
|
528
|
+
hold(): Promise<void>;
|
|
529
|
+
unhold(): Promise<void>;
|
|
530
|
+
sendDigits(digits: string): void;
|
|
531
|
+
/** Blind transfer (SIP REFER). Ends this call. */
|
|
532
|
+
transfer(target: string): Promise<void>;
|
|
533
|
+
/**
|
|
534
|
+
* Attended transfer, part 1: call the target while holding this call.
|
|
535
|
+
*
|
|
536
|
+
* Returns the consult leg — itself a Call, with its own state machine and events.
|
|
537
|
+
*/
|
|
538
|
+
startConsult(target: string): Promise<Call>;
|
|
539
|
+
/** Attended transfer, part 2: bridge the two, ending both. (REFER … Replaces) */
|
|
540
|
+
completeTransfer(): Promise<void>;
|
|
541
|
+
/** Abandon the consult and resume this call. */
|
|
542
|
+
cancelConsult(): Promise<void>;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Audio device control (SPEC §7 Phase 2). Reactive: `SoftphoneEvent.DeviceChange` fires whenever any
|
|
547
|
+
* field here changes — a device was plugged/unplugged, or a selection changed — so `useDevices()`
|
|
548
|
+
* (and any other listener) always reads a current snapshot rather than one frozen at mount.
|
|
549
|
+
*/
|
|
550
|
+
interface AudioDevices {
|
|
551
|
+
readonly inputs: readonly MediaDeviceInfo[];
|
|
552
|
+
readonly outputs: readonly MediaDeviceInfo[];
|
|
553
|
+
/**
|
|
554
|
+
* The currently-selected input/output, or `null` — no explicit choice made, the browser's own
|
|
555
|
+
* default is in use. Never a stale ID: if the selected device is unplugged, its selection is
|
|
556
|
+
* cleared back to `null` rather than pointing at a device that no longer exists.
|
|
557
|
+
*/
|
|
558
|
+
readonly currentInputId: string | null;
|
|
559
|
+
readonly currentOutputId: string | null;
|
|
560
|
+
/**
|
|
561
|
+
* Re-enumerate. `inputs`/`outputs` already stay current on their own — a device physically
|
|
562
|
+
* plugged or unplugged fires the browser's own `devicechange` and needs no help — but a
|
|
563
|
+
* *permission* grant does not: the labels enumerate as blank strings until the microphone has been
|
|
564
|
+
* granted at least once, and granting it later fires no event at all. Call this when a UI surface
|
|
565
|
+
* that shows device names is about to become visible (SPEC §7 Phase 2 note: the settings tab calls
|
|
566
|
+
* it on mount for exactly this reason).
|
|
567
|
+
*/
|
|
568
|
+
refresh(): Promise<void>;
|
|
569
|
+
/**
|
|
570
|
+
* Switches the microphone, or `null` to reset to the browser's own default device. Applies to the
|
|
571
|
+
* call in progress, if any — immediately, via `RTCRtpSender.replaceTrack`, no renegotiation and no
|
|
572
|
+
* interruption the far end can hear — and to every call placed or answered after this resolves.
|
|
573
|
+
*/
|
|
574
|
+
setInput(deviceId: string | null): Promise<void>;
|
|
575
|
+
/**
|
|
576
|
+
* Switches where the REMOTE PARTY is heard, or `null` to reset to the browser's own default
|
|
577
|
+
* device. Applies instantly, live call or not — output routing needs no WebRTC renegotiation at
|
|
578
|
+
* all, unlike the input side.
|
|
579
|
+
*
|
|
580
|
+
* A no-op (resolves immediately) when `canSelectOutput` is false.
|
|
581
|
+
*/
|
|
582
|
+
setOutput(deviceId: string | null): Promise<void>;
|
|
583
|
+
/**
|
|
584
|
+
* The RINGTONE output, separate from the call speaker.
|
|
585
|
+
*
|
|
586
|
+
* Of every SDK surveyed only Twilio ships this, and users want it: ring on the laptop speakers so
|
|
587
|
+
* you hear it across the room, take the call in the headset.
|
|
588
|
+
*
|
|
589
|
+
* **Not yet implemented** — still Phase 2 groundwork, like `NoopAudioDevices` before it. Calling it
|
|
590
|
+
* resolves without effect until a later pass wires it up.
|
|
591
|
+
*/
|
|
592
|
+
setRingtoneOutput(deviceId: string | null): Promise<void>;
|
|
593
|
+
/** False when the browser lacks `setSinkId` (Safari, most of Firefox) — `setOutput` is then a no-op. */
|
|
594
|
+
readonly canSelectOutput: boolean;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The engine. Long-lived; owns registration, credentials, devices, and the provider.
|
|
598
|
+
*
|
|
599
|
+
* Implementation lands in S2.
|
|
600
|
+
*/
|
|
601
|
+
interface Softphone extends TypedEmitter<SoftphoneEventMap> {
|
|
602
|
+
readonly state: SoftphoneState;
|
|
603
|
+
/**
|
|
604
|
+
* The last **fatal** error, or `null` — the reason the phone cannot currently be used.
|
|
605
|
+
*
|
|
606
|
+
* State rather than only an event, because the common case is: the phone breaks, and *then* the
|
|
607
|
+
* user opens the panel to find out why. A widget that mounts after the failure would never see
|
|
608
|
+
* `SoftphoneEvent.Error`, and would render a dialpad with no explanation for why it does nothing.
|
|
609
|
+
*
|
|
610
|
+
* **Cleared when the softphone next reaches `online`** — the only evidence available that whatever
|
|
611
|
+
* was wrong no longer is. Non-fatal errors are emitted but never set this: a busy tone is a fact
|
|
612
|
+
* about one call, not a broken phone.
|
|
613
|
+
*/
|
|
614
|
+
readonly lastError: FatalSoftphoneError | null;
|
|
615
|
+
/**
|
|
616
|
+
* Whether THIS tab is the one allowed to be reachable (`multi-tab-leader-election.md`).
|
|
617
|
+
*
|
|
618
|
+
* Zadarma's on-demand registration means two tabs open for the same extension race each other to
|
|
619
|
+
* register on every inbound push — confirmed live, exactly one tab rings, unpredictably which. At
|
|
620
|
+
* most one open tab is ever the leader at a time; leadership passes to the next tab automatically
|
|
621
|
+
* (no reload) the moment the current leader closes, reloads, or crashes.
|
|
622
|
+
*
|
|
623
|
+
* `false` by default and while waiting for another tab to release it — a host should show its own
|
|
624
|
+
* "active in another tab" state for that duration (the `/ui` widget already does).
|
|
625
|
+
*/
|
|
626
|
+
readonly isLeader: boolean;
|
|
627
|
+
/**
|
|
628
|
+
* Start.
|
|
629
|
+
*
|
|
630
|
+
* **Call this from a user gesture.** Both the microphone permission prompt and the browser's
|
|
631
|
+
* autoplay policy key off user activation. Twilio's own docs name this as the cause of *missed
|
|
632
|
+
* calls*: with no activation, the incoming-call ringtone is silently blocked and the agent never
|
|
633
|
+
* knows anyone rang.
|
|
634
|
+
*/
|
|
635
|
+
start(): Promise<void>;
|
|
636
|
+
stop(): Promise<void>;
|
|
637
|
+
/** Idempotent. After this the instance is dead — construct a new one. */
|
|
638
|
+
destroy(): Promise<void>;
|
|
639
|
+
call(destination: string, options?: CallOptions): Promise<Call>;
|
|
640
|
+
/** The live call, or null. The consult leg of an attended transfer is reachable from it. */
|
|
641
|
+
readonly activeCall: Call | null;
|
|
642
|
+
/**
|
|
643
|
+
* Contacts + history, when `SoftphoneConfig.coventia` was given; `null` otherwise.
|
|
644
|
+
*
|
|
645
|
+
* The widget reads these rather than being handed a config, so there is exactly ONE set per
|
|
646
|
+
* softphone — one name cache, one of every request.
|
|
647
|
+
*/
|
|
648
|
+
readonly sources: BackendSources | null;
|
|
649
|
+
readonly audio: AudioDevices;
|
|
650
|
+
/**
|
|
651
|
+
* False when the browser has blocked audio playback.
|
|
652
|
+
*
|
|
653
|
+
* When false, an incoming call RINGS SILENTLY. Surface a button; call `startAudio()` from its
|
|
654
|
+
* click handler. This is the failure mode almost every voice SDK leaves undocumented.
|
|
655
|
+
*/
|
|
656
|
+
readonly canPlayAudio: boolean;
|
|
657
|
+
/** Must be called from within a user gesture. */
|
|
658
|
+
startAudio(): Promise<void>;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Shaped like a Lucide icon (`size`, `className`, both optional — every SVG prop Lucide's own icons
|
|
663
|
+
* take is optional too, so a real Lucide component satisfies this structurally) WITHOUT naming
|
|
664
|
+
* `lucide-react`'s own exported type. A host should not need that package installed just to satisfy
|
|
665
|
+
* ours — this accepts Lucide itself, or any icon set built the same shape.
|
|
666
|
+
*/
|
|
667
|
+
type ContactActionIcon = (props: {
|
|
668
|
+
readonly size?: number | string;
|
|
669
|
+
readonly className?: string;
|
|
670
|
+
}) => ReactNode;
|
|
671
|
+
/**
|
|
672
|
+
* The one customisable action on a contact's detail page — was the legacy's hard-wired "Ver
|
|
673
|
+
* mensajes" (`useNavigate('/chats?wa_id=…')`), now genuinely the host's: whatever `onClick` does,
|
|
674
|
+
* under whatever `label` fits it. "Open a support ticket," "Ver ficha en el CRM," anything.
|
|
675
|
+
*
|
|
676
|
+
* Omit `contactAction` entirely and the row simply does not exist — the detail page is then just
|
|
677
|
+
* the avatar, the name, the phone number, and the call button. A row with a hardcoded label and no
|
|
678
|
+
* handler would be worse than no row at all.
|
|
679
|
+
*
|
|
680
|
+
* Deliberately NOT a render prop (no arbitrary React node accepted here). Every other host-supplied
|
|
681
|
+
* piece of this widget is DATA — a contacts function, a history array, a callback — never a chunk of
|
|
682
|
+
* JSX to render as-is; that is what keeps the widget looking like one thing instead of drifting row
|
|
683
|
+
* by row. `icon` is optional so a partner without a natural icon for their action is not forced to
|
|
684
|
+
* pick one.
|
|
685
|
+
*/
|
|
686
|
+
interface ContactAction {
|
|
687
|
+
readonly label: string;
|
|
688
|
+
readonly icon?: ContactActionIcon;
|
|
689
|
+
readonly onClick: (contact: Contact) => void;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* `'system'` (default) follows the OS via `prefers-color-scheme`. Pin it to match the HOST's theme
|
|
694
|
+
* instead — the CRM toggles dark with a class (next-themes), not the OS, so a dark OS + light app
|
|
695
|
+
* would otherwise render a dark widget inside a light page.
|
|
696
|
+
*
|
|
697
|
+
* const { resolvedTheme } = useTheme();
|
|
698
|
+
* <SoftphoneWidget theme={resolvedTheme} />
|
|
699
|
+
*/
|
|
700
|
+
type SoftphoneTheme = 'light' | 'dark' | 'system';
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Config that cannot be expressed as HTML attributes.
|
|
704
|
+
*
|
|
705
|
+
* Attributes are strings; `contacts`/`history`/`contactAction` carry FUNCTIONS. So this is set as a
|
|
706
|
+
* JS PROPERTY, not an attribute — one line in vanilla JS or a PHP-rendered page. (`theme` is a
|
|
707
|
+
* string, so it is also available as the `theme` attribute.)
|
|
708
|
+
*/
|
|
709
|
+
interface SoftphoneElementConfig {
|
|
710
|
+
/**
|
|
711
|
+
* Ask for the microphone before showing the dialer. **On by default** — see
|
|
712
|
+
* `SoftphoneWidgetProps.requireMic`. Set `false` only when the mic is somebody else's problem.
|
|
713
|
+
*/
|
|
714
|
+
readonly requireMic?: boolean;
|
|
715
|
+
/**
|
|
716
|
+
* Play call audio — the remote party, the inbound ring, the outbound ringback. **On by default**;
|
|
717
|
+
* see `SoftphoneWidgetProps.sounds`.
|
|
718
|
+
*
|
|
719
|
+
* Turning this off makes a connected call silent in both directions, because nothing else attaches
|
|
720
|
+
* the remote `MediaStream`. Only correct when the host attaches `call.remoteStream` itself, or for
|
|
721
|
+
* a view that watches calls without listening to them.
|
|
722
|
+
*/
|
|
723
|
+
readonly sounds?: boolean;
|
|
724
|
+
readonly contacts?: ContactsSource;
|
|
725
|
+
readonly history?: HistorySource;
|
|
726
|
+
/** The action row on a contact's detail page. See `ContactAction` from `@coventia/softphone/ui`. */
|
|
727
|
+
readonly contactAction?: ContactAction;
|
|
728
|
+
/**
|
|
729
|
+
* A second, independent action chip on the INCOMING call screen — see `ContactAction` and
|
|
730
|
+
* `SoftphoneWidgetProps.incomingCallAction`. A different value from `contactAction`, not the same
|
|
731
|
+
* one reused; a host may configure either, both, or neither.
|
|
732
|
+
*/
|
|
733
|
+
readonly incomingCallAction?: ContactAction;
|
|
734
|
+
readonly theme?: SoftphoneTheme;
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Every CustomEvent the element dispatches, with its payload. The single source of truth for both
|
|
738
|
+
* sides of the wire.
|
|
739
|
+
*
|
|
740
|
+
* **Emitting** (`#emit`): fully checked — a name outside this map, or a payload that does not match
|
|
741
|
+
* it, is a compile error.
|
|
742
|
+
*
|
|
743
|
+
* **Listening**: merged into the DOM's event maps below, so a TypeScript consumer gets the name
|
|
744
|
+
* autocompleted and `e.detail` narrowed, with no cast. One honest gap — a MISSPELLED name is not
|
|
745
|
+
* rejected outright: `addEventListener(type: string, …)` is a real DOM overload and must accept any
|
|
746
|
+
* string, or arbitrary CustomEvents would be unlistenable. The typo surfaces one step later, as
|
|
747
|
+
* "Property 'detail' does not exist on type 'Event'", since the fallback overload hands back a plain
|
|
748
|
+
* `Event`. So a typo is caught if the handler reads the payload, and silent if it ignores it.
|
|
749
|
+
*/
|
|
750
|
+
interface SoftphoneElementEventMap {
|
|
751
|
+
/** The connection state changed (registering, ready, reconnecting…). */
|
|
752
|
+
'cv-state-changed': CustomEvent<{
|
|
753
|
+
readonly state: SoftphoneState;
|
|
754
|
+
}>;
|
|
755
|
+
/** An inbound INVITE landed. `name` IS the number until a contact resolves (SPEC §5.4). */
|
|
756
|
+
'cv-incoming-call': CustomEvent<{
|
|
757
|
+
readonly number: string;
|
|
758
|
+
readonly name: string;
|
|
759
|
+
}>;
|
|
760
|
+
/** A call became the active one, in either direction. */
|
|
761
|
+
'cv-call-started': CustomEvent<{
|
|
762
|
+
readonly number: string;
|
|
763
|
+
readonly direction: CallDirection;
|
|
764
|
+
}>;
|
|
765
|
+
/** The active call ended, for `reason`. */
|
|
766
|
+
'cv-call-ended': CustomEvent<{
|
|
767
|
+
readonly number: string;
|
|
768
|
+
readonly reason: HangupReason;
|
|
769
|
+
}>;
|
|
770
|
+
/**
|
|
771
|
+
* Something failed. **The widget has already shown the user why** — a fatal error replaces the
|
|
772
|
+
* dialpad, a call outcome appears as a line under the number — so this is for the host's own
|
|
773
|
+
* logging, toasts or call records, not for building an error UI that would say it twice.
|
|
774
|
+
*
|
|
775
|
+
* `message` is the DEVELOPER's sentence and is English (see `ERROR_CATALOGUE`). It belongs in a log
|
|
776
|
+
* or a Sentry group, never on screen. The user's Spanish copy is the widget's business.
|
|
777
|
+
*
|
|
778
|
+
* `fatal` is the split worth branching on: `true` means calling is impossible until something
|
|
779
|
+
* changes, `false` means one call ended badly and the phone is still fine.
|
|
780
|
+
*/
|
|
781
|
+
'cv-error': CustomEvent<{
|
|
782
|
+
readonly code: SoftphoneErrorCode;
|
|
783
|
+
readonly fatal: boolean;
|
|
784
|
+
readonly message: string;
|
|
785
|
+
}>;
|
|
786
|
+
/**
|
|
787
|
+
* This tab's leadership changed (`multi-tab-leader-election.md`) — a DIFFERENT axis from
|
|
788
|
+
* `cv-state-changed`: that is reachability, this is whether this tab is even allowed to be
|
|
789
|
+
* reachable while another open tab holds the same extension.
|
|
790
|
+
*/
|
|
791
|
+
'cv-leadership-changed': CustomEvent<{
|
|
792
|
+
readonly isLeader: boolean;
|
|
793
|
+
}>;
|
|
794
|
+
}
|
|
795
|
+
declare global {
|
|
796
|
+
/** `document.querySelector('coventia-softphone')` returns the real type — no cast to set `.softphone`. */
|
|
797
|
+
interface HTMLElementTagNameMap {
|
|
798
|
+
'coventia-softphone': CoventiaSoftphoneElement;
|
|
799
|
+
}
|
|
800
|
+
/** Types `el.addEventListener` — and any ANCESTOR's, since these events bubble. */
|
|
801
|
+
interface HTMLElementEventMap extends SoftphoneElementEventMap {
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Types `document.addEventListener` too. Needed separately: `DocumentEventMap` extends
|
|
805
|
+
* `GlobalEventHandlersEventMap`, NOT `HTMLElementEventMap` — so augmenting the element map alone
|
|
806
|
+
* would leave the most common listening spot for a `composed` event untyped.
|
|
807
|
+
*/
|
|
808
|
+
interface DocumentEventMap extends SoftphoneElementEventMap {
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
declare class CoventiaSoftphoneElement extends HTMLElement {
|
|
812
|
+
#private;
|
|
813
|
+
/**
|
|
814
|
+
* `require-mic` and `sounds` are here so a host with **no JavaScript at all** can turn them off —
|
|
815
|
+
* a PHP/WordPress page or a CDN `<script>` drop-in, which is the whole point of this entry. The
|
|
816
|
+
* function-valued config (contacts, history, contactAction) can never be attributes; these two
|
|
817
|
+
* are booleans and have no such excuse.
|
|
818
|
+
*/
|
|
819
|
+
static readonly observedAttributes: string[];
|
|
820
|
+
constructor();
|
|
821
|
+
/**
|
|
822
|
+
* The engine. REQUIRED — the host constructs it, so it owns the connection's lifetime and can keep
|
|
823
|
+
* a call alive across re-renders/navigations (SPEC §5.1).
|
|
824
|
+
*
|
|
825
|
+
* el.softphone = createSoftphone({ tokenSource, provider: createZadarmaEngine({…}) });
|
|
826
|
+
*/
|
|
827
|
+
get softphone(): Softphone | null;
|
|
828
|
+
set softphone(value: Softphone | null);
|
|
829
|
+
/** Config carrying functions (contacts/history/contactAction) — a property, not an attribute. */
|
|
830
|
+
get config(): SoftphoneElementConfig;
|
|
831
|
+
set config(value: SoftphoneElementConfig);
|
|
832
|
+
connectedCallback(): void;
|
|
833
|
+
/**
|
|
834
|
+
* Moving a node in the DOM fires disconnect→connect **synchronously**, so nothing may be torn down
|
|
835
|
+
* here: by the next microtask the element may be connected again. Deferring the decision — not just
|
|
836
|
+
* the unmount — is what makes a move survivable. Two things go wrong if you tear down eagerly:
|
|
837
|
+
*
|
|
838
|
+
* - `#detach()` here kills the engine subscriptions, and `#attach()` would not run again → the
|
|
839
|
+
* widget still renders but `cv-*` events stop forever. Silent, and invisible in the UI.
|
|
840
|
+
* - Nulling `#root` here makes the reconnect's `??=` call `createRoot` on a container that
|
|
841
|
+
* already has a root ("You are calling createRoot() on a container that has already been
|
|
842
|
+
* passed to createRoot()") — two roots on one div.
|
|
843
|
+
*
|
|
844
|
+
* The unmount stays asynchronous regardless: React forbids unmounting synchronously from inside a
|
|
845
|
+
* lifecycle it may still be rendering.
|
|
846
|
+
*/
|
|
847
|
+
disconnectedCallback(): void;
|
|
848
|
+
attributeChangedCallback(): void;
|
|
849
|
+
}
|
|
850
|
+
/** Register the element. Idempotent — a page may load the bundle more than once. */
|
|
851
|
+
declare function defineSoftphoneElement(tagName?: string): void;
|
|
852
|
+
|
|
853
|
+
export { CoventiaSoftphoneElement, type SoftphoneElementConfig, type SoftphoneElementEventMap, defineSoftphoneElement };
|