@lightworkai.official/debug-capture 0.17.4 → 0.19.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/README.md +37 -1
- package/dist/config.d.ts +26 -0
- package/dist/debug-capture.js +41 -11
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +47 -17
- package/dist/live.d.ts +7 -0
- package/dist/server.d.ts +7 -3
- package/dist/server.mjs +7 -6
- package/dist/types.d.ts +2 -0
- package/dist/ui/chatBubble.d.ts +12 -0
- package/dist/ui/strings.d.ts +4 -0
- package/package.json +1 -1
- package/src/config.ts +27 -0
- package/src/embed.ts +12 -1
- package/src/index.ts +6 -0
- package/src/install.ts +11 -0
- package/src/live.ts +27 -6
- package/src/server.ts +16 -9
- package/src/submit.ts +2 -0
- package/src/types.ts +2 -0
- package/src/ui/callFrame.ts +1 -1
- package/src/ui/chatBubble.ts +247 -0
- package/src/ui/ring.ts +2 -2
- package/src/ui/strings.ts +10 -0
package/dist/live.d.ts
CHANGED
|
@@ -13,7 +13,14 @@ export declare function liveSocketUrl(host: string, ticket: string): string;
|
|
|
13
13
|
export declare function answerPath(call: Pick<RingingCall, "id" | "ticketId">): string;
|
|
14
14
|
/** Is this a call ringing for the person at this widget? */
|
|
15
15
|
export declare function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean;
|
|
16
|
+
type LiveFrame = {
|
|
17
|
+
type?: string;
|
|
18
|
+
[k: string]: unknown;
|
|
19
|
+
};
|
|
20
|
+
export declare function onLiveFrame(fn: (frame: LiveFrame) => void): () => void;
|
|
21
|
+
export declare function hostToken(): Promise<string | null>;
|
|
16
22
|
/** Start listening for calls. Idempotent; a no-op without identity, or with `ring: false`. */
|
|
17
23
|
export declare function startLiveRing(): void;
|
|
18
24
|
/** Let go — the socket, the card, the timers. For a host that unmounts, and for the tests. */
|
|
19
25
|
export declare function stopLiveRing(): void;
|
|
26
|
+
export {};
|
package/dist/server.d.ts
CHANGED
|
@@ -14,11 +14,15 @@ export interface SupportProfileFact {
|
|
|
14
14
|
label: string;
|
|
15
15
|
value: string;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Whoever the host has authenticated. Email is the account key — or, for someone the host knows but holds no email
|
|
19
|
+
* for (a CIB officer with none in the ERP, 2026-10-27: every report they ever sent landed anonymous, nobody could call
|
|
20
|
+
* them back), the host's own id alone.
|
|
21
|
+
*/
|
|
18
22
|
export interface SupportUser {
|
|
19
|
-
/** The host's own id for this person, recorded so a changed email still matches. */
|
|
23
|
+
/** The host's own id for this person, recorded so a changed email still matches — the key when there is no email. */
|
|
20
24
|
id?: string | null;
|
|
21
|
-
email
|
|
25
|
+
email?: string | null;
|
|
22
26
|
name?: string | null;
|
|
23
27
|
/** What the host knows about their work — most important first. Optional; bounded on the way in. */
|
|
24
28
|
profile?: SupportProfileFact[] | null;
|
package/dist/server.mjs
CHANGED
|
@@ -23,17 +23,18 @@ function cleanProfile(profile) {
|
|
|
23
23
|
var DEFAULT_TTL_SECONDS = 120;
|
|
24
24
|
var b64 = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
|
25
25
|
function createSupportToken(options) {
|
|
26
|
-
const email = options.user.email?.trim().toLowerCase();
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const email = options.user.email?.trim().toLowerCase() || null;
|
|
27
|
+
const id = options.user.id?.trim() || null;
|
|
28
|
+
if (email ? !email.includes("@") : !id)
|
|
29
|
+
throw new Error("createSupportToken: a real email, or the host's own id for this person, is required");
|
|
29
30
|
if (!options.secret || options.secret.length < 16) {
|
|
30
31
|
throw new Error("createSupportToken: the realm's reporter secret is missing or too short");
|
|
31
32
|
}
|
|
32
33
|
const now = Math.floor(Date.now() / 1000);
|
|
33
34
|
const expiresIn = Math.min(Math.max(Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), 30), 12 * 60 * 60);
|
|
34
35
|
const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({
|
|
35
|
-
sub:
|
|
36
|
-
email,
|
|
36
|
+
sub: id ?? email,
|
|
37
|
+
...email ? { email } : {},
|
|
37
38
|
name: options.user.name?.trim() || null,
|
|
38
39
|
...cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {},
|
|
39
40
|
jti: randomUUID(),
|
|
@@ -59,7 +60,7 @@ function supportTokenHandler(options) {
|
|
|
59
60
|
return json({ error: "Support identity is not configured" }, 503);
|
|
60
61
|
}
|
|
61
62
|
const user = await options.user();
|
|
62
|
-
if (!user?.email)
|
|
63
|
+
if (!user?.email?.trim() && !user?.id?.trim())
|
|
63
64
|
return json({ error: "Unauthorized" }, 401);
|
|
64
65
|
try {
|
|
65
66
|
const { token, expiresIn } = createSupportToken({ secret, user, ttlSeconds: options.ttlSeconds });
|
package/dist/types.d.ts
CHANGED
|
@@ -80,6 +80,8 @@ export interface DebugReason {
|
|
|
80
80
|
type: DebugReasonType;
|
|
81
81
|
message?: string;
|
|
82
82
|
stack?: string;
|
|
83
|
+
/** Opened from แชทกับทีม's panel (แจ้งปัญหาพร้อมภาพหน้าจอ): the report is linked to the person's chat. */
|
|
84
|
+
fromChat?: boolean;
|
|
83
85
|
}
|
|
84
86
|
/** A failed data-layer query the host chose to report (react-query, Apollo, …). */
|
|
85
87
|
export interface ErroredQuery {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
|
|
2
|
+
export declare function supportFrameUrl(host: string, realmKey: string): string;
|
|
3
|
+
/** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
|
|
4
|
+
export declare function badgeText(n: number): string;
|
|
5
|
+
/** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
|
|
6
|
+
export declare function openSupportChat(): void;
|
|
7
|
+
/** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
|
|
8
|
+
export declare function closeSupportChat(refocus?: boolean): void;
|
|
9
|
+
/** The bubble, once — hidden until the host's token says somebody is signed in. */
|
|
10
|
+
export declare function mountSupportChat(): void;
|
|
11
|
+
/** Take it away — for a host that unmounts, and for the tests. */
|
|
12
|
+
export declare function unmountSupportChat(): void;
|
package/dist/ui/strings.d.ts
CHANGED
|
@@ -120,5 +120,9 @@ export interface Strings {
|
|
|
120
120
|
ringAnswering: string;
|
|
121
121
|
/** The frame could not come up: answer in a new tab instead. */
|
|
122
122
|
ringFallback: string;
|
|
123
|
+
/** แชทกับทีม's bubble (ui/chatBubble): its name, the panel's, and what waits — `chatUnread` carries `{n}`. */
|
|
124
|
+
chatOpen: string;
|
|
125
|
+
chatTitle: string;
|
|
126
|
+
chatUnread: string;
|
|
123
127
|
}
|
|
124
128
|
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.
|
|
3
|
+
"version": "0.19.0",
|
|
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/config.ts
CHANGED
|
@@ -111,6 +111,17 @@ export interface DebugCaptureConfig {
|
|
|
111
111
|
/** Anything else worth recording — feature flags, build id, tenant. */
|
|
112
112
|
extra?: () => Record<string, unknown>;
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* แชทกับทีม — a chat bubble at the bottom right: the person asks the support desk anything, no report needed, and
|
|
116
|
+
* follows what they filed (เรื่องของฉัน) in the same panel. The desk answers from สนทนา › แชท and can open a เรื่อง
|
|
117
|
+
* from it; แจ้งปัญหาพร้อมภาพหน้าจอ in the panel opens this library's report, linked to the chat.
|
|
118
|
+
*
|
|
119
|
+
* Off unless set. Needs `identity` — a chat is a person's, and is read back: with no signed-in person the bubble is
|
|
120
|
+
* not shown. `true` puts it at the bottom right; `{ position: "left" }` at the bottom left; `offset` moves it in from
|
|
121
|
+
* the corner (px) for a host that already has something there.
|
|
122
|
+
*/
|
|
123
|
+
chat?: boolean | ChatOptions;
|
|
124
|
+
|
|
114
125
|
capture?: CaptureOptions;
|
|
115
126
|
redact?: RedactOptions;
|
|
116
127
|
|
|
@@ -209,6 +220,22 @@ export interface ToastNotice {
|
|
|
209
220
|
action?: { label: string; onClick: () => void };
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
/** Where แชทกับทีม's bubble sits (`chat`). */
|
|
224
|
+
export interface ChatOptions {
|
|
225
|
+
/** Which bottom corner — the right unless said. */
|
|
226
|
+
position?: "right" | "left";
|
|
227
|
+
/** In from that corner, in px — for a host that already has something there. */
|
|
228
|
+
offset?: { x?: number; y?: number };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The bubble's place, from `chat`: null when it is off. */
|
|
232
|
+
export function chatPlace(chat: DebugCaptureConfig["chat"]): { side: "right" | "left"; x: number; y: number } | null {
|
|
233
|
+
if (!chat) return null;
|
|
234
|
+
const o = chat === true ? {} : chat;
|
|
235
|
+
const n = (v: unknown, d: number) => (typeof v === "number" && Number.isFinite(v) && v >= 0 && v < 2000 ? v : d);
|
|
236
|
+
return { side: o.position === "left" ? "left" : "right", x: n(o.offset?.x, 16), y: n(o.offset?.y, 16) };
|
|
237
|
+
}
|
|
238
|
+
|
|
212
239
|
export interface CaptureOptions {
|
|
213
240
|
/** Rolling buffer sizes. */
|
|
214
241
|
maxRequests?: number;
|
package/src/embed.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* data-app-version="1.4.2"
|
|
10
10
|
* data-environment="production"
|
|
11
11
|
* data-modules='{"/saraban":"งานสารบรรณ","/asset":"งานพัสดุ"}'
|
|
12
|
-
* data-replay="60"
|
|
12
|
+
* data-replay="60"
|
|
13
|
+
* data-identity-url="/api/support-token"
|
|
14
|
+
* data-chat="right"></script>
|
|
13
15
|
*
|
|
14
16
|
* `data-replay` records the last N seconds of the page for a report (see
|
|
15
17
|
* `replay` in config.ts). The recorder is `rrweb-record.mjs`, shipped beside
|
|
@@ -20,6 +22,7 @@ import { moduleResolver, type ModuleMap } from "./modules";
|
|
|
20
22
|
import { installDebugCapture } from "./install";
|
|
21
23
|
import { launch } from "./reporter";
|
|
22
24
|
import { setRecorderLoader } from "./capture/replay";
|
|
25
|
+
import { openSupportChat } from "./ui/chatBubble";
|
|
23
26
|
|
|
24
27
|
const script = document.currentScript as HTMLScriptElement | null;
|
|
25
28
|
const data = script?.dataset ?? {};
|
|
@@ -65,6 +68,12 @@ if (data.host && data.realmKey) {
|
|
|
65
68
|
module: moduleResolver(parseModules(data.modules)),
|
|
66
69
|
locale: data.locale === "en" ? "en" : "th",
|
|
67
70
|
...(data.replay !== undefined ? { replay: { seconds: Number(data.replay) || undefined } } : {}),
|
|
71
|
+
/*
|
|
72
|
+
* Who is signed in (`identity`): the host's own endpoint answering its user's support token as text — the call's
|
|
73
|
+
* ring and แชทกับทีม need it. `data-chat`: the bubble, "left" for the bottom left.
|
|
74
|
+
*/
|
|
75
|
+
...(data.identityUrl ? { identity: () => fetch(data.identityUrl!, { credentials: "include" }).then((r) => (r.ok ? r.text() : null)).catch(() => null) } : {}),
|
|
76
|
+
...(data.chat !== undefined && data.chat !== "false" ? { chat: data.chat === "left" ? { position: "left" as const } : true } : {}),
|
|
68
77
|
});
|
|
69
78
|
/*
|
|
70
79
|
* In <head>, the script runs before there is a <body> — and installing then
|
|
@@ -78,3 +87,5 @@ if (data.host && data.realmKey) {
|
|
|
78
87
|
// The only handle a no-bundler page gets. Call it from a button's onclick —
|
|
79
88
|
// directly, so the click is still the active gesture when screen capture asks.
|
|
80
89
|
(window as unknown as { lwReportProblem: typeof launch }).lwReportProblem = launch;
|
|
90
|
+
// แชทกับทีม's panel, for a page with its own chat button.
|
|
91
|
+
(window as unknown as { lwOpenSupportChat: typeof openSupportChat }).lwOpenSupportChat = openSupportChat;
|
package/src/index.ts
CHANGED
|
@@ -73,5 +73,11 @@ export type { Strings as ReporterStrings } from "./ui/strings";
|
|
|
73
73
|
*/
|
|
74
74
|
export { openSupportPortal, supportPortalUrl, NoIdentityError } from "./portal";
|
|
75
75
|
export type { OpenPortalOptions } from "./portal";
|
|
76
|
+
/*
|
|
77
|
+
* แชทกับทีม (0.19): `chat` in the config mounts the bubble on install; a host with its own button opens and closes
|
|
78
|
+
* the same panel. The panel is the support host's page, framed — as the tickets are, maintained where they live.
|
|
79
|
+
*/
|
|
80
|
+
export { openSupportChat, closeSupportChat, mountSupportChat, unmountSupportChat, supportFrameUrl, badgeText } from "./ui/chatBubble";
|
|
81
|
+
export type { ChatOptions } from "./config";
|
|
76
82
|
|
|
77
83
|
export type * from "./types";
|
package/src/install.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { showToast } from "./ui/toast";
|
|
|
15
15
|
import { defineReporterElement, REPORTER_TAG } from "./ui/element";
|
|
16
16
|
import { loadRequestTypes } from "./requests";
|
|
17
17
|
import { startLiveRing } from "./live";
|
|
18
|
+
import { mountSupportChat } from "./ui/chatBubble";
|
|
18
19
|
|
|
19
20
|
let armed = false;
|
|
20
21
|
|
|
@@ -78,4 +79,14 @@ export function installDebugCapture(options: InstallOptions = {}): void {
|
|
|
78
79
|
*/
|
|
79
80
|
if (idle) idle(() => startLiveRing(), { timeout: 8000 });
|
|
80
81
|
else setTimeout(() => startLiveRing(), 3000);
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* แชทกับทีม's bubble (`chat`), once the page has settled — it shows itself only for a signed-in person (the host's
|
|
85
|
+
* token answers), and the person's socket above tells its badge when the desk writes.
|
|
86
|
+
*/
|
|
87
|
+
if (getConfig().chat) {
|
|
88
|
+
if (!getConfig().identity) console.warn("[debug-capture] `chat` needs `identity` — a chat is a signed-in person's; the bubble is not shown");
|
|
89
|
+
else if (idle) idle(() => mountSupportChat(), { timeout: 4000 });
|
|
90
|
+
else setTimeout(() => mountSupportChat(), 1500);
|
|
91
|
+
}
|
|
81
92
|
}
|
package/src/live.ts
CHANGED
|
@@ -74,7 +74,20 @@ export function ringsHere(call: Pick<RingingCall, "status" | "side">): boolean {
|
|
|
74
74
|
return call.status === "ringing" && (call.side === "reporter" || call.side === undefined || call.side === null);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
/*
|
|
78
|
+
* What else the person's socket says — แชทกับทีม's bubble hears the desk answer here (ui/chatBubble): `chat-changed`,
|
|
79
|
+
* `ticket-changed`, `chat-seen`. One socket for the ring and the bubble.
|
|
80
|
+
*/
|
|
81
|
+
type LiveFrame = { type?: string; [k: string]: unknown };
|
|
82
|
+
const frameListeners = new Set<(frame: LiveFrame) => void>();
|
|
83
|
+
export function onLiveFrame(fn: (frame: LiveFrame) => void): () => void {
|
|
84
|
+
frameListeners.add(fn);
|
|
85
|
+
return () => frameListeners.delete(fn);
|
|
86
|
+
}
|
|
87
|
+
/** Calls ring here — unless the host said `ring: false` (the socket may still be open for the chat's bubble). */
|
|
88
|
+
const ringOn = () => getConfig().ring !== false;
|
|
89
|
+
|
|
90
|
+
export async function hostToken(): Promise<string | null> {
|
|
78
91
|
const provider = getConfig().identity;
|
|
79
92
|
if (!provider) return null;
|
|
80
93
|
try {
|
|
@@ -97,7 +110,8 @@ async function post(path: string, token: string): Promise<Response> {
|
|
|
97
110
|
export function startLiveRing(): void {
|
|
98
111
|
if (started || typeof window === "undefined" || typeof WebSocket === "undefined") return;
|
|
99
112
|
const config = getConfig();
|
|
100
|
-
|
|
113
|
+
// The ring, or แชทกับทีม's bubble — either needs the person's socket.
|
|
114
|
+
if (!config.identity || (config.ring === false && !config.chat)) return;
|
|
101
115
|
started = true;
|
|
102
116
|
stopped = false;
|
|
103
117
|
warmUp(config.host);
|
|
@@ -147,7 +161,7 @@ async function connect(): Promise<void> {
|
|
|
147
161
|
tokenless = 0;
|
|
148
162
|
let res: Response;
|
|
149
163
|
try {
|
|
150
|
-
res = await post("ingest/live-ticket", token);
|
|
164
|
+
res = await post(getConfig().chat ? "ingest/live-ticket?chat=1" : "ingest/live-ticket", token);
|
|
151
165
|
} catch {
|
|
152
166
|
return schedule();
|
|
153
167
|
}
|
|
@@ -164,11 +178,12 @@ async function connect(): Promise<void> {
|
|
|
164
178
|
return schedule();
|
|
165
179
|
}
|
|
166
180
|
// Calls off for this desk: no socket at all.
|
|
167
|
-
|
|
181
|
+
// Calls off and no chat asked for: no socket at all. Calls off with the chat's bubble: the socket, no ring.
|
|
182
|
+
if (!out.ticket) {
|
|
168
183
|
stopped = true;
|
|
169
184
|
return;
|
|
170
185
|
}
|
|
171
|
-
if (out.call) hear(out.call);
|
|
186
|
+
if (out.calls && out.call && ringOn()) hear(out.call);
|
|
172
187
|
const ws = new WebSocket(liveSocketUrl(getConfig().host, out.ticket));
|
|
173
188
|
socket = ws;
|
|
174
189
|
ws.onopen = () => {
|
|
@@ -183,7 +198,13 @@ async function connect(): Promise<void> {
|
|
|
183
198
|
ws.onmessage = (e) => {
|
|
184
199
|
try {
|
|
185
200
|
const frame = JSON.parse(String(e.data)) as { type?: string; call?: RingingCall | string; op?: unknown };
|
|
186
|
-
if (frame.type === "
|
|
201
|
+
if (frame.type === "chat-changed" || frame.type === "ticket-changed" || frame.type === "chat-seen") {
|
|
202
|
+
for (const fn of frameListeners) fn(frame as LiveFrame);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (frame.type === "call-state" && frame.call && typeof frame.call === "object") {
|
|
206
|
+
if (ringOn()) hear(frame.call);
|
|
207
|
+
}
|
|
187
208
|
// The team's marks on this page, shared from the portal's call (§21) — read again, never drawn as received.
|
|
188
209
|
else if (frame.type === "call-ink" && typeof frame.call === "string") {
|
|
189
210
|
const op = readInkOp(frame.op);
|
package/src/server.ts
CHANGED
|
@@ -34,11 +34,15 @@ export interface SupportProfileFact {
|
|
|
34
34
|
value: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Whoever the host has authenticated. Email is the account key — or, for someone the host knows but holds no email
|
|
39
|
+
* for (a CIB officer with none in the ERP, 2026-10-27: every report they ever sent landed anonymous, nobody could call
|
|
40
|
+
* them back), the host's own id alone.
|
|
41
|
+
*/
|
|
38
42
|
export interface SupportUser {
|
|
39
|
-
/** The host's own id for this person, recorded so a changed email still matches. */
|
|
43
|
+
/** The host's own id for this person, recorded so a changed email still matches — the key when there is no email. */
|
|
40
44
|
id?: string | null;
|
|
41
|
-
email
|
|
45
|
+
email?: string | null;
|
|
42
46
|
name?: string | null;
|
|
43
47
|
/** What the host knows about their work — most important first. Optional; bounded on the way in. */
|
|
44
48
|
profile?: SupportProfileFact[] | null;
|
|
@@ -95,8 +99,9 @@ export function createSupportToken(options: {
|
|
|
95
99
|
user: SupportUser;
|
|
96
100
|
ttlSeconds?: number;
|
|
97
101
|
}): { token: string; expiresIn: number } {
|
|
98
|
-
const email = options.user.email?.trim().toLowerCase();
|
|
99
|
-
|
|
102
|
+
const email = options.user.email?.trim().toLowerCase() || null;
|
|
103
|
+
const id = options.user.id?.trim() || null;
|
|
104
|
+
if (email ? !email.includes("@") : !id) throw new Error("createSupportToken: a real email, or the host's own id for this person, is required");
|
|
100
105
|
if (!options.secret || options.secret.length < 16) {
|
|
101
106
|
throw new Error("createSupportToken: the realm's reporter secret is missing or too short");
|
|
102
107
|
}
|
|
@@ -105,9 +110,10 @@ export function createSupportToken(options: {
|
|
|
105
110
|
const expiresIn = Math.min(Math.max(Math.floor(options.ttlSeconds ?? DEFAULT_TTL_SECONDS), 30), 12 * 60 * 60);
|
|
106
111
|
const body = `${b64({ alg: "HS256", typ: "JWT" })}.${b64({
|
|
107
112
|
// The host's own id when it has one, so the same person is recognised even
|
|
108
|
-
// if their email later changes. The email stands in when it does not
|
|
109
|
-
|
|
110
|
-
email,
|
|
113
|
+
// if their email later changes. The email stands in when it does not; with
|
|
114
|
+
// no email the id is the whole of who they are, and the claim is left out.
|
|
115
|
+
sub: id ?? email,
|
|
116
|
+
...(email ? { email } : {}),
|
|
111
117
|
name: options.user.name?.trim() || null,
|
|
112
118
|
// Only when there is one: an older support desk ignores the claim, a newer one records it.
|
|
113
119
|
...(cleanProfile(options.user.profile) ? { profile: cleanProfile(options.user.profile) } : {}),
|
|
@@ -184,7 +190,8 @@ export function supportTokenHandler(options: {
|
|
|
184
190
|
}
|
|
185
191
|
|
|
186
192
|
const user = await options.user();
|
|
187
|
-
|
|
193
|
+
// Nobody — or somebody the host cannot name by an email or an id of its own.
|
|
194
|
+
if (!user?.email?.trim() && !user?.id?.trim()) return json({ error: "Unauthorized" }, 401);
|
|
188
195
|
|
|
189
196
|
try {
|
|
190
197
|
const { token, expiresIn } = createSupportToken({ secret, user, ttlSeconds: options.ttlSeconds });
|
package/src/submit.ts
CHANGED
|
@@ -182,6 +182,8 @@ export async function submitBundle(title: string, bundle: DebugBundle): Promise<
|
|
|
182
182
|
...(replay ? { replay } : {}),
|
|
183
183
|
},
|
|
184
184
|
attachments,
|
|
185
|
+
// From แชทกับทีม's panel: linked to the person's chat with the desk (a known person only — the desk checks).
|
|
186
|
+
...(trimmed.reason.fromChat ? { fromChat: true } : {}),
|
|
185
187
|
};
|
|
186
188
|
|
|
187
189
|
// The widget ingest mounts at the ROOT of the support app, beside the
|
package/src/types.ts
CHANGED
|
@@ -88,6 +88,8 @@ export interface DebugReason {
|
|
|
88
88
|
type: DebugReasonType;
|
|
89
89
|
message?: string;
|
|
90
90
|
stack?: string;
|
|
91
|
+
/** Opened from แชทกับทีม's panel (แจ้งปัญหาพร้อมภาพหน้าจอ): the report is linked to the person's chat. */
|
|
92
|
+
fromChat?: boolean;
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/** A failed data-layer query the host chose to report (react-query, Apollo, …). */
|
package/src/ui/callFrame.ts
CHANGED
|
@@ -357,5 +357,5 @@ function place(): void {
|
|
|
357
357
|
if (placed && !narrow) {
|
|
358
358
|
const p = clampTo(placed, box, viewOf());
|
|
359
359
|
Object.assign(s, { left: `${p.x}px`, top: `${p.y}px`, right: "", bottom: "" });
|
|
360
|
-
} else Object.assign(s, { left: narrow ? `${MARGIN / 2}px` : "", top: "", right: narrow ? "" : `${MARGIN / 2}px`, bottom:
|
|
360
|
+
} else Object.assign(s, { left: narrow ? `${MARGIN / 2}px` : "", top: "", right: narrow ? "" : `${MARGIN / 2}px`, bottom: `calc(${MARGIN / 2}px + var(--lw-chat-lift, 0px))` });
|
|
361
361
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* แชทกับทีม — the chat bubble (the support desk's docs/SUPPORT-CHAT-PLAN.md phase 4). `chat` in the config puts a round
|
|
3
|
+
* button at the bottom right of the host app; pressed, a panel above it: the support host's own page
|
|
4
|
+
* (`/embed/support`), framed — the person's chat with the desk and their เรื่อง, each thread in the panel. One chat,
|
|
5
|
+
* one thread, one editor for the bubble and the portal alike: nothing of it is drawn here but the button and the box.
|
|
6
|
+
*
|
|
7
|
+
* the badge what still waits for the person — the desk's lines in the chat and their เรื่อง's replies — read with
|
|
8
|
+
* the host's token before the panel was ever opened (/ingest/support-unread), again when the person's
|
|
9
|
+
* socket says the desk wrote (live.ts `onLiveFrame`), and from the panel itself while it is open
|
|
10
|
+
* sign-in the panel asks (`hello`); the host's token goes to it by postMessage, to the support host's origin only
|
|
11
|
+
* แจ้งปัญหา… in the panel: the panel folds and this library's report opens — its screen, its page — linked to the chat
|
|
12
|
+
* the corner the call's card and frame sit above the bubble (`--lw-chat-lift`)
|
|
13
|
+
*
|
|
14
|
+
* Shown only for a signed-in person (`identity` answers a token): a chat is someone's, and is read back.
|
|
15
|
+
*/
|
|
16
|
+
import { chatPlace, getConfig } from "../config";
|
|
17
|
+
import { hostToken, onLiveFrame } from "../live";
|
|
18
|
+
import { launch } from "../reporter";
|
|
19
|
+
import { strings } from "./strings";
|
|
20
|
+
|
|
21
|
+
const HOST_TAG = "lw-support-chat";
|
|
22
|
+
const FRAME = "lw-support-frame";
|
|
23
|
+
const HOST = "lw-support-host";
|
|
24
|
+
const SIZE = 56;
|
|
25
|
+
const GAP = 12;
|
|
26
|
+
|
|
27
|
+
const ICON = `<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>`;
|
|
28
|
+
|
|
29
|
+
const CSS = `
|
|
30
|
+
:host { all: initial; }
|
|
31
|
+
.bubble {
|
|
32
|
+
position: fixed; z-index: 2147483000; width: ${SIZE}px; height: ${SIZE}px; border-radius: 50%;
|
|
33
|
+
display: grid; place-items: center; border: 0; cursor: pointer; padding: 0;
|
|
34
|
+
background: #00468f; color: #fff; box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 4px 10px -4px rgba(0, 0, 0, 0.2);
|
|
35
|
+
transition: transform 150ms ease, background 150ms ease;
|
|
36
|
+
font-family: "Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
|
|
37
|
+
}
|
|
38
|
+
.bubble:hover { background: #003a78; transform: translateY(-1px); }
|
|
39
|
+
.bubble:focus-visible { outline: 3px solid rgba(0, 70, 143, 0.35); outline-offset: 3px; }
|
|
40
|
+
.badge {
|
|
41
|
+
position: absolute; top: -2px; right: -2px; min-width: 20px; height: 20px; padding: 0 5px; box-sizing: border-box;
|
|
42
|
+
border-radius: 10px; background: #dc2626; color: #fff; font: 700 11px/20px "Inter Variable", -apple-system, sans-serif;
|
|
43
|
+
text-align: center; box-shadow: 0 0 0 2px #fff;
|
|
44
|
+
}
|
|
45
|
+
.badge[hidden] { display: none; }
|
|
46
|
+
.panel {
|
|
47
|
+
position: fixed; z-index: 2147483001; width: min(380px, calc(100vw - 32px)); height: min(640px, calc(100vh - ${SIZE + GAP + 32}px));
|
|
48
|
+
border-radius: 16px; overflow: hidden; background: #fff; border: 1px solid #e8e8ec;
|
|
49
|
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.3); animation: in 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
50
|
+
}
|
|
51
|
+
.panel[hidden] { display: none; }
|
|
52
|
+
.panel iframe { display: block; width: 100%; height: 100%; border: 0; background: #fff; }
|
|
53
|
+
@keyframes in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
|
|
54
|
+
@media (max-width: 639px) {
|
|
55
|
+
.panel { inset: 0 !important; width: 100vw; height: 100dvh; border-radius: 0; border: 0; }
|
|
56
|
+
.open .bubble { display: none; }
|
|
57
|
+
}
|
|
58
|
+
@media (prefers-reduced-motion: reduce) { .panel { animation: none; } .bubble { transition: none; } }
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
let root: HTMLElement | null = null;
|
|
62
|
+
let shadow: ShadowRoot | null = null;
|
|
63
|
+
let panel: HTMLDivElement | null = null;
|
|
64
|
+
let frame: HTMLIFrameElement | null = null;
|
|
65
|
+
let bubble: HTMLButtonElement | null = null;
|
|
66
|
+
let badge: HTMLSpanElement | null = null;
|
|
67
|
+
let isOpen = false;
|
|
68
|
+
let unread = 0;
|
|
69
|
+
|
|
70
|
+
const base = (host: string) => (host.endsWith("/") ? host : `${host}/`);
|
|
71
|
+
const hostOrigin = () => {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(getConfig().host).origin;
|
|
74
|
+
} catch {
|
|
75
|
+
return "*";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The panel's address: the support host's page, told the realm, signing in by hand-off (no token in the address). */
|
|
80
|
+
export function supportFrameUrl(host: string, realmKey: string): string {
|
|
81
|
+
const url = new URL("embed/support", base(host));
|
|
82
|
+
url.searchParams.set("realm", realmKey);
|
|
83
|
+
url.searchParams.set("hand", "1");
|
|
84
|
+
return url.toString();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The badge's number: `99+` past 99; nothing at 0. Pure, for the tests. */
|
|
88
|
+
export function badgeText(n: number): string {
|
|
89
|
+
return n <= 0 ? "" : n > 99 ? "99+" : String(n);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function setBadge(n: number): void {
|
|
93
|
+
unread = Math.max(0, Math.floor(n) || 0);
|
|
94
|
+
if (!badge || !bubble) return;
|
|
95
|
+
const t = strings(getConfig().locale);
|
|
96
|
+
badge.textContent = badgeText(unread);
|
|
97
|
+
badge.hidden = unread === 0;
|
|
98
|
+
bubble.setAttribute("aria-label", unread ? `${t.chatOpen} · ${t.chatUnread.replace("{n}", String(unread))}` : t.chatOpen);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let asking: Promise<void> | null = null;
|
|
102
|
+
/** What waits for the person, read with the host's token — no token: nobody is signed in, and there is no bubble. */
|
|
103
|
+
async function refreshUnread(): Promise<void> {
|
|
104
|
+
asking ??= (async () => {
|
|
105
|
+
try {
|
|
106
|
+
const token = await hostToken();
|
|
107
|
+
if (!token) return void show(false);
|
|
108
|
+
const config = getConfig();
|
|
109
|
+
const res = await fetch(new URL("ingest/support-unread", base(config.host)).toString(), { headers: { "x-realm-key": config.realmKey, authorization: `Bearer ${token}` } });
|
|
110
|
+
// A support host from before the chat, or a token it does not accept: no bubble rather than one that cannot open.
|
|
111
|
+
if (res.status === 404 || res.status === 401 || res.status === 403) return void show(false);
|
|
112
|
+
if (!res.ok) return;
|
|
113
|
+
show(true);
|
|
114
|
+
const out = (await res.json()) as { total?: number };
|
|
115
|
+
if (!isOpen) setBadge(typeof out.total === "number" ? out.total : 0);
|
|
116
|
+
} catch {
|
|
117
|
+
/* the next nudge asks again */
|
|
118
|
+
} finally {
|
|
119
|
+
asking = null;
|
|
120
|
+
}
|
|
121
|
+
})();
|
|
122
|
+
return asking;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function show(on: boolean): void {
|
|
126
|
+
if (!root) return;
|
|
127
|
+
root.style.display = on ? "" : "none";
|
|
128
|
+
// The call's card and frame sit above the bubble while it is there (ring.ts, callFrame.ts).
|
|
129
|
+
const place = chatPlace(getConfig().chat);
|
|
130
|
+
if (on && place?.side === "right") document.documentElement.style.setProperty("--lw-chat-lift", `${SIZE + GAP}px`);
|
|
131
|
+
else document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function post(message: Record<string, unknown>): void {
|
|
135
|
+
frame?.contentWindow?.postMessage({ source: HOST, ...message }, hostOrigin());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function onFrame(e: MessageEvent): Promise<void> {
|
|
139
|
+
if (!frame || e.source !== frame.contentWindow) return;
|
|
140
|
+
const d = e.data as { source?: unknown; type?: unknown; total?: unknown } | null;
|
|
141
|
+
if (!d || d.source !== FRAME) return;
|
|
142
|
+
if (d.type === "hello") {
|
|
143
|
+
const token = await hostToken();
|
|
144
|
+
if (token) post({ type: "token", token });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (d.type === "ready") return post({ type: isOpen ? "shown" : "hidden" });
|
|
148
|
+
if (d.type === "close") return closeSupportChat();
|
|
149
|
+
if (d.type === "unread" && typeof d.total === "number") return setBadge(d.total);
|
|
150
|
+
if (d.type === "report") {
|
|
151
|
+
// The panel folds, and the report opens over the page it is about — linked to the chat (ingest `fromChat`).
|
|
152
|
+
closeSupportChat(false);
|
|
153
|
+
launch({ type: "manual", fromChat: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const onMessage = (e: MessageEvent) => void onFrame(e);
|
|
158
|
+
|
|
159
|
+
function onKey(e: KeyboardEvent): void {
|
|
160
|
+
if (isOpen && e.key === "Escape") closeSupportChat();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Open the panel — from the bubble, or from the host app's own button (`openSupportChat`). */
|
|
164
|
+
export function openSupportChat(): void {
|
|
165
|
+
if (!root || !panel || !shadow) mountSupportChat();
|
|
166
|
+
if (!panel) return;
|
|
167
|
+
const config = getConfig();
|
|
168
|
+
if (!frame) {
|
|
169
|
+
frame = document.createElement("iframe");
|
|
170
|
+
frame.src = supportFrameUrl(config.host, config.realmKey);
|
|
171
|
+
frame.title = strings(config.locale).chatTitle;
|
|
172
|
+
frame.setAttribute("allow", "clipboard-write");
|
|
173
|
+
panel.append(frame);
|
|
174
|
+
}
|
|
175
|
+
isOpen = true;
|
|
176
|
+
panel.hidden = false;
|
|
177
|
+
shadow?.querySelector(".wrap")?.classList.add("open");
|
|
178
|
+
bubble?.setAttribute("aria-expanded", "true");
|
|
179
|
+
post({ type: "shown" });
|
|
180
|
+
frame.focus();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Fold the panel — it stays loaded, so the chat is there as it was the next time. */
|
|
184
|
+
export function closeSupportChat(refocus = true): void {
|
|
185
|
+
if (!panel) return;
|
|
186
|
+
isOpen = false;
|
|
187
|
+
panel.hidden = true;
|
|
188
|
+
shadow?.querySelector(".wrap")?.classList.remove("open");
|
|
189
|
+
bubble?.setAttribute("aria-expanded", "false");
|
|
190
|
+
post({ type: "hidden" });
|
|
191
|
+
if (refocus) bubble?.focus();
|
|
192
|
+
void refreshUnread();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let stopFrames: (() => void) | null = null;
|
|
196
|
+
let lastFocusAsk = 0;
|
|
197
|
+
function onFocus(): void {
|
|
198
|
+
// A person coming back to the tab: what came while they were away — at most twice a minute.
|
|
199
|
+
if (Date.now() - lastFocusAsk < 30_000) return;
|
|
200
|
+
lastFocusAsk = Date.now();
|
|
201
|
+
void refreshUnread();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The bubble, once — hidden until the host's token says somebody is signed in. */
|
|
205
|
+
export function mountSupportChat(): void {
|
|
206
|
+
if (root || typeof document === "undefined") return;
|
|
207
|
+
const config = getConfig();
|
|
208
|
+
const place = chatPlace(config.chat);
|
|
209
|
+
if (!place || !config.identity) return;
|
|
210
|
+
const t = strings(config.locale);
|
|
211
|
+
root = document.createElement(HOST_TAG);
|
|
212
|
+
root.style.display = "none";
|
|
213
|
+
shadow = root.attachShadow({ mode: "open" });
|
|
214
|
+
const side = place.side;
|
|
215
|
+
shadow.innerHTML = `<style>${CSS}</style><div class="wrap"><div class="panel" role="dialog" aria-label="${t.chatTitle}" hidden></div><button type="button" class="bubble" aria-expanded="false" aria-haspopup="dialog" aria-label="${t.chatOpen}" title="${t.chatOpen}">${ICON}<span class="badge" hidden></span></button></div>`;
|
|
216
|
+
panel = shadow.querySelector(".panel");
|
|
217
|
+
bubble = shadow.querySelector(".bubble");
|
|
218
|
+
badge = shadow.querySelector(".badge");
|
|
219
|
+
Object.assign(bubble!.style, { [side]: `${place.x}px`, bottom: `${place.y}px` });
|
|
220
|
+
Object.assign(panel!.style, { [side]: `${place.x}px`, bottom: `${place.y + SIZE + GAP}px` });
|
|
221
|
+
bubble!.addEventListener("click", () => (isOpen ? closeSupportChat() : openSupportChat()));
|
|
222
|
+
document.body.append(root);
|
|
223
|
+
window.addEventListener("message", onMessage);
|
|
224
|
+
document.addEventListener("keydown", onKey);
|
|
225
|
+
window.addEventListener("focus", onFocus);
|
|
226
|
+
// The desk wrote, or opened a เรื่อง, or read: the badge asks again — while the panel is open, the panel tells it.
|
|
227
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
228
|
+
stopFrames = onLiveFrame(() => {
|
|
229
|
+
if (isOpen) return;
|
|
230
|
+
clearTimeout(settle);
|
|
231
|
+
settle = setTimeout(() => void refreshUnread(), 500);
|
|
232
|
+
});
|
|
233
|
+
void refreshUnread();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Take it away — for a host that unmounts, and for the tests. */
|
|
237
|
+
export function unmountSupportChat(): void {
|
|
238
|
+
stopFrames?.();
|
|
239
|
+
stopFrames = null;
|
|
240
|
+
window.removeEventListener("message", onMessage);
|
|
241
|
+
document.removeEventListener("keydown", onKey);
|
|
242
|
+
window.removeEventListener("focus", onFocus);
|
|
243
|
+
root?.remove();
|
|
244
|
+
root = shadow = panel = frame = bubble = badge = null;
|
|
245
|
+
isOpen = false;
|
|
246
|
+
document.documentElement.style.removeProperty("--lw-chat-lift");
|
|
247
|
+
}
|
package/src/ui/ring.ts
CHANGED
|
@@ -26,14 +26,14 @@ const HOST_TAG = "lw-call-ring";
|
|
|
26
26
|
const CSS = `
|
|
27
27
|
:host { all: initial; }
|
|
28
28
|
.card {
|
|
29
|
-
position: fixed; right: 16px; bottom: 16px; z-index: 2147483002;
|
|
29
|
+
position: fixed; right: 16px; bottom: calc(16px + var(--lw-chat-lift, 0px)); z-index: 2147483002;
|
|
30
30
|
width: min(352px, calc(100vw - 32px)); box-sizing: border-box;
|
|
31
31
|
padding: 16px; border-radius: 16px; background: #fff; color: #1a1a1e;
|
|
32
32
|
border: 1px solid #e8e8ec; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
33
33
|
font-family: "Inter Variable", "Noto Sans Thai Variable", "IBM Plex Sans Thai", -apple-system, "Segoe UI", Roboto, "Noto Sans Thai", sans-serif;
|
|
34
34
|
animation: in 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
35
35
|
}
|
|
36
|
-
@media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: 8px; width: auto; } }
|
|
36
|
+
@media (max-width: 639px) { .card { right: 8px; left: 8px; bottom: calc(8px + var(--lw-chat-lift, 0px)); width: auto; } }
|
|
37
37
|
.head { display: flex; align-items: center; gap: 12px; }
|
|
38
38
|
.icon {
|
|
39
39
|
position: relative; flex: none; width: 48px; height: 48px; border-radius: 50%;
|