@lightworkai.official/debug-capture 0.14.3 → 0.15.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/src/config.ts CHANGED
@@ -149,6 +149,18 @@ export interface DebugCaptureConfig {
149
149
  /** UI language. Only affects the built-in reporter's own strings. */
150
150
  locale?: "th" | "en";
151
151
 
152
+ /**
153
+ * The ring for a call from the support team. Default true.
154
+ *
155
+ * With `identity` set and calls switched on for the desk, the reporter's own
156
+ * live connection to the support host is held here — the one their portal
157
+ * tab holds, proven with the same signed token — so a call from the team
158
+ * rings in this app, as a card, and opens the reporter's request on the
159
+ * support host to answer it (the call itself happens there). `false` leaves
160
+ * calls to the support host and the invitation the team sends.
161
+ */
162
+ ring?: boolean;
163
+
152
164
  /**
153
165
  * Where this library's own notifications go.
154
166
  *
package/src/index.ts CHANGED
@@ -42,6 +42,8 @@ export { redactBody, redactHeaders, redactLabel, redactUrl, REDACTED } from "./c
42
42
  // `defineReporterElement()` builds it in the browser, where it exists.
43
43
  export { defineReporterElement, setErroredQueriesSource, REPORTER_TAG } from "./ui/element";
44
44
  export { showToast } from "./ui/toast";
45
+ // A call from the support team, rung in the host app — started by install; a host that renders its own UI may start or stop it.
46
+ export { startLiveRing, stopLiveRing } from "./live";
45
47
 
46
48
  /*
47
49
  * The annotator ENGINE, shared by every framework package.
package/src/install.ts CHANGED
@@ -14,6 +14,7 @@ import { strings } from "./ui/strings";
14
14
  import { showToast } from "./ui/toast";
15
15
  import { defineReporterElement, REPORTER_TAG } from "./ui/element";
16
16
  import { loadRequestTypes } from "./requests";
17
+ import { startLiveRing } from "./live";
17
18
 
18
19
  let armed = false;
19
20
 
@@ -69,4 +70,12 @@ export function installDebugCapture(options: InstallOptions = {}): void {
69
70
  const idle = (window as Window & { requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number }).requestIdleCallback;
70
71
  if (idle) idle(() => void loadRequestTypes(), { timeout: 5000 });
71
72
  else setTimeout(() => void loadRequestTypes(), 2000);
73
+
74
+ /*
75
+ * A call from the support team rings here (live.ts) — when the host vouches
76
+ * for who is signed in and the desk has calls on. Late, like the request
77
+ * types: the page came for something else.
78
+ */
79
+ if (idle) idle(() => startLiveRing(), { timeout: 8000 });
80
+ else setTimeout(() => startLiveRing(), 3000);
72
81
  }
