@lightworkai.official/debug-capture 0.14.3 → 0.16.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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The call, in this app — the support host's own call UI in a frame (0.16; the support host's docs/CALLS-PLAN.md §15).
3
+ *
4
+ * A reporter used to answer in a new tab on the support host: a hand-off, a popup a browser might block, and the app
5
+ * with the problem left behind just when the team asks to see it. Now the support host's `/embed/call` opens here, in a
6
+ * frame of our own — the same ring card, call corner, screen-sharing question and voice settings as its portal, so
7
+ * they never drift apart — signed in by the token the portal button uses, given the microphone, camera and screen.
8
+ *
9
+ * The frame says how to hold it (its messages, below): ready, its size and whether a moment covers the page, a drag
10
+ * of its top line, done, failed. We listen only to that frame's own window, from the support host's origin.
11
+ *
12
+ * Its own shadow root, like the ring card: it lands on pages we do not control. Marked as ours, so a report filed
13
+ * during a call does not photograph it.
14
+ */
15
+
16
+ const HOST_TAG = "lw-call-frame";
17
+ const SOURCE = "lw-call";
18
+ const MARGIN = 16;
19
+ /** The call corner's width — the support host's dock, plus the frame's room for its shadow. */
20
+ const CARD_WIDTH = 368;
21
+
22
+ export type FrameLayout = "card" | "full";
23
+ export interface FrameMessage {
24
+ source?: string;
25
+ type?: string;
26
+ width?: number;
27
+ height?: number;
28
+ layout?: FrameLayout;
29
+ phase?: "start" | "move" | "end";
30
+ x?: number;
31
+ y?: number;
32
+ reason?: string;
33
+ }
34
+ export interface FrameHandlers {
35
+ /** The call is on screen in the frame. */
36
+ ready(): void;
37
+ /** Nothing left to show — the frame is gone already. */
38
+ done(): void;
39
+ /** It could not sign in or never came up — the frame is gone; the caller's own card carries the call. */
40
+ failed(reason: string): void;
41
+ }
42
+
43
+ let host: HTMLElement | null = null;
44
+ let frame: HTMLIFrameElement | null = null;
45
+ let callId: string | null = null;
46
+ let origin = "";
47
+ let handlers: FrameHandlers | null = null;
48
+ let layout: FrameLayout = "card";
49
+ let size = { width: CARD_WIDTH, height: 0 };
50
+ let readyTimer: ReturnType<typeof setTimeout> | undefined;
51
+ let isReady = false;
52
+ /** Where the corner was moved to (left, top), or null: bottom right. Kept for this tab. */
53
+ let placed: { x: number; y: number } | null = null;
54
+ let drag: { sx: number; sy: number; x: number; y: number } | null = null;
55
+ const PLACE_KEY = "lw-call-frame-place";
56
+
57
+ /** The support host's page for this call. Pure, for the tests. */
58
+ export function frameUrl(supportHost: string, realmKey: string, token: string, id: string): string {
59
+ const url = new URL("embed/call", supportHost.endsWith("/") ? supportHost : `${supportHost}/`);
60
+ url.searchParams.set("realm", realmKey);
61
+ url.searchParams.set("token", token);
62
+ url.searchParams.set("call", id);
63
+ return url.toString();
64
+ }
65
+
66
+ /** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
67
+ export function clampTo(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
68
+ return {
69
+ x: Math.min(Math.max(p.x, MARGIN / 2), Math.max(MARGIN / 2, view.w - box.w - MARGIN / 2)),
70
+ y: Math.min(Math.max(p.y, MARGIN / 2), Math.max(MARGIN / 2, view.h - box.h - MARGIN / 2)),
71
+ };
72
+ }
73
+
74
+ /** Is this message from our frame, about the call? */
75
+ export function fromFrame(e: { origin: string; source: unknown; data: unknown }, expected: { origin: string; window: unknown }): FrameMessage | null {
76
+ if (e.origin !== expected.origin || e.source !== expected.window) return null;
77
+ const d = e.data as FrameMessage | null;
78
+ return d && typeof d === "object" && d.source === SOURCE && typeof d.type === "string" ? d : null;
79
+ }
80
+
81
+ export function callFrameFor(): string | null {
82
+ return host?.isConnected ? callId : null;
83
+ }
84
+
85
+ /** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
86
+ export function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs = 15_000): void {
87
+ if (typeof document === "undefined") return;
88
+ if (callId === id && host?.isConnected) return;
89
+ closeCallFrame();
90
+ callId = id;
91
+ handlers = h;
92
+ isReady = false;
93
+ layout = "card";
94
+ size = { width: CARD_WIDTH, height: 0 };
95
+ origin = new URL(src).origin;
96
+ try {
97
+ placed = JSON.parse(sessionStorage.getItem(PLACE_KEY) ?? "null");
98
+ } catch {
99
+ placed = null;
100
+ }
101
+ host = document.createElement(HOST_TAG);
102
+ host.dataset.debugReporter = "true";
103
+ const root = host.attachShadow({ mode: "open" });
104
+ const style = document.createElement("style");
105
+ style.textContent = `:host { all: initial; }
106
+ iframe { position: fixed; z-index: 2147483003; border: 0; background: transparent; color-scheme: normal; visibility: hidden; }
107
+ iframe.on { visibility: visible; }`;
108
+ frame = document.createElement("iframe");
109
+ // What a call needs: to hear and be heard, to show a face or a screen, to play the other side's voice.
110
+ frame.allow = "microphone; camera; display-capture; autoplay; clipboard-write; fullscreen";
111
+ frame.title = "สายจากทีมงาน";
112
+ frame.dataset.lwCallFrame = id;
113
+ frame.setAttribute("allowtransparency", "true");
114
+ frame.src = src;
115
+ root.append(style, frame);
116
+ document.body.append(host);
117
+ place();
118
+ window.addEventListener("message", onMessage);
119
+ window.addEventListener("resize", place);
120
+ // Never came up — a host that forbids the frame, a network that dropped it: the caller's card carries the call.
121
+ readyTimer = setTimeout(() => {
122
+ if (!isReady) fail("timeout");
123
+ }, readyWithinMs);
124
+ }
125
+
126
+ export function closeCallFrame(): void {
127
+ clearTimeout(readyTimer);
128
+ window.removeEventListener("message", onMessage);
129
+ window.removeEventListener("resize", place);
130
+ host?.remove();
131
+ host = null;
132
+ frame = null;
133
+ callId = null;
134
+ handlers = null;
135
+ drag = null;
136
+ }
137
+
138
+ function fail(reason: string): void {
139
+ const h = handlers;
140
+ closeCallFrame();
141
+ h?.failed(reason);
142
+ }
143
+
144
+ function onMessage(e: MessageEvent): void {
145
+ if (!frame) return;
146
+ const m = fromFrame(e, { origin, window: frame.contentWindow });
147
+ if (!m) return;
148
+ switch (m.type) {
149
+ case "ready":
150
+ isReady = true;
151
+ clearTimeout(readyTimer);
152
+ frame.classList.add("on");
153
+ handlers?.ready();
154
+ return;
155
+ case "size":
156
+ if (typeof m.width === "number" && typeof m.height === "number") size = { width: m.width, height: m.height };
157
+ if (m.layout === "card" || m.layout === "full") {
158
+ // About to cover the page: the call is told where its corner is, so it stays there while the frame grows.
159
+ if (m.layout === "full" && layout === "card") tellCorner();
160
+ layout = m.layout;
161
+ }
162
+ return place();
163
+ case "drag":
164
+ return onDrag(m);
165
+ case "home":
166
+ placed = null;
167
+ savePlace();
168
+ return place();
169
+ case "done": {
170
+ const h = handlers;
171
+ closeCallFrame();
172
+ h?.done();
173
+ return;
174
+ }
175
+ case "failed":
176
+ return fail(m.reason ?? "failed");
177
+ }
178
+ }
179
+
180
+ /** The corner moved by its top line: from where the press began, by how far the pointer went on the screen. */
181
+ function onDrag(m: FrameMessage): void {
182
+ if (!frame || layout !== "card" || typeof m.x !== "number" || typeof m.y !== "number") return;
183
+ if (m.phase === "start") {
184
+ const r = frame.getBoundingClientRect();
185
+ drag = { sx: m.x, sy: m.y, x: r.left, y: r.top };
186
+ return;
187
+ }
188
+ if (!drag) return;
189
+ placed = clampTo({ x: drag.x + (m.x - drag.sx), y: drag.y + (m.y - drag.sy) }, boxOf(), viewOf());
190
+ place();
191
+ if (m.phase === "end") {
192
+ drag = null;
193
+ savePlace();
194
+ }
195
+ }
196
+
197
+ /** Where the corner is on the page — the frame asks nothing else of us, and is told nothing else. */
198
+ function tellCorner(): void {
199
+ if (!frame?.contentWindow) return;
200
+ const r = frame.getBoundingClientRect();
201
+ frame.contentWindow.postMessage({ source: "lw-call-host", type: "corner", x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width) }, origin);
202
+ }
203
+
204
+ function savePlace(): void {
205
+ try {
206
+ if (placed) sessionStorage.setItem(PLACE_KEY, JSON.stringify(placed));
207
+ else sessionStorage.removeItem(PLACE_KEY);
208
+ } catch {
209
+ /* this tab only, when it can */
210
+ }
211
+ }
212
+
213
+ const viewOf = () => ({ w: window.innerWidth, h: window.innerHeight });
214
+ /**
215
+ * The corner: always the dock's width — the call fills whatever width it is given, so a width it reported while it
216
+ * had the whole window must never come back as the corner's (measured: 1264 px after a screen share) — and the
217
+ * height it says it needs.
218
+ */
219
+ function boxOf(): { w: number; h: number } {
220
+ const view = viewOf();
221
+ return { w: Math.min(CARD_WIDTH, view.w - MARGIN), h: Math.min(size.height || 0, view.h - MARGIN) };
222
+ }
223
+
224
+ /** The whole window for a moment that covers it; otherwise the corner — bottom right, or where it was moved. */
225
+ function place(): void {
226
+ if (!frame) return;
227
+ const s = frame.style;
228
+ if (layout === "full") {
229
+ Object.assign(s, { left: "0px", top: "0px", right: "", bottom: "", width: "100vw", height: "100vh" });
230
+ return;
231
+ }
232
+ const box = boxOf();
233
+ const narrow = viewOf().w < 480;
234
+ s.width = narrow ? `calc(100vw - ${MARGIN}px)` : `${box.w}px`;
235
+ s.height = `${box.h}px`;
236
+ if (placed && !narrow) {
237
+ const p = clampTo(placed, box, viewOf());
238
+ Object.assign(s, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "" });
239
+ } else Object.assign(s, { left: narrow ? `${MARGIN / 2}px` : "", top: "", right: narrow ? "" : `${MARGIN / 2}px`, bottom: `${MARGIN / 2}px` });
240
+ }
package/src/ui/ring.ts ADDED
@@ -0,0 +1,258 @@
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
+ /**
189
+ * The call's own frame is up (ui/callFrame, 0.16): its ring card is the one to press, so ours steps out of sight —
190
+ * the ring and the title stay, rung from this page, until the call stops ringing. `false` brings ours back (the frame
191
+ * failed: our card carries the call again).
192
+ */
193
+ export function quietRing(id: string, quiet = true): void {
194
+ if (!host?.isConnected || shownId !== id) return;
195
+ host.style.display = quiet ? "none" : "";
196
+ }
197
+
198
+ /** The call is no longer ringing for this person — answered, declined, missed or ended. */
199
+ export function hideRing(id?: string): void {
200
+ if (id && shownId && id !== shownId) return;
201
+ stopTone();
202
+ restoreTitle();
203
+ host?.remove();
204
+ host = null;
205
+ shownId = null;
206
+ }
207
+
208
+ export function ringShownFor(): string | null {
209
+ return host?.isConnected ? shownId : null;
210
+ }
211
+
212
+ function restoreTitle(): void {
213
+ if (titleBefore === null || typeof document === "undefined") return;
214
+ if (document.title.startsWith(t().ringTab)) document.title = titleBefore;
215
+ titleBefore = null;
216
+ }
217
+
218
+ /* ── A soft ring: two notes every two seconds, as the support host's own ── */
219
+
220
+ let audio: AudioContext | null = null;
221
+ let toneTimer: ReturnType<typeof setInterval> | undefined;
222
+
223
+ function startTone(): void {
224
+ stopTone();
225
+ const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
226
+ const Ctx = w.AudioContext ?? w.webkitAudioContext;
227
+ if (!Ctx) return;
228
+ try {
229
+ const ctx = audio ?? new Ctx();
230
+ audio = ctx;
231
+ // A page nobody has pressed anything on may not play sound — then the card and the title carry it.
232
+ void ctx.resume?.().catch(() => undefined);
233
+ } catch {
234
+ return;
235
+ }
236
+ const beep = () => {
237
+ if (!audio || audio.state !== "running") return;
238
+ const now = audio.currentTime;
239
+ for (const [i, f] of [660, 880].entries()) {
240
+ const osc = audio.createOscillator();
241
+ const gain = audio.createGain();
242
+ osc.frequency.value = f;
243
+ gain.gain.setValueAtTime(0.0001, now + i * 0.22);
244
+ gain.gain.exponentialRampToValueAtTime(0.08, now + i * 0.22 + 0.02);
245
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + i * 0.22 + 0.2);
246
+ osc.connect(gain).connect(audio.destination);
247
+ osc.start(now + i * 0.22);
248
+ osc.stop(now + i * 0.22 + 0.21);
249
+ }
250
+ };
251
+ beep();
252
+ toneTimer = setInterval(beep, 2200);
253
+ }
254
+
255
+ function stopTone(): void {
256
+ clearInterval(toneTimer);
257
+ toneTimer = undefined;
258
+ }
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 {