@lightworkai.official/debug-capture 0.17.0 → 0.17.2
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/README.md +17 -0
- package/dist/debug-capture.js +44 -32
- package/dist/index.mjs +50 -38
- package/dist/ui/callFrame.d.ts +10 -3
- package/dist/ui/ring.d.ts +12 -2
- package/dist/ui/strings.d.ts +7 -0
- package/package.json +1 -1
- package/src/live.ts +65 -23
- package/src/ui/callFrame.ts +85 -7
- package/src/ui/ring.ts +128 -49
- package/src/ui/strings.ts +17 -0
package/dist/ui/callFrame.d.ts
CHANGED
|
@@ -19,8 +19,15 @@ export interface FrameHandlers {
|
|
|
19
19
|
/** It could not sign in or never came up — the frame is gone; the caller's own card carries the call. */
|
|
20
20
|
failed(reason: string): void;
|
|
21
21
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The support host's page for this call. Pure, for the tests. Without a token (0.17.2): `hand=1` — the page asks for it
|
|
24
|
+
* by message, so it can load while the host is still fetching it.
|
|
25
|
+
*/
|
|
26
|
+
export declare function frameUrl(supportHost: string, realmKey: string, token: string | null, id: string): string;
|
|
27
|
+
/** รับสาย pressed on our card: the frame answers the moment it is ready — now, if it is. */
|
|
28
|
+
export declare function answerInFrame(id: string): void;
|
|
29
|
+
/** ทดสอบไมค์และลำโพง pressed on our card: the frame's voice settings, the moment it is ready. */
|
|
30
|
+
export declare function voiceInFrame(id: string): void;
|
|
24
31
|
/** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
|
|
25
32
|
export declare function clampTo(p: {
|
|
26
33
|
x: number;
|
|
@@ -46,5 +53,5 @@ export declare function fromFrame(e: {
|
|
|
46
53
|
}): FrameMessage | null;
|
|
47
54
|
export declare function callFrameFor(): string | null;
|
|
48
55
|
/** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
|
|
49
|
-
export declare function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs?: number): void;
|
|
56
|
+
export declare function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs?: number, token?: Promise<string | null> | null): void;
|
|
50
57
|
export declare function closeCallFrame(): void;
|
package/dist/ui/ring.d.ts
CHANGED
|
@@ -4,14 +4,24 @@ export interface RingCall {
|
|
|
4
4
|
ticketTitle: string;
|
|
5
5
|
}
|
|
6
6
|
export interface RingHandlers {
|
|
7
|
-
/**
|
|
7
|
+
/** The portal's way: runs INSIDE the press — a tab can be opened only there, before anything is awaited. */
|
|
8
8
|
open: () => void;
|
|
9
9
|
decline: () => void;
|
|
10
|
+
/** The call's frame is coming (0.17.2): รับสาย here — the frame answers the moment it is ready. */
|
|
11
|
+
answer?: () => void;
|
|
12
|
+
/** …and ทดสอบไมค์และลำโพง — the frame's voice settings, the moment it is ready. */
|
|
13
|
+
voice?: () => void;
|
|
10
14
|
}
|
|
15
|
+
/** `frame`: the call's own card while its frame comes; `portal`: answer in a new tab (no frame, or it failed). */
|
|
16
|
+
export type RingMode = "frame" | "portal";
|
|
11
17
|
/** "เรื่อง CALL-12 · title" — the line under the card's title. Pure, for the tests. */
|
|
12
18
|
export declare function ringAbout(template: string, call: RingCall): string;
|
|
13
19
|
/** Show the ring for this call — once; the same call again changes nothing. */
|
|
14
|
-
export declare function showRing(call: RingCall, handlers: RingHandlers): void;
|
|
20
|
+
export declare function showRing(call: RingCall, handlers: RingHandlers, mode?: RingMode): void;
|
|
21
|
+
/** รับสาย pressed while the call's frame comes: said, and nothing more to press until it is there. */
|
|
22
|
+
export declare function ringAnswering(id: string): void;
|
|
23
|
+
/** The call's frame could not come up: the same card, now the portal's way — a new tab, which needs a fresh press. */
|
|
24
|
+
export declare function ringFallback(call: RingCall, handlers: RingHandlers): void;
|
|
15
25
|
/** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
|
|
16
26
|
export declare function ringOpened(blockedUrl?: string): void;
|
|
17
27
|
/**
|
package/dist/ui/strings.d.ts
CHANGED
|
@@ -113,5 +113,12 @@ export interface Strings {
|
|
|
113
113
|
ringBlocked: string;
|
|
114
114
|
ringClose: string;
|
|
115
115
|
ringTab: string;
|
|
116
|
+
/** The call's own card, while its frame is coming (0.17.2): the same words as the support host's ring card. */
|
|
117
|
+
ringHint: string;
|
|
118
|
+
ringTest: string;
|
|
119
|
+
ringAnswer: string;
|
|
120
|
+
ringAnswering: string;
|
|
121
|
+
/** The frame could not come up: answer in a new tab instead. */
|
|
122
|
+
ringFallback: string;
|
|
116
123
|
}
|
|
117
124
|
export declare function strings(locale: "th" | "en" | undefined): Strings;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightworkai.official/debug-capture",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
4
4
|
"description": "Report-a-problem widget: rolling network/console/action capture, optional rrweb session replay, annotated screenshot, and submission to a Lightwork Support host",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.mjs",
|
package/src/live.ts
CHANGED
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { getConfig } from "./config";
|
|
26
26
|
import { preparePortalUrl } from "./portal";
|
|
27
|
-
import { hideRing, quietRing, ringOpened, ringShownFor, showRing } from "./ui/ring";
|
|
28
|
-
import { callFrameFor, closeCallFrame, frameUrl, openCallFrame } from "./ui/callFrame";
|
|
27
|
+
import { hideRing, quietRing, ringAnswering, ringFallback, ringOpened, ringShownFor, showRing, type RingHandlers } from "./ui/ring";
|
|
28
|
+
import { answerInFrame, callFrameFor, closeCallFrame, frameUrl, openCallFrame, voiceInFrame } from "./ui/callFrame";
|
|
29
29
|
|
|
30
30
|
export interface RingingCall {
|
|
31
31
|
id: string;
|
|
@@ -98,6 +98,7 @@ export function startLiveRing(): void {
|
|
|
98
98
|
if (!config.identity || config.ring === false) return;
|
|
99
99
|
started = true;
|
|
100
100
|
stopped = false;
|
|
101
|
+
warmUp(config.host);
|
|
101
102
|
void connect();
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -203,36 +204,62 @@ function markFramed(id: string | null): void {
|
|
|
203
204
|
}
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
|
|
207
|
+
/**
|
|
208
|
+
* The support host, met before the first ring (0.17.2): its name looked up and its connection made while the page is
|
|
209
|
+
* idle, so the call's frame does not start with them. A hint the browser may ignore; nothing is fetched.
|
|
210
|
+
*/
|
|
211
|
+
let warmed = false;
|
|
212
|
+
function warmUp(supportHost: string): void {
|
|
213
|
+
if (warmed || typeof document === "undefined") return;
|
|
214
|
+
warmed = true;
|
|
215
|
+
try {
|
|
216
|
+
const href = new URL(supportHost).origin;
|
|
217
|
+
for (const rel of ["dns-prefetch", "preconnect"]) {
|
|
218
|
+
const link = document.createElement("link");
|
|
219
|
+
link.rel = rel;
|
|
220
|
+
link.href = href;
|
|
221
|
+
if (rel === "preconnect") link.crossOrigin = "anonymous";
|
|
222
|
+
link.dataset.debugReporter = "true";
|
|
223
|
+
document.head.append(link);
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
/* not a host we can name */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
207
230
|
/** The call in a frame here (0.16): the support host's own call UI, signed in with the host's token. */
|
|
208
|
-
|
|
231
|
+
function frameFor(call: RingingCall): void {
|
|
209
232
|
const config = getConfig();
|
|
210
|
-
if (config.callFrame === false || callFrameFor() === call.id
|
|
211
|
-
framing = call.id;
|
|
212
|
-
const token = await hostToken();
|
|
213
|
-
framing = null;
|
|
214
|
-
if (!token) return;
|
|
233
|
+
if (config.callFrame === false || callFrameFor() === call.id) return;
|
|
215
234
|
markFramed(call.id);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
235
|
+
// At once (0.17.2): the frame loads while the host fetches its token — handed over when the frame says hello.
|
|
236
|
+
openCallFrame(
|
|
237
|
+
frameUrl(config.host, config.realmKey, null, call.id),
|
|
238
|
+
call.id,
|
|
239
|
+
{
|
|
240
|
+
ready: () => quietRing(call.id),
|
|
241
|
+
done: () => {
|
|
242
|
+
markFramed(null);
|
|
243
|
+
hideRing(call.id);
|
|
244
|
+
},
|
|
245
|
+
// The same card, now the portal's way: a new tab — a fresh press, since one cannot be opened after a wait.
|
|
246
|
+
failed: () => {
|
|
247
|
+
markFramed(null);
|
|
248
|
+
ringFallback(call, portalWay(call));
|
|
249
|
+
},
|
|
221
250
|
},
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
quietRing(call.id, false);
|
|
226
|
-
},
|
|
227
|
-
});
|
|
251
|
+
15_000,
|
|
252
|
+
hostToken(),
|
|
253
|
+
);
|
|
228
254
|
}
|
|
255
|
+
const portalWay = (call: RingingCall): RingHandlers => ({ open: () => answer(call), decline: () => void decline(call) });
|
|
229
256
|
|
|
230
257
|
/** The support host's word on a call: ringing here shows the card (and its frame); anything else takes the card away — the frame stays, it IS the call. */
|
|
231
258
|
function hear(call: RingingCall): void {
|
|
232
259
|
if (!ringsHere(call)) {
|
|
233
260
|
hideRing(call.id);
|
|
234
261
|
// Answered in this tab's frame, then the page reloaded: the frame comes back and takes the call back.
|
|
235
|
-
if (call.status === "active" && framedHere() === call.id && !callFrameFor())
|
|
262
|
+
if (call.status === "active" && framedHere() === call.id && !callFrameFor()) frameFor(call);
|
|
236
263
|
// Over: the frame says its last word and goes — and goes anyway if it cannot.
|
|
237
264
|
if (!LIVE_STATES.has(call.status) && callFrameFor() === call.id) {
|
|
238
265
|
setTimeout(() => {
|
|
@@ -244,8 +271,23 @@ function hear(call: RingingCall): void {
|
|
|
244
271
|
}
|
|
245
272
|
return;
|
|
246
273
|
}
|
|
247
|
-
|
|
248
|
-
|
|
274
|
+
if (getConfig().callFrame === false) showRing(call, portalWay(call), "portal");
|
|
275
|
+
else {
|
|
276
|
+
// The call's own card from the first moment — รับสาย pressed before its frame is up is kept, and done when it is.
|
|
277
|
+
showRing(
|
|
278
|
+
call,
|
|
279
|
+
{
|
|
280
|
+
...portalWay(call),
|
|
281
|
+
answer: () => {
|
|
282
|
+
ringAnswering(call.id);
|
|
283
|
+
answerInFrame(call.id);
|
|
284
|
+
},
|
|
285
|
+
voice: () => voiceInFrame(call.id),
|
|
286
|
+
},
|
|
287
|
+
"frame",
|
|
288
|
+
);
|
|
289
|
+
frameFor(call);
|
|
290
|
+
}
|
|
249
291
|
// Never longer than it rings: a socket that dropped mid-ring must not leave a card up for good.
|
|
250
292
|
clearTimeout(expiry);
|
|
251
293
|
const left = new Date(call.ringUntil).getTime() - Date.now();
|
package/src/ui/callFrame.ts
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
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
13
|
* during a call does not photograph it.
|
|
14
14
|
*
|
|
15
|
+
* And at once (0.17.2, the support host's §20): the owner, "it shows [the widget's card] a few seconds then turns into
|
|
16
|
+
* [the call's card] — why". The frame used to wait for the host's token before it began to load. Now it opens the
|
|
17
|
+
* moment the call rings and says `hello`; the token follows by message when the host gives it (and is never in an
|
|
18
|
+
* address). A รับสาย pressed on our card meanwhile is kept and passed on the moment the frame is ready (`answer`).
|
|
19
|
+
*
|
|
15
20
|
* And the team's marks (0.17, ชี้ · วาด · กรอบ): the frame hands each one up (`ink`) and ui/inkOverlay draws it over
|
|
16
21
|
* the page. So the call can tell whether the tab being shared is this one — where the marks can be seen — this page
|
|
17
22
|
* is named for the call while it lasts (Capture Handle, Chrome and Edge: `lw-call:<id>`, readable by the support host
|
|
@@ -39,6 +44,8 @@ export interface FrameMessage {
|
|
|
39
44
|
reason?: string;
|
|
40
45
|
op?: unknown;
|
|
41
46
|
}
|
|
47
|
+
/** What we tell the frame: where its corner is, the host's token, and a press it should act on. */
|
|
48
|
+
type HostMessage = { type: "corner"; x: number; y: number; width: number } | { type: "token"; token: string } | { type: "answer" } | { type: "voice" };
|
|
42
49
|
export interface FrameHandlers {
|
|
43
50
|
/** The call is on screen in the frame. */
|
|
44
51
|
ready(): void;
|
|
@@ -57,20 +64,47 @@ let layout: FrameLayout = "card";
|
|
|
57
64
|
let size = { width: CARD_WIDTH, height: 0 };
|
|
58
65
|
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
66
|
let isReady = false;
|
|
67
|
+
/** The host's token, on its way since the call rang — handed to the frame when it says hello (0.17.2). */
|
|
68
|
+
let tokenOnItsWay: Promise<string | null> | null = null;
|
|
69
|
+
/** Presses on our card while the frame was coming: passed on once it is ready. */
|
|
70
|
+
let pending: { answer: boolean; voice: boolean } = { answer: false, voice: false };
|
|
60
71
|
/** Where the corner was moved to (left, top), or null: bottom right. Kept for this tab. */
|
|
61
72
|
let placed: { x: number; y: number } | null = null;
|
|
62
|
-
|
|
73
|
+
/** A drag of the call's top line: where the press began (screen), where the frame was, and the last place it was moved to. */
|
|
74
|
+
let drag: { sx: number; sy: number; x: number; y: number; lx: number; ly: number } | null = null;
|
|
63
75
|
const PLACE_KEY = "lw-call-frame-place";
|
|
64
76
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The support host's page for this call. Pure, for the tests. Without a token (0.17.2): `hand=1` — the page asks for it
|
|
79
|
+
* by message, so it can load while the host is still fetching it.
|
|
80
|
+
*/
|
|
81
|
+
export function frameUrl(supportHost: string, realmKey: string, token: string | null, id: string): string {
|
|
67
82
|
const url = new URL("embed/call", supportHost.endsWith("/") ? supportHost : `${supportHost}/`);
|
|
68
83
|
url.searchParams.set("realm", realmKey);
|
|
69
|
-
url.searchParams.set("token", token);
|
|
84
|
+
if (token) url.searchParams.set("token", token);
|
|
85
|
+
else url.searchParams.set("hand", "1");
|
|
70
86
|
url.searchParams.set("call", id);
|
|
71
87
|
return url.toString();
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
/** Tell the frame something — only the support host's origin may read it. */
|
|
91
|
+
function tellFrame(m: HostMessage): void {
|
|
92
|
+
frame?.contentWindow?.postMessage({ source: "lw-call-host", ...m }, origin);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** รับสาย pressed on our card: the frame answers the moment it is ready — now, if it is. */
|
|
96
|
+
export function answerInFrame(id: string): void {
|
|
97
|
+
if (callId !== id) return;
|
|
98
|
+
pending.answer = true;
|
|
99
|
+
if (isReady) tellFrame({ type: "answer" });
|
|
100
|
+
}
|
|
101
|
+
/** ทดสอบไมค์และลำโพง pressed on our card: the frame's voice settings, the moment it is ready. */
|
|
102
|
+
export function voiceInFrame(id: string): void {
|
|
103
|
+
if (callId !== id) return;
|
|
104
|
+
pending.voice = true;
|
|
105
|
+
if (isReady) tellFrame({ type: "voice" });
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
/** Within the window, by at least the margin — a corner dragged off screen comes back. Pure, for the tests. */
|
|
75
109
|
export function clampTo(p: { x: number; y: number }, box: { w: number; h: number }, view: { w: number; h: number }): { x: number; y: number } {
|
|
76
110
|
return {
|
|
@@ -91,13 +125,15 @@ export function callFrameFor(): string | null {
|
|
|
91
125
|
}
|
|
92
126
|
|
|
93
127
|
/** Open the call's frame — once per call; the same call again changes nothing. Shown when it says it is ready. */
|
|
94
|
-
export function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs = 15_000): void {
|
|
128
|
+
export function openCallFrame(src: string, id: string, h: FrameHandlers, readyWithinMs = 15_000, token: Promise<string | null> | null = null): void {
|
|
95
129
|
if (typeof document === "undefined") return;
|
|
96
130
|
if (callId === id && host?.isConnected) return;
|
|
97
131
|
closeCallFrame();
|
|
98
132
|
callId = id;
|
|
99
133
|
handlers = h;
|
|
100
134
|
isReady = false;
|
|
135
|
+
tokenOnItsWay = token;
|
|
136
|
+
pending = { answer: false, voice: false };
|
|
101
137
|
layout = "card";
|
|
102
138
|
size = { width: CARD_WIDTH, height: 0 };
|
|
103
139
|
origin = new URL(src).origin;
|
|
@@ -138,12 +174,15 @@ export function closeCallFrame(): void {
|
|
|
138
174
|
clearTimeout(readyTimer);
|
|
139
175
|
window.removeEventListener("message", onMessage);
|
|
140
176
|
window.removeEventListener("resize", place);
|
|
177
|
+
followHere(false);
|
|
141
178
|
host?.remove();
|
|
142
179
|
host = null;
|
|
143
180
|
frame = null;
|
|
144
181
|
callId = null;
|
|
145
182
|
handlers = null;
|
|
146
183
|
drag = null;
|
|
184
|
+
tokenOnItsWay = null;
|
|
185
|
+
pending = { answer: false, voice: false };
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
function fail(reason: string): void {
|
|
@@ -157,11 +196,22 @@ function onMessage(e: MessageEvent): void {
|
|
|
157
196
|
const m = fromFrame(e, { origin, window: frame.contentWindow });
|
|
158
197
|
if (!m) return;
|
|
159
198
|
switch (m.type) {
|
|
199
|
+
case "hello":
|
|
200
|
+
// The page is up and asks for the host's token (it was not in its address): given when the host has it.
|
|
201
|
+
void tokenOnItsWay?.then((token) => {
|
|
202
|
+
if (!frame || callId === null) return;
|
|
203
|
+
if (token) tellFrame({ type: "token", token });
|
|
204
|
+
else fail("no_token");
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
160
207
|
case "ready":
|
|
161
208
|
isReady = true;
|
|
162
209
|
clearTimeout(readyTimer);
|
|
163
210
|
frame.classList.add("on");
|
|
164
211
|
handlers?.ready();
|
|
212
|
+
// What was pressed on our card while it came: done now.
|
|
213
|
+
if (pending.answer) tellFrame({ type: "answer" });
|
|
214
|
+
if (pending.voice) tellFrame({ type: "voice" });
|
|
165
215
|
return;
|
|
166
216
|
case "size":
|
|
167
217
|
if (typeof m.width === "number" && typeof m.height === "number") size = { width: m.width, height: m.height };
|
|
@@ -214,23 +264,51 @@ function onDrag(m: FrameMessage): void {
|
|
|
214
264
|
if (!frame || layout !== "card" || typeof m.x !== "number" || typeof m.y !== "number") return;
|
|
215
265
|
if (m.phase === "start") {
|
|
216
266
|
const r = frame.getBoundingClientRect();
|
|
217
|
-
drag = { sx: m.x, sy: m.y, x: r.left, y: r.top };
|
|
267
|
+
drag = { sx: m.x, sy: m.y, x: r.left, y: r.top, lx: m.x, ly: m.y };
|
|
268
|
+
followHere(true);
|
|
218
269
|
return;
|
|
219
270
|
}
|
|
220
271
|
if (!drag) return;
|
|
272
|
+
drag.lx = m.x;
|
|
273
|
+
drag.ly = m.y;
|
|
221
274
|
placed = clampTo({ x: drag.x + (m.x - drag.sx), y: drag.y + (m.y - drag.sy) }, boxOf(), viewOf());
|
|
222
275
|
place();
|
|
223
276
|
if (m.phase === "end") {
|
|
224
277
|
drag = null;
|
|
278
|
+
followHere(false);
|
|
225
279
|
savePlace();
|
|
226
280
|
}
|
|
227
281
|
}
|
|
228
282
|
|
|
283
|
+
/**
|
|
284
|
+
* This page's own pointer, while the call is dragged (0.17.1). The press is in the frame and the frame hears the moves
|
|
285
|
+
* — until a quick flick leaves it on the first one (measured on the folded call, 62 px tall: a 30 px first step), and
|
|
286
|
+
* from then on only this page hears the pointer. So it is followed here too, in the same screen coordinates; whichever
|
|
287
|
+
* hears a move, the frame goes there. A move with no button down is a release this page never heard: the drag ends
|
|
288
|
+
* where it last was — never where the pointer happens to be.
|
|
289
|
+
*/
|
|
290
|
+
let hostFollow: ((e: PointerEvent) => void) | null = null;
|
|
291
|
+
function followHere(on: boolean): void {
|
|
292
|
+
if (hostFollow) {
|
|
293
|
+
for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.removeEventListener(t, hostFollow, true);
|
|
294
|
+
hostFollow = null;
|
|
295
|
+
}
|
|
296
|
+
if (!on) return;
|
|
297
|
+
hostFollow = (e: PointerEvent) => {
|
|
298
|
+
if (!drag) return followHere(false);
|
|
299
|
+
if (e.type === "pointermove" && e.buttons !== 0) return onDrag({ type: "drag", phase: "move", x: e.screenX, y: e.screenY });
|
|
300
|
+
// Let go here — or let go somewhere unheard and only hovering now: ended where it last was, unless released here.
|
|
301
|
+
const released = e.type === "pointerup";
|
|
302
|
+
onDrag({ type: "drag", phase: "end", x: released ? e.screenX : drag.lx, y: released ? e.screenY : drag.ly });
|
|
303
|
+
};
|
|
304
|
+
for (const t of ["pointermove", "pointerup", "pointercancel"] as const) window.addEventListener(t, hostFollow, true);
|
|
305
|
+
}
|
|
306
|
+
|
|
229
307
|
/** Where the corner is on the page — the frame asks nothing else of us, and is told nothing else. */
|
|
230
308
|
function tellCorner(): void {
|
|
231
309
|
if (!frame?.contentWindow) return;
|
|
232
310
|
const r = frame.getBoundingClientRect();
|
|
233
|
-
|
|
311
|
+
tellFrame({ type: "corner", x: Math.round(r.left), y: Math.round(r.top), width: Math.round(r.width) });
|
|
234
312
|
}
|
|
235
313
|
|
|
236
314
|
function savePlace(): void {
|
package/src/ui/ring.ts
CHANGED
|
@@ -17,48 +17,70 @@ import { strings } from "./strings";
|
|
|
17
17
|
|
|
18
18
|
const HOST_TAG = "lw-call-ring";
|
|
19
19
|
|
|
20
|
+
/*
|
|
21
|
+
* The support host's own ring card, drawn the same (0.17.2, its §20): the call's frame takes this card's place when it is
|
|
22
|
+
* ready, and the owner saw two different cards — ours (เปิดเพื่อรับสาย) for a few seconds, then the call's (รับสาย). Now
|
|
23
|
+
* the same size, place, colours, words and buttons; the swap is not seen. Its values are the support host's default
|
|
24
|
+
* theme (--app-primary, --app-danger, emerald-600, --app-muted, --app-border; rounded-2xl, shadow-2xl, 22rem).
|
|
25
|
+
*/
|
|
20
26
|
const CSS = `
|
|
21
27
|
:host { all: initial; }
|
|
22
28
|
.card {
|
|
23
29
|
position: fixed; right: 16px; bottom: 16px; z-index: 2147483002;
|
|
24
|
-
width: min(
|
|
25
|
-
padding: 16px; border-radius: 16px; background: #fff; color: #
|
|
26
|
-
border: 1px solid #
|
|
27
|
-
font-family:
|
|
30
|
+
width: min(352px, calc(100vw - 32px)); box-sizing: border-box;
|
|
31
|
+
padding: 16px; border-radius: 16px; background: #fff; color: #1a1a1e;
|
|
32
|
+
border: 1px solid #e8e8ec; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
33
|
+
font-family: "Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
|
|
28
34
|
animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
29
35
|
}
|
|
30
|
-
@media (max-width:
|
|
36
|
+
@media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: 8px; width: auto; } }
|
|
31
37
|
.head { display: flex; align-items: center; gap: 12px; }
|
|
32
38
|
.icon {
|
|
33
|
-
position: relative; flex: none; width:
|
|
34
|
-
display: grid; place-items: center; background:
|
|
39
|
+
position: relative; flex: none; width: 48px; height: 48px; border-radius: 50%;
|
|
40
|
+
display: grid; place-items: center; background: rgba(0, 70, 143, 0.1); color: #00468f;
|
|
35
41
|
}
|
|
36
42
|
.icon::after {
|
|
37
43
|
content: ""; position: absolute; inset: 0; border-radius: 50%;
|
|
38
|
-
background: rgba(
|
|
44
|
+
background: rgba(0, 70, 143, 0.2); animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
|
|
39
45
|
}
|
|
40
46
|
.icon svg { position: relative; width: 20px; height: 20px; }
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
.how
|
|
45
|
-
.
|
|
47
|
+
.words { min-width: 0; }
|
|
48
|
+
h2 { margin: 0; font-size: 15px; font-weight: 600; line-height: 1.5; color: #1a1a1e; }
|
|
49
|
+
.about { margin: 0; font-size: 12px; line-height: 1.5; color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
50
|
+
.how { margin: 10px 0 0; font-size: 12px; line-height: 1.5; color: #6b7280; }
|
|
51
|
+
.how a { color: #00468f; font-weight: 600; }
|
|
52
|
+
.test {
|
|
53
|
+
margin: 6px 0 0; padding: 0; border: 0; background: none; cursor: pointer;
|
|
54
|
+
display: inline-flex; align-items: center; gap: 4px; font: inherit; font-size: 12px; font-weight: 500; color: #00468f;
|
|
55
|
+
}
|
|
56
|
+
.test:hover { text-decoration: underline; text-underline-offset: 2px; }
|
|
57
|
+
.test svg { width: 14px; height: 14px; }
|
|
58
|
+
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
|
59
|
+
.actions.one { grid-template-columns: 1fr; }
|
|
46
60
|
.actions button {
|
|
47
|
-
|
|
48
|
-
height:
|
|
61
|
+
box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
|
62
|
+
height: 44px; padding: 0 12px; border: none; border-radius: 999px; font: inherit; font-size: 14px; font-weight: 500; cursor: pointer; color: #fff;
|
|
49
63
|
}
|
|
50
|
-
.
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
64
|
+
.actions button svg { width: 16px; height: 16px; }
|
|
65
|
+
.actions button:disabled { opacity: 0.6; cursor: default; }
|
|
66
|
+
.decline { background: #f44336; }
|
|
67
|
+
.decline:hover:not(:disabled) { background: #dd3a2f; }
|
|
68
|
+
.open, .answer { background: #059669; }
|
|
69
|
+
.open:hover, .answer:hover:not(:disabled) { background: #04875f; }
|
|
70
|
+
.close { border: 1px solid #e8e8ec !important; background: #fff; color: #1a1a1e !important; }
|
|
71
|
+
.spin { animation: spin 0.9s linear infinite; }
|
|
72
|
+
.actions button:focus-visible, .test:focus-visible { outline: 2px solid #00468f; outline-offset: 2px; }
|
|
56
73
|
@keyframes in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
57
|
-
@keyframes ping {
|
|
58
|
-
@
|
|
74
|
+
@keyframes ping { 75%, 100% { transform: scale(2); opacity: 0; } }
|
|
75
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
76
|
+
@media (prefers-reduced-motion: reduce) { .card { animation: none; } .icon::after { animation: none; opacity: 0; } .spin { animation: none; } }
|
|
59
77
|
`;
|
|
60
78
|
|
|
61
|
-
const
|
|
79
|
+
const SVG = (d: string) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
|
|
80
|
+
const PHONE = SVG(`<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"/>`);
|
|
81
|
+
const PHONE_OFF = SVG(`<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/><line x1="22" x2="2" y1="2" y2="22"/>`);
|
|
82
|
+
const SLIDERS = SVG(`<path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>`);
|
|
83
|
+
const SPINNER = SVG(`<path d="M21 12a9 9 0 1 1-6.219-8.56"/>`).replace("<svg ", '<svg class="spin" ');
|
|
62
84
|
|
|
63
85
|
export interface RingCall {
|
|
64
86
|
id: string;
|
|
@@ -67,11 +89,18 @@ export interface RingCall {
|
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
export interface RingHandlers {
|
|
70
|
-
/**
|
|
92
|
+
/** The portal's way: runs INSIDE the press — a tab can be opened only there, before anything is awaited. */
|
|
71
93
|
open: () => void;
|
|
72
94
|
decline: () => void;
|
|
95
|
+
/** The call's frame is coming (0.17.2): รับสาย here — the frame answers the moment it is ready. */
|
|
96
|
+
answer?: () => void;
|
|
97
|
+
/** …and ทดสอบไมค์และลำโพง — the frame's voice settings, the moment it is ready. */
|
|
98
|
+
voice?: () => void;
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
/** `frame`: the call's own card while its frame comes; `portal`: answer in a new tab (no frame, or it failed). */
|
|
102
|
+
export type RingMode = "frame" | "portal";
|
|
103
|
+
|
|
75
104
|
let host: HTMLElement | null = null;
|
|
76
105
|
let shownId: string | null = null;
|
|
77
106
|
let titleBefore: string | null = null;
|
|
@@ -94,12 +123,23 @@ function mount(): ShadowRoot | null {
|
|
|
94
123
|
return root;
|
|
95
124
|
}
|
|
96
125
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
function button(className: string, html: string, data: string, onClick: () => void): HTMLButtonElement {
|
|
127
|
+
const b = document.createElement("button");
|
|
128
|
+
b.type = "button";
|
|
129
|
+
b.className = className;
|
|
130
|
+
b.innerHTML = html;
|
|
131
|
+
b.dataset.lwRing = data;
|
|
132
|
+
b.onclick = onClick;
|
|
133
|
+
return b;
|
|
134
|
+
}
|
|
135
|
+
const label = (text: string) => {
|
|
136
|
+
const span = document.createElement("span");
|
|
137
|
+
span.textContent = text;
|
|
138
|
+
return span.outerHTML;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The card's insides, for a mode — the head stays the same in both. */
|
|
142
|
+
function render(root: ShadowRoot, call: RingCall, handlers: RingHandlers, mode: RingMode, note?: string): void {
|
|
103
143
|
const s = t();
|
|
104
144
|
root.innerHTML = "";
|
|
105
145
|
const style = document.createElement("style");
|
|
@@ -109,12 +149,14 @@ export function showRing(call: RingCall, handlers: RingHandlers): void {
|
|
|
109
149
|
card.setAttribute("role", "alertdialog");
|
|
110
150
|
card.setAttribute("aria-label", s.ringTitle);
|
|
111
151
|
card.dataset.lwCallRing = call.id;
|
|
152
|
+
card.dataset.lwRingMode = mode;
|
|
112
153
|
const head = document.createElement("div");
|
|
113
154
|
head.className = "head";
|
|
114
155
|
const icon = document.createElement("span");
|
|
115
156
|
icon.className = "icon";
|
|
116
157
|
icon.innerHTML = PHONE;
|
|
117
158
|
const words = document.createElement("div");
|
|
159
|
+
words.className = "words";
|
|
118
160
|
const h = document.createElement("h2");
|
|
119
161
|
h.textContent = s.ringTitle;
|
|
120
162
|
const about = document.createElement("p");
|
|
@@ -125,30 +167,53 @@ export function showRing(call: RingCall, handlers: RingHandlers): void {
|
|
|
125
167
|
head.append(icon, words);
|
|
126
168
|
const how = document.createElement("p");
|
|
127
169
|
how.className = "how";
|
|
128
|
-
how.textContent = s.ringHow;
|
|
170
|
+
how.textContent = note ?? (mode === "frame" ? s.ringHint : s.ringHow);
|
|
129
171
|
const actions = document.createElement("div");
|
|
130
172
|
actions.className = "actions";
|
|
131
|
-
const decline =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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);
|
|
173
|
+
const decline = button("decline", PHONE_OFF + label(s.ringDecline), "decline", () => handlers.decline());
|
|
174
|
+
card.append(head, how);
|
|
175
|
+
if (mode === "frame" && handlers.answer) {
|
|
176
|
+
if (handlers.voice) card.append(button("test", SLIDERS + label(s.ringTest), "test", () => handlers.voice?.()));
|
|
177
|
+
const answer = button("answer", PHONE + label(s.ringAnswer), "answer", () => handlers.answer?.());
|
|
178
|
+
actions.append(decline, answer);
|
|
179
|
+
} else actions.append(decline, button("open", label(s.ringOpen), "open", () => handlers.open()));
|
|
180
|
+
card.append(actions);
|
|
145
181
|
root.append(style, card);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Show the ring for this call — once; the same call again changes nothing. */
|
|
185
|
+
export function showRing(call: RingCall, handlers: RingHandlers, mode: RingMode = "portal"): void {
|
|
186
|
+
if (shownId === call.id && host?.isConnected) return;
|
|
187
|
+
const root = mount();
|
|
188
|
+
if (!root) return;
|
|
189
|
+
shownId = call.id;
|
|
190
|
+
render(root, call, handlers, mode);
|
|
146
191
|
startTone();
|
|
147
192
|
if (titleBefore === null) {
|
|
148
193
|
titleBefore = document.title;
|
|
149
|
-
document.title = `${
|
|
194
|
+
document.title = `${t().ringTab} — ${titleBefore}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** รับสาย pressed while the call's frame comes: said, and nothing more to press until it is there. */
|
|
199
|
+
export function ringAnswering(id: string): void {
|
|
200
|
+
const root = host?.shadowRoot;
|
|
201
|
+
if (!root || shownId !== id) return;
|
|
202
|
+
const answer = root.querySelector<HTMLButtonElement>(".answer");
|
|
203
|
+
if (answer) {
|
|
204
|
+
answer.innerHTML = SPINNER + label(t().ringAnswering);
|
|
205
|
+
answer.disabled = true;
|
|
150
206
|
}
|
|
151
|
-
|
|
207
|
+
const decline = root.querySelector<HTMLButtonElement>(".decline");
|
|
208
|
+
if (decline) decline.disabled = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The call's frame could not come up: the same card, now the portal's way — a new tab, which needs a fresh press. */
|
|
212
|
+
export function ringFallback(call: RingCall, handlers: RingHandlers): void {
|
|
213
|
+
const root = host?.isConnected ? host.shadowRoot : null;
|
|
214
|
+
if (!root || shownId !== call.id) return showRing(call, handlers, "portal");
|
|
215
|
+
host!.style.display = "";
|
|
216
|
+
render(root, call, handlers, "portal", t().ringFallback);
|
|
152
217
|
}
|
|
153
218
|
|
|
154
219
|
/** Opened: the card says where to answer, and stops ringing. `blockedUrl` — the browser refused the tab: a link, which is a fresh press. */
|
|
@@ -192,8 +257,22 @@ export function ringOpened(blockedUrl?: string): void {
|
|
|
192
257
|
*/
|
|
193
258
|
export function quietRing(id: string, quiet = true): void {
|
|
194
259
|
if (!host?.isConnected || shownId !== id) return;
|
|
195
|
-
|
|
260
|
+
const h = host;
|
|
261
|
+
clearTimeout(fading);
|
|
262
|
+
if (!quiet) {
|
|
263
|
+
h.style.display = "";
|
|
264
|
+
h.style.opacity = "";
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Under the call's own card (the frame is above ours): a short fade, so a line that wraps differently in its fonts
|
|
268
|
+
// is not seen to jump (0.17.2).
|
|
269
|
+
h.style.transition = "opacity 150ms ease-out";
|
|
270
|
+
h.style.opacity = "0";
|
|
271
|
+
fading = setTimeout(() => {
|
|
272
|
+
if (h.style.opacity === "0") h.style.display = "none";
|
|
273
|
+
}, 160);
|
|
196
274
|
}
|
|
275
|
+
let fading: ReturnType<typeof setTimeout> | undefined;
|
|
197
276
|
|
|
198
277
|
/** The call is no longer ringing for this person — answered, declined, missed or ended. */
|
|
199
278
|
export function hideRing(id?: string): void {
|