package/src/live.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The ring for a call from the support team, in the host app.
3
+ *
4
+ * A reporter lives in the app they report from, not on the support host; a
5
+ * call from the team used to reach them only by the invitation in their
6
+ * email. So, when the host vouches for who is signed in (`identity`) and the
7
+ * desk has calls switched on, the widget holds the reporter's OWN live
8
+ * connection to the support host — the one their portal tab holds, traded
9
+ * for the host's signed token at `/ingest/live-ticket`, told about their own
10
+ * requests and calls and nothing else — and a call rings here as a card.
11
+ *
12
+ * Answering happens on the support host, where the call's media lives: the
13
+ * card's button opens the reporter's request there, signed in by the same
14
+ * hand-off the portal button uses — a tab opened INSIDE the press (a browser
15
+ * allows nothing later), its address set once the token is in hand.
16
+ *
17
+ * Quiet by design. No identity, calls off, a support host older than this
18
+ * package, a token refused: no socket, no card, nothing in the console — the
19
+ * widget does exactly what it did before.
20
+ */
21
+ import { getConfig } from "./config";
22
+ import { preparePortalUrl } from "./portal";
23
+ import { hideRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
24
+
25
+ export interface RingingCall {
26
+ id: string;
27
+ ticketId: string;
28
+ ticketKey: string;
29
+ ticketTitle: string;
30
+ status: string;
31
+ ringUntil: string;
32
+ side?: string | null;
33
+ }
34
+
35
+ const RETRY_MS = [2000, 5000, 15_000, 30_000, 60_000];
36
+ const PING_MS = 20_000;
37
+
38
+ let started = false;
39
+ let stopped = false;
40
+ /** Asked for a token and got none — nobody signed in yet. A few slow tries, then quiet: never a request a minute forever. */
41
+ let tokenless = 0;
42
+ const TOKENLESS_MAX = 5;
43
+ let socket: WebSocket | null = null;
44
+ let retry = 0;
45
+ let pinger: ReturnType<typeof setInterval> | undefined;
46
+ let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
47
+ let expiry: ReturnType<typeof setTimeout> | undefined;
48
+
49
+ const base = (host: string) => (host.endsWith("/") ? host : `${host}/`);
50
+
51
+ /** `wss://support.example/api/presence/ws?t=…` — the support host's scheme, the socket's own path. Pure, for the tests. */
52
+ export function liveSocketUrl(host: string, ticket: string): string {
53
+ const url = new URL("api/presence/ws", base(host));
54
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
55
+ url.searchParams.set("t", ticket);
56
+ return url.toString();
57
+ }
58
+
59
+ /** Where the card sends them: their request, with the call — the support host rings it there. Pure, for the tests. */
60
+ export function answerPath(call: Pick<RingingCall, "id" | "ticketId">): string {
61
+ return `/me/tickets/${call.ticketId}?call=${call.id}`;
62
+ }
63
+
64
+ /** Is this a call ringing for the person at this widget? */
65
+ export function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean {
66
+ return call.status === "ringing" && (call.side === "reporter" || call.side === undefined || call.side === null);
67
+ }
68
+
69
+ async function hostToken(): Promise<string | null> {
70
+ const provider = getConfig().identity;
71
+ if (!provider) return null;
72
+ try {
73
+ return (await provider()) ?? null;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ async function post(path: string, token: string): Promise<Response> {
80
+ const config = getConfig();
81
+ return fetch(new URL(path, base(config.host)).toString(), {
82
+ method: "POST",
83
+ headers: { "content-type": "application/json", "x-realm-key": config.realmKey, authorization: `Bearer ${token}` },
84
+ body: "{}",
85
+ });
86
+ }
87
+
88
+ /** Start listening for calls. Idempotent; a no-op without identity, or with `ring: false`. */
89
+ export function startLiveRing(): void {
90
+ if (started || typeof window === "undefined" || typeof WebSocket === "undefined") return;
91
+ const config = getConfig();
92
+ if (!config.identity || config.ring === false) return;
93
+ started = true;
94
+ stopped = false;
95
+ void connect();
96
+ }
97
+
98
+ /** Let go — the socket, the card, the timers. For a host that unmounts, and for the tests. */
99
+ export function stopLiveRing(): void {
100
+ stopped = true;
101
+ started = false;
102
+ clearTimeout(reconnectTimer);
103
+ clearInterval(pinger);
104
+ clearTimeout(expiry);
105
+ try {
106
+ socket?.close(1000, "bye");
107
+ } catch {
108
+ /* already */
109
+ }
110
+ socket = null;
111
+ hideRing();
112
+ }
113
+
114
+ function schedule(slow = false): void {
115
+ if (stopped) return;
116
+ const wait = slow ? RETRY_MS[RETRY_MS.length - 1]! : RETRY_MS[Math.min(retry++, RETRY_MS.length - 1)]!;
117
+ clearTimeout(reconnectTimer);
118
+ reconnectTimer = setTimeout(() => void connect(), wait);
119
+ }
120
+
121
+ async function connect(): Promise<void> {
122
+ if (stopped) return;
123
+ const token = await hostToken();
124
+ // Nobody signed in to the host yet: ask again, slowly — a few times, then stop.
125
+ if (!token) {
126
+ if (++tokenless >= TOKENLESS_MAX) {
127
+ stopped = true;
128
+ return;
129
+ }
130
+ return schedule(true);
131
+ }
132
+ tokenless = 0;
133
+ let res: Response;
134
+ try {
135
+ res = await post("ingest/live-ticket", token);
136
+ } catch {
137
+ return schedule();
138
+ }
139
+ // A token the desk does not accept, or a support host from before the ring: nothing to do, and nothing to say.
140
+ if (res.status === 401 || res.status === 404 || res.status === 403) {
141
+ stopped = true;
142
+ return;
143
+ }
144
+ if (!res.ok) return schedule(res.status === 429);
145
+ let out: { calls?: boolean; ticket?: string | null; call?: RingingCall | null };
146
+ try {
147
+ out = await res.json();
148
+ } catch {
149
+ return schedule();
150
+ }
151
+ // Calls off for this desk: no socket at all.
152
+ if (!out.calls || !out.ticket) {
153
+ stopped = true;
154
+ return;
155
+ }
156
+ if (out.call) hear(out.call);
157
+ const ws = new WebSocket(liveSocketUrl(getConfig().host, out.ticket));
158
+ socket = ws;
159
+ ws.onopen = () => {
160
+ retry = 0;
161
+ clearInterval(pinger);
162
+ pinger = setInterval(() => {
163
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
164
+ }, PING_MS);
165
+ };
166
+ ws.onmessage = (e) => {
167
+ try {
168
+ const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall };
169
+ if (frame.type === "call-state" && frame.call) hear(frame.call);
170
+ } catch {
171
+ /* not ours to read */
172
+ }
173
+ };
174
+ ws.onclose = () => {
175
+ clearInterval(pinger);
176
+ if (socket === ws) socket = null;
177
+ schedule();
178
+ };
179
+ }
180
+
181
+ /** The support host's word on a call: ringing here shows the card; anything else takes it away. */
182
+ function hear(call: RingingCall): void {
183
+ if (!ringsHere(call)) return hideRing(call.id);
184
+ showRing(call, { open: () => answer(call), decline: () => void decline(call) });
185
+ // Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
186
+ clearTimeout(expiry);
187
+ const left = new Date(call.ringUntil).getTime() - Date.now();
188
+ expiry = setTimeout(() => {
189
+ if (ringShownFor() === call.id) hideRing(call.id);
190
+ }, Math.max(5000, left + 5000));
191
+ }
192
+
193
+ /**
194
+ * เปิดเพื่อรับสาย. The tab is opened HERE, inside the press — after an await the
195
+ * browser would call it an unasked-for popup — then sent to the signed-in
196
+ * link once the host's token arrives. The support host is told the reporter
197
+ * is coming, so the ring waits for the hand-off and the team sees it.
198
+ */
199
+ function answer(call: RingingCall): void {
200
+ const tab = window.open("about:blank", "_blank");
201
+ void (async () => {
202
+ const token = await hostToken();
203
+ if (token) void post(`ingest/calls/${call.id}/coming`, token).catch(() => undefined);
204
+ let url: string;
205
+ try {
206
+ url = await preparePortalUrl(answerPath(call));
207
+ } catch {
208
+ tab?.close();
209
+ return;
210
+ }
211
+ if (tab && !tab.closed) {
212
+ tab.location.href = url;
213
+ try {
214
+ tab.opener = null;
215
+ } catch {
216
+ /* open either way */
217
+ }
218
+ ringOpened();
219
+ } else ringOpened(url);
220
+ })();
221
+ }
222
+
223
+ async function decline(call: RingingCall): Promise<void> {
224
+ hideRing(call.id);
225
+ const token = await hostToken();
226
+ if (token) await post(`ingest/calls/${call.id}/decline`, token).catch(() => undefined);
227
+ }
package/src/portal.ts CHANGED
@@ -79,6 +79,9 @@ async function prepareUrl(returnTo?: string): Promise<string> {
79
79
  return supportPortalUrl(config.host, config.realmKey, token, returnTo);
80
80
  }
81
81
 
82
+ /** The signed-in link to a path on the support host — for a caller that opened its own tab first (the call ring, ui/ring). */
83
+ export const preparePortalUrl = prepareUrl;
84
+
82
85
  /** False when the browser refused — a popup blocker, almost always. */
83
86
  function navigate(url: string, target: OpenPortalOptions["target"]): boolean {
84
87
  if (target === "_self") {
package/src/ui/ring.ts ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * A call from the support team, rung in the host app (live.ts).
3
+ *
4
+ * The call itself happens on the support host — its media code, its screen
5
+ * sharing, its dock — so this card does one thing: say who is calling and
6
+ * about what, and open the reporter's request there to answer it. A soft ring
7
+ * and the tab's title too, because a call that rings in a background tab and
8
+ * says nothing is a missed call.
9
+ *
10
+ * Its own shadow root and styles, like the dialog, the toast and the hand-off:
11
+ * it lands on pages we do not control. Marked as ours, so a report filed while
12
+ * it is up does not photograph it.
13
+ */
14
+ import { getConfig } from "../config";
15
+ import { containKeys } from "./keys";
16
+ import { strings } from "./strings";
17
+
18
+ const HOST_TAG = "lw-call-ring";
19
+
20
+ const CSS = `
21
+ :host { all: initial; }
22
+ .card {
23
+ position: fixed; right: 16px; bottom: 16px; z-index: 2147483002;
24
+ width: min(340px, calc(100vw - 32px)); box-sizing: border-box;
25
+ padding: 16px; border-radius: 16px; background: #fff; color: #0f172a;
26
+ border: 1px solid #e2e8f0; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.24);
27
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
28
+ animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
29
+ }
30
+ @media (max-width: 480px) { .card { right: 8px; left: 8px; bottom: 8px; width: auto; } }
31
+ .head { display: flex; align-items: center; gap: 12px; }
32
+ .icon {
33
+ position: relative; flex: none; width: 44px; height: 44px; border-radius: 50%;
34
+ display: grid; place-items: center; background: #ecfdf5; color: #059669;
35
+ }
36
+ .icon::after {
37
+ content: ""; position: absolute; inset: 0; border-radius: 50%;
38
+ background: rgba(16, 185, 129, 0.25); animation: ping 1.4s ease-out infinite;
39
+ }
40
+ .icon svg { position: relative; width: 20px; height: 20px; }
41
+ h2 { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.4; }
42
+ .about { margin: 2px 0 0; font-size: 12px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 250px; }
43
+ .how { margin: 10px 0 0; font-size: 12px; line-height: 1.6; color: #64748b; }
44
+ .how a { color: #0369a1; font-weight: 600; }
45
+ .actions { display: flex; gap: 8px; margin-top: 12px; }
46
+ .actions button {
47
+ flex: 1; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
48
+ height: 40px; padding: 0 12px; border-radius: 999px; font: inherit; font-size: 14px; font-weight: 600; cursor: pointer;
49
+ }
50
+ .decline { border: 1px solid #fecdd3; background: #fff1f2; color: #be123c; }
51
+ .decline:hover { background: #ffe4e6; }
52
+ .open { border: none; background: #059669; color: #fff; }
53
+ .open:hover { background: #047857; }
54
+ .close { border: 1px solid #e2e8f0; background: #fff; color: #334155; }
55
+ .actions button:focus-visible { outline: 2px solid #0ea5e9; outline-offset: 2px; }
56
+ @keyframes in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
57
+ @keyframes ping { from { transform: scale(1); opacity: 1; } to { transform: scale(1.6); opacity: 0; } }
58
+ @media (prefers-reduced-motion: reduce) { .card { animation: none; } .icon::after { animation: none; opacity: 0; } }
59
+ `;
60
+
61
+ const PHONE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`;
62
+
63
+ export interface RingCall {
64
+ id: string;
65
+ ticketKey: string;
66
+ ticketTitle: string;
67
+ }
68
+
69
+ export interface RingHandlers {
70
+ /** Runs INSIDE the press: a tab can be opened only there, before anything is awaited. */
71
+ open: () => void;
72
+ decline: () => void;
73
+ }
74
+
75
+ let host: HTMLElement | null = null;
76
+ let shownId: string | null = null;
77
+ let titleBefore: string | null = null;
78
+
79
+ /** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
80
+ export function ringAbout(template: string, call: RingCall): string {
81
+ return template.replace("{key}", call.ticketKey).replace("{title}", call.ticketTitle);
82
+ }
83
+
84
+ const t = () => strings(getConfig().locale);
85
+
86
+ function mount(): ShadowRoot | null {
87
+ if (typeof document === "undefined") return null;
88
+ if (host?.isConnected) return host.shadowRoot;
89
+ host = document.createElement(HOST_TAG);
90
+ host.dataset.debugReporter = "true";
91
+ const root = host.attachShadow({ mode: "open" });
92
+ containKeys(root);
93
+ document.body.append(host);
94
+ return root;
95
+ }
96
+
97
+ /** Show the ring for this call — once; the same call again changes nothing. */
98
+ export function showRing(call: RingCall, handlers: RingHandlers): void {
99
+ if (shownId === call.id && host?.isConnected) return;
100
+ const root = mount();
101
+ if (!root) return;
102
+ shownId = call.id;
103
+ const s = t();
104
+ root.innerHTML = "";
105
+ const style = document.createElement("style");
106
+ style.textContent = CSS;
107
+ const card = document.createElement("div");
108
+ card.className = "card";
109
+ card.setAttribute("role", "alertdialog");
110
+ card.setAttribute("aria-label", s.ringTitle);
111
+ card.dataset.lwCallRing = call.id;
112
+ const head = document.createElement("div");
113
+ head.className = "head";
114
+ const icon = document.createElement("span");
115
+ icon.className = "icon";
116
+ icon.innerHTML = PHONE;
117
+ const words = document.createElement("div");
118
+ const h = document.createElement("h2");
119
+ h.textContent = s.ringTitle;
120
+ const about = document.createElement("p");
121
+ about.className = "about";
122
+ about.textContent = ringAbout(s.ringAbout, call);
123
+ about.title = about.textContent;
124
+ words.append(h, about);
125
+ head.append(icon, words);
126
+ const how = document.createElement("p");
127
+ how.className = "how";
128
+ how.textContent = s.ringHow;
129
+ const actions = document.createElement("div");
130
+ actions.className = "actions";
131
+ const decline = document.createElement("button");
132
+ decline.type = "button";
133
+ decline.className = "decline";
134
+ decline.textContent = s.ringDecline;
135
+ decline.dataset.lwRing = "decline";
136
+ decline.onclick = () => handlers.decline();
137
+ const open = document.createElement("button");
138
+ open.type = "button";
139
+ open.className = "open";
140
+ open.textContent = s.ringOpen;
141
+ open.dataset.lwRing = "open";
142
+ open.onclick = () => handlers.open();
143
+ actions.append(decline, open);
144
+ card.append(head, how, actions);
145
+ root.append(style, card);
146
+ startTone();
147
+ if (titleBefore === null) {
148
+ titleBefore = document.title;
149
+ document.title = `${s.ringTab} — ${titleBefore}`;
150
+ }
151
+ open.focus({ preventScroll: true });
152
+ }
153
+
154
+ /** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
155
+ export function ringOpened(blockedUrl?: string): void {
156
+ const root = host?.shadowRoot;
157
+ if (!root) return;
158
+ stopTone();
159
+ restoreTitle();
160
+ const s = t();
161
+ const how = root.querySelector(".how");
162
+ const actions = root.querySelector(".actions");
163
+ if (how) {
164
+ how.textContent = "";
165
+ if (blockedUrl) {
166
+ const a = document.createElement("a");
167
+ a.href = blockedUrl;
168
+ a.target = "_blank";
169
+ a.rel = "noopener";
170
+ a.textContent = s.ringBlocked;
171
+ a.dataset.lwRing = "link";
172
+ a.onclick = () => window.setTimeout(() => ringOpened(), 0);
173
+ how.append(a);
174
+ } else how.textContent = s.ringOpened;
175
+ }
176
+ if (actions) {
177
+ actions.innerHTML = "";
178
+ const close = document.createElement("button");
179
+ close.type = "button";
180
+ close.className = "close";
181
+ close.textContent = s.ringClose;
182
+ close.dataset.lwRing = "close";
183
+ close.onclick = () => hideRing();
184
+ actions.append(close);
185
+ }
186
+ }
187
+
188
+ /** The call is no longer ringing for this person — answered, declined, missed or ended. */
189
+ export function hideRing(id?: string): void {
190
+ if (id && shownId && id !== shownId) return;
191
+ stopTone();
192
+ restoreTitle();
193
+ host?.remove();
194
+ host = null;
195
+ shownId = null;
196
+ }
197
+
198
+ export function ringShownFor(): string | null {
199
+ return host?.isConnected ? shownId : null;
200
+ }
201
+
202
+ function restoreTitle(): void {
203
+ if (titleBefore === null || typeof document === "undefined") return;
204
+ if (document.title.startsWith(t().ringTab)) document.title = titleBefore;
205
+ titleBefore = null;
206
+ }
207
+
208
+ /* ── A soft ring: two notes every two seconds, as the support host's own ── */
209
+
210
+ let audio: AudioContext | null = null;
211
+ let toneTimer: ReturnType<typeof setInterval> | undefined;
212
+
213
+ function startTone(): void {
214
+ stopTone();
215
+ const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
216
+ const Ctx = w.AudioContext ?? w.webkitAudioContext;
217
+ if (!Ctx) return;
218
+ try {
219
+ const ctx = audio ?? new Ctx();
220
+ audio = ctx;
221
+ // A page nobody has pressed anything on may not play sound — then the card and the title carry it.
222
+ void ctx.resume?.().catch(() => undefined);
223
+ } catch {
224
+ return;
225
+ }
226
+ const beep = () => {
227
+ if (!audio || audio.state !== "running") return;
228
+ const now = audio.currentTime;
229
+ for (const [i, f] of [660, 880].entries()) {
230
+ const osc = audio.createOscillator();
231
+ const gain = audio.createGain();
232
+ osc.frequency.value = f;
233
+ gain.gain.setValueAtTime(0.0001, now + i * 0.22);
234
+ gain.gain.exponentialRampToValueAtTime(0.08, now + i * 0.22 + 0.02);
235
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + i * 0.22 + 0.2);
236
+ osc.connect(gain).connect(audio.destination);
237
+ osc.start(now + i * 0.22);
238
+ osc.stop(now + i * 0.22 + 0.21);
239
+ }
240
+ };
241
+ beep();
242
+ toneTimer = setInterval(beep, 2200);
243
+ }
244
+
245
+ function stopTone(): void {
246
+ clearInterval(toneTimer);
247
+ toneTimer = undefined;
248
+ }
package/src/ui/strings.ts CHANGED
@@ -108,6 +108,16 @@ export interface Strings {
108
108
  /** Carry `{n}`. */
109
109
  days: string;
110
110
  hours: string;
111
+ /** A call from the support team (ui/ring). `ringAbout` carries `{key}` and `{title}`. */
112
+ ringTitle: string;
113
+ ringAbout: string;
114
+ ringHow: string;
115
+ ringOpen: string;
116
+ ringDecline: string;
117
+ ringOpened: string;
118
+ ringBlocked: string;
119
+ ringClose: string;
120
+ ringTab: string;
111
121
  }
112
122
 
113
123
  const th: Strings = {
@@ -183,6 +193,15 @@ const th: Strings = {
183
193
  requestLeavesWhat: "บันทึกการใช้งานของหน้านี้ — ใช้กับการแจ้งปัญหาเท่านั้น",
184
194
  days: "{n} วัน",
185
195
  hours: "{n} ชั่วโมง",
196
+ ringTitle: "ทีมงานกำลังโทรหาคุณ",
197
+ ringAbout: "เรื่อง {key} · {title}",
198
+ ringHow: "รับสายในหน้าคำร้องของคุณ — เปิดในแท็บใหม่ ไม่ต้องเข้าสู่ระบบอีก",
199
+ ringOpen: "เปิดเพื่อรับสาย",
200
+ ringDecline: "ไม่รับ",
201
+ ringOpened: "เปิดหน้ารับสายแล้ว — กด รับสาย ในแท็บที่เปิดขึ้น",
202
+ ringBlocked: "เบราว์เซอร์บล็อกแท็บใหม่ — กดที่นี่เพื่อเปิดหน้ารับสาย",
203
+ ringClose: "ปิด",
204
+ ringTab: "📞 สายเข้า",
186
205
  };
187
206
 
188
207
  const en: Strings = {
@@ -258,6 +277,15 @@ const en: Strings = {
258
277
  requestLeavesWhat: "this page's activity log — it is for problem reports only",
259
278
  days: "{n} days",
260
279
  hours: "{n} hours",
280
+ ringTitle: "The support team is calling you",
281
+ ringAbout: "About {key} · {title}",
282
+ ringHow: "Answer on your request's page — it opens in a new tab, already signed in",
283
+ ringOpen: "Open to answer",
284
+ ringDecline: "Decline",
285
+ ringOpened: "Opened — press Answer in the new tab",
286
+ ringBlocked: "Your browser blocked the new tab — open the call here",
287
+ ringClose: "Close",
288
+ ringTab: "📞 Incoming call",
261
289
  };
262
290
 
263
291
  export function strings(locale: "th" | "en" | undefined): Strings {