@usero/sdk 1.1.11 → 1.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugins/session-replay.cjs +10 -3
- package/dist/plugins/session-replay.cjs.map +1 -1
- package/dist/plugins/session-replay.d.cts +1 -0
- package/dist/plugins/session-replay.d.ts +1 -0
- package/dist/plugins/session-replay.js +10 -3
- package/dist/plugins/session-replay.js.map +1 -1
- package/dist/plugins/user-test.cjs +613 -248
- package/dist/plugins/user-test.cjs.map +1 -1
- package/dist/plugins/user-test.d.cts +53 -2
- package/dist/plugins/user-test.d.ts +53 -2
- package/dist/plugins/user-test.js +613 -248
- package/dist/plugins/user-test.js.map +1 -1
- package/dist/react.cjs +11 -1
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +11 -1
- package/dist/react.js.map +1 -1
- package/dist/usero.iife.js +20 -20
- package/dist/usero.iife.js.map +1 -1
- package/dist/vanilla.cjs +26 -1
- package/dist/vanilla.cjs.map +1 -1
- package/dist/vanilla.d.cts +41 -1
- package/dist/vanilla.d.ts +41 -1
- package/dist/vanilla.js +26 -2
- package/dist/vanilla.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// src/types.ts
|
|
4
4
|
var DEFAULT_API_URL = "https://usero.io";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
6
|
+
// src/identity.ts
|
|
7
|
+
function isValidSdkSessionId(id) {
|
|
8
|
+
return /^[a-z0-9-]{8,}$/i.test(id);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/plugins/user-test/shared.ts
|
|
7
12
|
var DEFAULT_OPTIONS = {
|
|
8
13
|
queryParam: "usero_test",
|
|
9
14
|
// 10s (not 30) so at most ~10s of audio is at risk if the tab is torn
|
|
@@ -18,8 +23,96 @@ var DEFAULT_OPTIONS = {
|
|
|
18
23
|
};
|
|
19
24
|
var TESTER_NAME_STORAGE_KEY = "usero:user-test:tester-name";
|
|
20
25
|
var TASKS_PANEL_OPEN_STORAGE_KEY = "usero:user-test:tasks-panel-open";
|
|
26
|
+
var ACTIVE_SESSION_STORAGE_KEY = "usero:user-test:active-session";
|
|
27
|
+
var ACTIVE_SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1e3;
|
|
28
|
+
var RESUME_MAX_IDLE_MS = 30 * 60 * 1e3;
|
|
21
29
|
var IDB_NAME = "usero-user-test";
|
|
22
30
|
var IDB_STORE = "pending-chunks";
|
|
31
|
+
var SILENCE_RMS_DB_THRESHOLD = -60;
|
|
32
|
+
var SILENCE_FLOOR_DB = -100;
|
|
33
|
+
var SILENCE_SUSTAINED_MS = 1800;
|
|
34
|
+
var SILENCE_POLL_MS = 250;
|
|
35
|
+
var MIC_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 8 0M8 11.5v3M5.5 14.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
36
|
+
var MIC_MUTED_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v3.2L10 11V3.5a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 6.5 3.12M12 7.5a4 4 0 0 1-.3 1.5M8 11.5v3M5.5 14.5h5M2 2l12 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
37
|
+
var NOTE_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M3 3.5A1.5 1.5 0 0 1 4.5 2h7A1.5 1.5 0 0 1 13 3.5V10a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V3.5A1.5 1.5 0 0 1 3.5 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
38
|
+
var TICK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 12.5 10 17.5 19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
39
|
+
var TICK_SM_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5 6.5 11.5 12.5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
40
|
+
var CLOCK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="8.4" stroke="currentColor" stroke-width="2"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
41
|
+
var SPARK_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1.5 9.5 6.5 14.5 8 9.5 9.5 8 14.5 6.5 9.5 1.5 8 6.5 6.5Z" fill="currentColor"/></svg>`;
|
|
42
|
+
var FLAG_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 21V4M6 4.5h9.5l-1.6 3.2 1.6 3.2H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
43
|
+
|
|
44
|
+
// src/plugins/user-test/session.ts
|
|
45
|
+
function parseActiveSession(raw) {
|
|
46
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
47
|
+
const s = raw;
|
|
48
|
+
if (typeof s.slug !== "string" || !s.slug) return null;
|
|
49
|
+
if (typeof s.sessionId !== "string" || !s.sessionId) return null;
|
|
50
|
+
if (typeof s.nextChunkIndex !== "number" || !Number.isInteger(s.nextChunkIndex) || s.nextChunkIndex < 0) return null;
|
|
51
|
+
if (typeof s.startedAt !== "number" || !Number.isFinite(s.startedAt)) return null;
|
|
52
|
+
const status = s.status === "paused" ? "paused" : "active";
|
|
53
|
+
const result = {
|
|
54
|
+
slug: s.slug,
|
|
55
|
+
sessionId: s.sessionId,
|
|
56
|
+
nextChunkIndex: s.nextChunkIndex,
|
|
57
|
+
startedAt: s.startedAt,
|
|
58
|
+
status
|
|
59
|
+
};
|
|
60
|
+
if (typeof s.sdkSessionId === "string" && isValidSdkSessionId(s.sdkSessionId)) {
|
|
61
|
+
result.sdkSessionId = s.sdkSessionId;
|
|
62
|
+
}
|
|
63
|
+
if (typeof s.pausedAt === "number" && Number.isFinite(s.pausedAt)) {
|
|
64
|
+
result.pausedAt = s.pausedAt;
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
function readActiveSession() {
|
|
69
|
+
try {
|
|
70
|
+
const raw = window.localStorage?.getItem(ACTIVE_SESSION_STORAGE_KEY);
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
const parsed = parseActiveSession(JSON.parse(raw));
|
|
73
|
+
if (!parsed) return null;
|
|
74
|
+
if (Date.now() - parsed.startedAt > ACTIVE_SESSION_MAX_AGE_MS) {
|
|
75
|
+
clearActiveSession();
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (parsed.status === "paused" && typeof parsed.pausedAt === "number") {
|
|
79
|
+
if (Date.now() - parsed.pausedAt > RESUME_MAX_IDLE_MS) {
|
|
80
|
+
clearActiveSession();
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function writeActiveSession(state) {
|
|
90
|
+
try {
|
|
91
|
+
window.localStorage?.setItem(ACTIVE_SESSION_STORAGE_KEY, JSON.stringify(state));
|
|
92
|
+
} catch {
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function clearActiveSession() {
|
|
96
|
+
try {
|
|
97
|
+
window.localStorage?.removeItem(ACTIVE_SESSION_STORAGE_KEY);
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function persistActiveSession(store, status) {
|
|
102
|
+
if (!store.sessionId) return;
|
|
103
|
+
const state = {
|
|
104
|
+
slug: store.slug,
|
|
105
|
+
sessionId: store.sessionId,
|
|
106
|
+
nextChunkIndex: store.chunkIndex,
|
|
107
|
+
startedAt: store.startedAt,
|
|
108
|
+
status
|
|
109
|
+
};
|
|
110
|
+
if (store.sdkSessionId) state.sdkSessionId = store.sdkSessionId;
|
|
111
|
+
if (status === "paused") {
|
|
112
|
+
state.pausedAt = Date.now();
|
|
113
|
+
}
|
|
114
|
+
writeActiveSession(state);
|
|
115
|
+
}
|
|
23
116
|
function readTesterName(override) {
|
|
24
117
|
if (override) return override;
|
|
25
118
|
try {
|
|
@@ -55,129 +148,222 @@ function getTestSlug(queryParam) {
|
|
|
55
148
|
return null;
|
|
56
149
|
}
|
|
57
150
|
}
|
|
58
|
-
function
|
|
59
|
-
|
|
151
|
+
function parseTasks(raw) {
|
|
152
|
+
if (!Array.isArray(raw)) return [];
|
|
153
|
+
const out = raw.flatMap((item) => {
|
|
154
|
+
const t = item;
|
|
155
|
+
if (!t || typeof t.id !== "string" || typeof t.prompt !== "string" || typeof t.sortOrder !== "number") return [];
|
|
156
|
+
return [{ id: t.id, prompt: t.prompt, sortOrder: t.sortOrder }];
|
|
157
|
+
});
|
|
158
|
+
out.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
159
|
+
return out;
|
|
60
160
|
}
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
161
|
+
async function createSession(apiUrl, slug, testerName) {
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ slug, ...testerName ? { testerName } : {} })
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) return null;
|
|
169
|
+
const json = await res.json();
|
|
170
|
+
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
171
|
+
return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
67
174
|
}
|
|
68
|
-
return void 0;
|
|
69
175
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
};
|
|
84
|
-
req.onsuccess = () => resolve(req.result);
|
|
85
|
-
req.onerror = () => resolve(null);
|
|
86
|
-
} catch {
|
|
87
|
-
resolve(null);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
176
|
+
async function adoptSession(apiUrl, sessionId) {
|
|
177
|
+
try {
|
|
178
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/adopt`, {
|
|
179
|
+
method: "GET"
|
|
180
|
+
});
|
|
181
|
+
if (res.status === 409 || res.status === 410) return { kind: "closed" };
|
|
182
|
+
if (!res.ok) return { kind: "error" };
|
|
183
|
+
const json = await res.json();
|
|
184
|
+
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return { kind: "error" };
|
|
185
|
+
return { kind: "ok", sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
186
|
+
} catch {
|
|
187
|
+
return { kind: "error" };
|
|
188
|
+
}
|
|
90
189
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
resolve();
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
db.close();
|
|
190
|
+
function parsePaymentSummary(raw) {
|
|
191
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
192
|
+
const p = raw;
|
|
193
|
+
if (typeof p.qualified !== "boolean") return null;
|
|
194
|
+
return {
|
|
195
|
+
qualified: p.qualified,
|
|
196
|
+
reward: typeof p.reward === "string" ? p.reward : null,
|
|
197
|
+
payoutEmail: typeof p.payoutEmail === "string" ? p.payoutEmail : null,
|
|
198
|
+
tasksDone: typeof p.tasksDone === "number" ? p.tasksDone : 0,
|
|
199
|
+
tasksTotal: typeof p.tasksTotal === "number" ? p.tasksTotal : 0
|
|
200
|
+
};
|
|
106
201
|
}
|
|
107
|
-
async function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
tx.oncomplete = () => resolve();
|
|
115
|
-
tx.onerror = () => resolve();
|
|
116
|
-
tx.onabort = () => resolve();
|
|
117
|
-
} catch {
|
|
118
|
-
resolve();
|
|
202
|
+
async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
|
|
203
|
+
try {
|
|
204
|
+
const body = {
|
|
205
|
+
durationSeconds: Math.max(0, Math.round(durationSeconds))
|
|
206
|
+
};
|
|
207
|
+
if (extras.mutedSegments && extras.mutedSegments.length > 0) {
|
|
208
|
+
body.mutedSegments = extras.mutedSegments;
|
|
119
209
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
210
|
+
const trimmedEndNote = extras.endNote?.trim();
|
|
211
|
+
if (trimmedEndNote) body.endNote = trimmedEndNote;
|
|
212
|
+
if (extras.notes && extras.notes.length > 0) {
|
|
213
|
+
body.notes = extras.notes.slice(0, 200).map((n) => ({
|
|
214
|
+
atMs: Math.max(0, Math.round(n.atMs)),
|
|
215
|
+
text: n.text
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
if (extras.sdkSessionId) body.sdkSessionId = extras.sdkSessionId;
|
|
219
|
+
if (typeof extras.replayOffsetMs === "number") {
|
|
220
|
+
body.replayOffsetMs = Math.max(0, Math.round(extras.replayOffsetMs));
|
|
221
|
+
}
|
|
222
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
headers: { "Content-Type": "application/json" },
|
|
225
|
+
body: JSON.stringify(body),
|
|
226
|
+
keepalive: true
|
|
227
|
+
});
|
|
228
|
+
if (!res.ok) return { ok: false, payment: null };
|
|
229
|
+
let payment = null;
|
|
127
230
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
req.onsuccess = () => {
|
|
131
|
-
const all = req.result ?? [];
|
|
132
|
-
resolve(all.filter((c) => c.sessionId === sessionId));
|
|
133
|
-
};
|
|
134
|
-
req.onerror = () => resolve([]);
|
|
231
|
+
const json = await res.json();
|
|
232
|
+
payment = parsePaymentSummary(json.payment);
|
|
135
233
|
} catch {
|
|
136
|
-
resolve([]);
|
|
137
234
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
235
|
+
return { ok: true, payment };
|
|
236
|
+
} catch {
|
|
237
|
+
return { ok: false, payment: null };
|
|
238
|
+
}
|
|
141
239
|
}
|
|
142
|
-
async function
|
|
143
|
-
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/
|
|
144
|
-
|
|
145
|
-
|
|
240
|
+
async function postPayout(apiUrl, sessionId, destination, logger) {
|
|
241
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/payout`;
|
|
242
|
+
const body = { method: "email" };
|
|
243
|
+
if (destination) body.destination = destination;
|
|
244
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
146
245
|
try {
|
|
147
246
|
const res = await fetch(url, {
|
|
148
|
-
method: "
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
keepalive:
|
|
152
|
-
// browsers cap keepalive bodies
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "Content-Type": "application/json" },
|
|
249
|
+
body: JSON.stringify(body),
|
|
250
|
+
keepalive: true
|
|
153
251
|
});
|
|
154
252
|
if (res.ok) return true;
|
|
155
|
-
if (res.status >= 400 && res.status < 500
|
|
156
|
-
logger.
|
|
253
|
+
if (res.status >= 400 && res.status < 500) {
|
|
254
|
+
logger.warn(`payout rejected with ${res.status}`);
|
|
157
255
|
return false;
|
|
158
256
|
}
|
|
159
257
|
} catch (err) {
|
|
160
|
-
logger.warn(`
|
|
258
|
+
logger.warn(`payout attempt ${attempt + 1} failed`, err);
|
|
161
259
|
}
|
|
162
|
-
|
|
163
|
-
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
164
|
-
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
260
|
+
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
165
261
|
}
|
|
166
262
|
return false;
|
|
167
263
|
}
|
|
264
|
+
async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: { "Content-Type": "application/json" },
|
|
269
|
+
body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
|
|
270
|
+
keepalive: true
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
logger.warn(`note POST rejected with ${res.status}`);
|
|
274
|
+
return { ok: false, transient: res.status >= 500 || res.status === 408 || res.status === 429 };
|
|
275
|
+
}
|
|
276
|
+
let id;
|
|
277
|
+
try {
|
|
278
|
+
const json = await res.json();
|
|
279
|
+
if (typeof json.id === "string") id = json.id;
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, id, transient: false };
|
|
283
|
+
} catch (err) {
|
|
284
|
+
logger.warn("note POST failed", err);
|
|
285
|
+
return { ok: false, transient: true };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function postNoteWithRetry(apiUrl, sessionId, atMs, text, logger) {
|
|
289
|
+
const first = await postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
290
|
+
if (first.ok || !first.transient) return first;
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
292
|
+
return postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/plugins/user-test/ui.ts
|
|
296
|
+
var KEYBOARD_OPEN_MIN_INSET_PX = 80;
|
|
297
|
+
function computeKeyboardInset(innerHeight, viewportHeight, viewportOffsetTop) {
|
|
298
|
+
return Math.max(0, Math.round(innerHeight - (viewportHeight + viewportOffsetTop)));
|
|
299
|
+
}
|
|
300
|
+
function installKeyboardInsetWatcher(anchor) {
|
|
301
|
+
const viewport = window.visualViewport;
|
|
302
|
+
if (!viewport) return null;
|
|
303
|
+
let rafId = 0;
|
|
304
|
+
let lastInset = -1;
|
|
305
|
+
let resizeSeen = false;
|
|
306
|
+
const apply = () => {
|
|
307
|
+
rafId = 0;
|
|
308
|
+
const fromResize = resizeSeen;
|
|
309
|
+
resizeSeen = false;
|
|
310
|
+
const inset = computeKeyboardInset(window.innerHeight, viewport.height, viewport.offsetTop);
|
|
311
|
+
if (inset === lastInset) return;
|
|
312
|
+
lastInset = inset;
|
|
313
|
+
anchor.setAttribute("data-vv-scrolling", fromResize ? "false" : "true");
|
|
314
|
+
anchor.style.setProperty("--keyboard-inset", `${inset}px`);
|
|
315
|
+
anchor.style.setProperty("--vv-height", `${Math.round(viewport.height)}px`);
|
|
316
|
+
anchor.setAttribute("data-keyboard-open", inset >= KEYBOARD_OPEN_MIN_INSET_PX ? "true" : "false");
|
|
317
|
+
};
|
|
318
|
+
const schedule = () => {
|
|
319
|
+
if (rafId === 0) rafId = window.requestAnimationFrame(apply);
|
|
320
|
+
};
|
|
321
|
+
const onResize = () => {
|
|
322
|
+
resizeSeen = true;
|
|
323
|
+
schedule();
|
|
324
|
+
};
|
|
325
|
+
const onScroll = () => {
|
|
326
|
+
schedule();
|
|
327
|
+
};
|
|
328
|
+
viewport.addEventListener("resize", onResize);
|
|
329
|
+
viewport.addEventListener("scroll", onScroll);
|
|
330
|
+
resizeSeen = true;
|
|
331
|
+
apply();
|
|
332
|
+
return () => {
|
|
333
|
+
viewport.removeEventListener("resize", onResize);
|
|
334
|
+
viewport.removeEventListener("scroll", onScroll);
|
|
335
|
+
if (rafId !== 0) window.cancelAnimationFrame(rafId);
|
|
336
|
+
};
|
|
337
|
+
}
|
|
168
338
|
function buildIndicator(host, store, callbacks) {
|
|
169
|
-
const root = host.attachShadow({ mode: "
|
|
339
|
+
const root = host.attachShadow({ mode: "open" });
|
|
170
340
|
const style = document.createElement("style");
|
|
171
341
|
style.textContent = `
|
|
172
342
|
:host { all: initial; }
|
|
173
343
|
.anchor {
|
|
344
|
+
/* --keyboard-inset is the height of the mobile soft keyboard, written by
|
|
345
|
+
the visualViewport watcher (0 when closed / unsupported). position:fixed
|
|
346
|
+
anchors to the LAYOUT viewport, which the keyboard does not shrink, so
|
|
347
|
+
without this the open keyboard covers the bar and task panel entirely. */
|
|
348
|
+
--keyboard-inset: 0px;
|
|
174
349
|
position: fixed;
|
|
175
|
-
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
|
350
|
+
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px + var(--keyboard-inset));
|
|
176
351
|
left: 50%; transform: translateX(-50%);
|
|
177
352
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
|
178
353
|
z-index: 2147483646; max-width: calc(100vw - 32px);
|
|
179
354
|
font: 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
|
180
355
|
color: #fff;
|
|
356
|
+
/* Eased only for keyboard show/hide; scroll ticks suppress it below. */
|
|
357
|
+
transition: bottom 0.18s ease-out;
|
|
358
|
+
}
|
|
359
|
+
/* While the visual viewport is being panned (URL bar collapse, pinch,
|
|
360
|
+
keyboard-driven scroll) the inset must track 1:1, animating every tick
|
|
361
|
+
reads as lag. The watcher flips this attribute per update source. */
|
|
362
|
+
.anchor[data-vv-scrolling="true"] { transition: none; }
|
|
363
|
+
/* Keyboard open: the keyboard covers the home-indicator zone, so the
|
|
364
|
+
safe-area + 16px margin is dead space. Tuck in to an 8px gap instead. */
|
|
365
|
+
.anchor[data-keyboard-open="true"] {
|
|
366
|
+
bottom: calc(var(--keyboard-inset) + 8px);
|
|
181
367
|
}
|
|
182
368
|
.bar {
|
|
183
369
|
display: inline-flex; align-items: center; gap: 6px;
|
|
@@ -200,6 +386,16 @@ function buildIndicator(host, store, callbacks) {
|
|
|
200
386
|
width: max-content; overflow-y: auto;
|
|
201
387
|
}
|
|
202
388
|
.panel[hidden] { display: none; }
|
|
389
|
+
/* Compact state while the keyboard is up: 60vh is measured against the
|
|
390
|
+
LAYOUT viewport and can exceed the visible strip above the keyboard,
|
|
391
|
+
clipping the instructions. Cap against the VISUAL viewport height
|
|
392
|
+
(--vv-height, written by the watcher) minus the bar's footprint, with a
|
|
393
|
+
96px floor so at least a couple of lines stay readable and scrollable.
|
|
394
|
+
Slightly tighter padding to make the most of the scarce space. */
|
|
395
|
+
.anchor[data-keyboard-open="true"] .panel {
|
|
396
|
+
max-height: min(480px, max(96px, calc(var(--vv-height, 100vh) - 96px)));
|
|
397
|
+
padding: 10px 12px 10px 8px;
|
|
398
|
+
}
|
|
203
399
|
.panel ol { margin: 0; padding-left: 26px; }
|
|
204
400
|
.panel li { margin: 0 0 8px; }
|
|
205
401
|
.panel li:last-child { margin: 0; }
|
|
@@ -332,6 +528,28 @@ function buildIndicator(host, store, callbacks) {
|
|
|
332
528
|
to { opacity: 0; transform: translateY(4px); }
|
|
333
529
|
}
|
|
334
530
|
|
|
531
|
+
/* "Recording resumed" confirmation: same pill footprint as the mute toast,
|
|
532
|
+
but carries the live-record red accent (not the amber warning treatment)
|
|
533
|
+
so it reads as reassurance, not a problem. Compact, inline, auto-dismisses.
|
|
534
|
+
Leads with the same pulsing record dot used on the bar's mic chip. */
|
|
535
|
+
.resume-toast {
|
|
536
|
+
display: inline-flex; align-items: center; gap: 8px;
|
|
537
|
+
background: rgba(17,17,17,0.92);
|
|
538
|
+
border: 1px solid rgba(239, 68, 68, 0.42);
|
|
539
|
+
color: #fff; font-weight: 500; letter-spacing: 0.01em;
|
|
540
|
+
padding: 8px 13px; border-radius: 999px;
|
|
541
|
+
box-shadow: 0 12px 28px rgba(0,0,0,0.28);
|
|
542
|
+
white-space: nowrap;
|
|
543
|
+
animation: toast-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
544
|
+
}
|
|
545
|
+
.resume-toast[data-leaving="true"] { animation: toast-out 0.24s ease forwards; }
|
|
546
|
+
.resume-toast .dot {
|
|
547
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
548
|
+
background: #ef4444; flex-shrink: 0;
|
|
549
|
+
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
|
|
550
|
+
animation: pulse 1.6s ease-out infinite;
|
|
551
|
+
}
|
|
552
|
+
|
|
335
553
|
/* Notes popover */
|
|
336
554
|
.note-popover {
|
|
337
555
|
background: rgba(17,17,17,0.94);
|
|
@@ -434,6 +652,12 @@ function buildIndicator(host, store, callbacks) {
|
|
|
434
652
|
color: #ea580c;
|
|
435
653
|
}
|
|
436
654
|
.thanks .check.early svg { width: 24px; height: 24px; }
|
|
655
|
+
.thanks .check.ended {
|
|
656
|
+
background: #f5f5f4;
|
|
657
|
+
box-shadow: inset 0 0 0 1px rgba(120,113,108,0.20);
|
|
658
|
+
color: #78716c;
|
|
659
|
+
}
|
|
660
|
+
.thanks .check.ended svg { width: 24px; height: 24px; }
|
|
437
661
|
|
|
438
662
|
/* Verified-checks list (complete) / progress list (ended early) */
|
|
439
663
|
.thanks .checks {
|
|
@@ -605,11 +829,14 @@ function buildIndicator(host, store, callbacks) {
|
|
|
605
829
|
}
|
|
606
830
|
@media (prefers-reduced-motion: reduce) {
|
|
607
831
|
.dot { animation: none; }
|
|
608
|
-
.toast, .note-popover { animation: none; }
|
|
832
|
+
.toast, .note-popover, .resume-toast { animation: none; }
|
|
833
|
+
.resume-toast[data-leaving="true"] { opacity: 0; }
|
|
834
|
+
.anchor { transition: none; }
|
|
609
835
|
}
|
|
610
836
|
`;
|
|
611
837
|
const anchor = document.createElement("div");
|
|
612
838
|
anchor.className = "anchor";
|
|
839
|
+
store.keyboardWatcherCleanup = installKeyboardInsetWatcher(anchor);
|
|
613
840
|
const panel = document.createElement("div");
|
|
614
841
|
panel.className = "panel";
|
|
615
842
|
panel.hidden = true;
|
|
@@ -670,13 +897,6 @@ function buildIndicator(host, store, callbacks) {
|
|
|
670
897
|
root.appendChild(anchor);
|
|
671
898
|
return root;
|
|
672
899
|
}
|
|
673
|
-
var MIC_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v4a2 2 0 1 0 4 0v-4a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 8 0M8 11.5v3M5.5 14.5h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
674
|
-
var MIC_MUTED_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="13" height="13"><path d="M8 1.5a2 2 0 0 0-2 2v3.2L10 11V3.5a2 2 0 0 0-2-2Z" fill="currentColor"/><path d="M4 7.5a4 4 0 0 0 6.5 3.12M12 7.5a4 4 0 0 1-.3 1.5M8 11.5v3M5.5 14.5h5M2 2l12 12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`;
|
|
675
|
-
var NOTE_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14"><path d="M3 3.5A1.5 1.5 0 0 1 4.5 2h7A1.5 1.5 0 0 1 13 3.5V10a1.5 1.5 0 0 1-1.5 1.5H7L4 14v-2.5h-.5A1.5 1.5 0 0 1 2 10V3.5A1.5 1.5 0 0 1 3.5 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
676
|
-
var TICK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5 12.5 10 17.5 19 7" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
677
|
-
var TICK_SM_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.5 8.5 6.5 11.5 12.5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
678
|
-
var CLOCK_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="8.4" stroke="currentColor" stroke-width="2"/><path d="M12 7.5V12l3 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
|
|
679
|
-
var SPARK_ICON_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 1.5 9.5 6.5 14.5 8 9.5 9.5 8 14.5 6.5 9.5 1.5 8 6.5 6.5Z" fill="currentColor"/></svg>`;
|
|
680
900
|
function installTasksToggle(bar, finishBtn, store, onToggleTasks) {
|
|
681
901
|
const tasksBtn = document.createElement("button");
|
|
682
902
|
tasksBtn.type = "button";
|
|
@@ -854,6 +1074,36 @@ function showMuteToast(store) {
|
|
|
854
1074
|
}, 3e3);
|
|
855
1075
|
store.muteToastTimers.push(outer);
|
|
856
1076
|
}
|
|
1077
|
+
function showResumedToast(store) {
|
|
1078
|
+
if (!store.resumed) return;
|
|
1079
|
+
store.resumed = false;
|
|
1080
|
+
if (!store.hasMicPermission || store.indicatorState === "no-audio") return;
|
|
1081
|
+
const root = store.indicatorRoot;
|
|
1082
|
+
if (!root) return;
|
|
1083
|
+
const slot = root.querySelector(".toast-slot");
|
|
1084
|
+
if (!(slot instanceof HTMLElement)) return;
|
|
1085
|
+
slot.innerHTML = "";
|
|
1086
|
+
const toast = document.createElement("div");
|
|
1087
|
+
toast.className = "resume-toast";
|
|
1088
|
+
toast.setAttribute("role", "status");
|
|
1089
|
+
const dot = document.createElement("span");
|
|
1090
|
+
dot.className = "dot";
|
|
1091
|
+
dot.setAttribute("aria-hidden", "true");
|
|
1092
|
+
const label = document.createElement("span");
|
|
1093
|
+
label.textContent = "Recording resumed";
|
|
1094
|
+
toast.appendChild(dot);
|
|
1095
|
+
toast.appendChild(label);
|
|
1096
|
+
slot.appendChild(toast);
|
|
1097
|
+
const outer = window.setTimeout(() => {
|
|
1098
|
+
if (!toast.isConnected) return;
|
|
1099
|
+
toast.setAttribute("data-leaving", "true");
|
|
1100
|
+
const inner = window.setTimeout(() => {
|
|
1101
|
+
if (toast.isConnected) toast.remove();
|
|
1102
|
+
}, 260);
|
|
1103
|
+
store.resumeToastTimers.push(inner);
|
|
1104
|
+
}, 3200);
|
|
1105
|
+
store.resumeToastTimers.push(outer);
|
|
1106
|
+
}
|
|
857
1107
|
function openNotePopover(store, onSave, onCancel) {
|
|
858
1108
|
const root = store.indicatorRoot;
|
|
859
1109
|
if (!root) return;
|
|
@@ -953,11 +1203,30 @@ function showThanksScreen(root, opts) {
|
|
|
953
1203
|
card.className = "thanks-card";
|
|
954
1204
|
overlay.appendChild(card);
|
|
955
1205
|
root.appendChild(overlay);
|
|
956
|
-
if (opts.payment && !opts.payment.qualified) {
|
|
957
|
-
renderEndedEarly(card, opts);
|
|
958
|
-
return;
|
|
959
|
-
}
|
|
960
|
-
renderComplete(card, opts);
|
|
1206
|
+
if (opts.payment && !opts.payment.qualified) {
|
|
1207
|
+
renderEndedEarly(card, opts);
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
renderComplete(card, opts);
|
|
1211
|
+
}
|
|
1212
|
+
function showSessionEndedScreen(root) {
|
|
1213
|
+
if (root.querySelector(".thanks")) return;
|
|
1214
|
+
const overlay = document.createElement("div");
|
|
1215
|
+
overlay.className = "thanks";
|
|
1216
|
+
overlay.setAttribute("role", "dialog");
|
|
1217
|
+
overlay.setAttribute("aria-modal", "true");
|
|
1218
|
+
const card = document.createElement("div");
|
|
1219
|
+
card.className = "thanks-card";
|
|
1220
|
+
const head = document.createElement("div");
|
|
1221
|
+
head.className = "head";
|
|
1222
|
+
head.innerHTML = `
|
|
1223
|
+
<div class="check ended" aria-hidden="true">${FLAG_ICON_SVG}</div>
|
|
1224
|
+
<h2>This test session ended</h2>
|
|
1225
|
+
<p class="lede">Thanks for taking part. Your earlier responses were saved. You can close this tab.</p>
|
|
1226
|
+
`;
|
|
1227
|
+
card.appendChild(head);
|
|
1228
|
+
overlay.appendChild(card);
|
|
1229
|
+
root.appendChild(overlay);
|
|
961
1230
|
}
|
|
962
1231
|
function checksList(rows) {
|
|
963
1232
|
const items = rows.map((r) => {
|
|
@@ -1208,166 +1477,173 @@ function appendNoteSection(card, opts, prompt) {
|
|
|
1208
1477
|
ta.focus({ preventScroll: true });
|
|
1209
1478
|
});
|
|
1210
1479
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
if (!t || typeof t.id !== "string" || typeof t.prompt !== "string" || typeof t.sortOrder !== "number") return [];
|
|
1216
|
-
return [{ id: t.id, prompt: t.prompt, sortOrder: t.sortOrder }];
|
|
1217
|
-
});
|
|
1218
|
-
out.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
1219
|
-
return out;
|
|
1220
|
-
}
|
|
1221
|
-
async function createSession(apiUrl, slug, testerName) {
|
|
1222
|
-
try {
|
|
1223
|
-
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions`, {
|
|
1224
|
-
method: "POST",
|
|
1225
|
-
headers: { "Content-Type": "application/json" },
|
|
1226
|
-
body: JSON.stringify({ slug, ...testerName ? { testerName } : {} })
|
|
1227
|
-
});
|
|
1228
|
-
if (!res.ok) return null;
|
|
1229
|
-
const json = await res.json();
|
|
1230
|
-
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
1231
|
-
return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
1232
|
-
} catch {
|
|
1233
|
-
return null;
|
|
1234
|
-
}
|
|
1480
|
+
|
|
1481
|
+
// src/plugins/user-test/recorder.ts
|
|
1482
|
+
function isMediaRecorderSupported() {
|
|
1483
|
+
return typeof window !== "undefined" && typeof window.MediaRecorder !== "undefined" && typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia;
|
|
1235
1484
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const json = await res.json();
|
|
1243
|
-
if (typeof json.sessionId !== "string" || typeof json.clientId !== "string") return null;
|
|
1244
|
-
return { sessionId: json.sessionId, clientId: json.clientId, tasks: parseTasks(json.tasks) };
|
|
1245
|
-
} catch {
|
|
1246
|
-
return null;
|
|
1485
|
+
function pickMimeType() {
|
|
1486
|
+
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"];
|
|
1487
|
+
for (const candidate of candidates) {
|
|
1488
|
+
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported?.(candidate)) {
|
|
1489
|
+
return candidate;
|
|
1490
|
+
}
|
|
1247
1491
|
}
|
|
1492
|
+
return void 0;
|
|
1248
1493
|
}
|
|
1249
|
-
function
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
qualified: p.qualified,
|
|
1255
|
-
reward: typeof p.reward === "string" ? p.reward : null,
|
|
1256
|
-
payoutEmail: typeof p.payoutEmail === "string" ? p.payoutEmail : null,
|
|
1257
|
-
tasksDone: typeof p.tasksDone === "number" ? p.tasksDone : 0,
|
|
1258
|
-
tasksTotal: typeof p.tasksTotal === "number" ? p.tasksTotal : 0
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {}) {
|
|
1262
|
-
try {
|
|
1263
|
-
const body = {
|
|
1264
|
-
durationSeconds: Math.max(0, Math.round(durationSeconds))
|
|
1265
|
-
};
|
|
1266
|
-
if (extras.mutedSegments && extras.mutedSegments.length > 0) {
|
|
1267
|
-
body.mutedSegments = extras.mutedSegments;
|
|
1494
|
+
function idbOpen() {
|
|
1495
|
+
return new Promise((resolve) => {
|
|
1496
|
+
if (typeof indexedDB === "undefined") {
|
|
1497
|
+
resolve(null);
|
|
1498
|
+
return;
|
|
1268
1499
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1500
|
+
try {
|
|
1501
|
+
const req = indexedDB.open(IDB_NAME, 1);
|
|
1502
|
+
req.onupgradeneeded = () => {
|
|
1503
|
+
const db = req.result;
|
|
1504
|
+
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
|
1505
|
+
db.createObjectStore(IDB_STORE, { keyPath: "id" });
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
req.onsuccess = () => resolve(req.result);
|
|
1509
|
+
req.onerror = () => resolve(null);
|
|
1510
|
+
} catch {
|
|
1511
|
+
resolve(null);
|
|
1276
1512
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
async function idbStashChunk(chunk) {
|
|
1516
|
+
const db = await idbOpen();
|
|
1517
|
+
if (!db) return;
|
|
1518
|
+
await new Promise((resolve) => {
|
|
1519
|
+
try {
|
|
1520
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
1521
|
+
tx.objectStore(IDB_STORE).put(chunk);
|
|
1522
|
+
tx.oncomplete = () => resolve();
|
|
1523
|
+
tx.onerror = () => resolve();
|
|
1524
|
+
tx.onabort = () => resolve();
|
|
1525
|
+
} catch {
|
|
1526
|
+
resolve();
|
|
1280
1527
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
let payment = null;
|
|
1528
|
+
});
|
|
1529
|
+
db.close();
|
|
1530
|
+
}
|
|
1531
|
+
async function idbDeleteChunk(id) {
|
|
1532
|
+
const db = await idbOpen();
|
|
1533
|
+
if (!db) return;
|
|
1534
|
+
await new Promise((resolve) => {
|
|
1289
1535
|
try {
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1536
|
+
const tx = db.transaction(IDB_STORE, "readwrite");
|
|
1537
|
+
tx.objectStore(IDB_STORE).delete(id);
|
|
1538
|
+
tx.oncomplete = () => resolve();
|
|
1539
|
+
tx.onerror = () => resolve();
|
|
1540
|
+
tx.onabort = () => resolve();
|
|
1292
1541
|
} catch {
|
|
1542
|
+
resolve();
|
|
1293
1543
|
}
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1544
|
+
});
|
|
1545
|
+
db.close();
|
|
1546
|
+
}
|
|
1547
|
+
async function idbListChunks(sessionId) {
|
|
1548
|
+
const db = await idbOpen();
|
|
1549
|
+
if (!db) return [];
|
|
1550
|
+
const items = await new Promise((resolve) => {
|
|
1551
|
+
try {
|
|
1552
|
+
const tx = db.transaction(IDB_STORE, "readonly");
|
|
1553
|
+
const req = tx.objectStore(IDB_STORE).getAll();
|
|
1554
|
+
req.onsuccess = () => {
|
|
1555
|
+
const all = req.result ?? [];
|
|
1556
|
+
resolve(all.filter((c) => c.sessionId === sessionId));
|
|
1557
|
+
};
|
|
1558
|
+
req.onerror = () => resolve([]);
|
|
1559
|
+
} catch {
|
|
1560
|
+
resolve([]);
|
|
1561
|
+
}
|
|
1562
|
+
});
|
|
1563
|
+
db.close();
|
|
1564
|
+
return items;
|
|
1565
|
+
}
|
|
1566
|
+
function classifyChunkResponse(status, body) {
|
|
1567
|
+
if (status >= 200 && status < 300) return "ok";
|
|
1568
|
+
if (status === 409 && typeof body === "object" && body !== null && body.closeResume === true) {
|
|
1569
|
+
return "closed";
|
|
1297
1570
|
}
|
|
1571
|
+
if (status >= 500 || status === 408 || status === 429) return "retry";
|
|
1572
|
+
if (status >= 400) return "fatal";
|
|
1573
|
+
return "retry";
|
|
1298
1574
|
}
|
|
1299
|
-
async function
|
|
1300
|
-
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1575
|
+
async function uploadChunkWithRetry(apiUrl, sessionId, index, blob, logger, maxAttempts = 5) {
|
|
1576
|
+
const url = `${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/chunk?index=${index}`;
|
|
1577
|
+
let attempt = 0;
|
|
1578
|
+
while (attempt < maxAttempts) {
|
|
1304
1579
|
try {
|
|
1305
1580
|
const res = await fetch(url, {
|
|
1306
|
-
method: "
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
keepalive:
|
|
1581
|
+
method: "PUT",
|
|
1582
|
+
body: blob,
|
|
1583
|
+
headers: { "Content-Type": blob.type || "audio/webm" },
|
|
1584
|
+
keepalive: blob.size <= 60 * 1024
|
|
1585
|
+
// browsers cap keepalive bodies
|
|
1310
1586
|
});
|
|
1311
|
-
if (res.ok) return
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1587
|
+
if (res.ok) return "ok";
|
|
1588
|
+
let body = null;
|
|
1589
|
+
try {
|
|
1590
|
+
body = await res.json();
|
|
1591
|
+
} catch {
|
|
1592
|
+
body = null;
|
|
1593
|
+
}
|
|
1594
|
+
const klass = classifyChunkResponse(res.status, body);
|
|
1595
|
+
if (klass === "closed") {
|
|
1596
|
+
logger.info(`chunk ${index}: server reports session closed; stopping`);
|
|
1597
|
+
return "closed";
|
|
1598
|
+
}
|
|
1599
|
+
if (klass === "fatal") {
|
|
1600
|
+
logger.error(`chunk ${index} rejected with ${res.status}`);
|
|
1601
|
+
return "failed";
|
|
1315
1602
|
}
|
|
1316
1603
|
} catch (err) {
|
|
1317
|
-
logger.warn(`
|
|
1318
|
-
}
|
|
1319
|
-
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
1320
|
-
}
|
|
1321
|
-
return false;
|
|
1322
|
-
}
|
|
1323
|
-
async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
|
|
1324
|
-
try {
|
|
1325
|
-
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
|
|
1326
|
-
method: "POST",
|
|
1327
|
-
headers: { "Content-Type": "application/json" },
|
|
1328
|
-
body: JSON.stringify({ atMs: Math.max(0, Math.round(atMs)), text }),
|
|
1329
|
-
keepalive: true
|
|
1330
|
-
});
|
|
1331
|
-
if (!res.ok) {
|
|
1332
|
-
logger.warn(`note POST rejected with ${res.status}`);
|
|
1333
|
-
return { ok: false, transient: res.status >= 500 || res.status === 408 || res.status === 429 };
|
|
1334
|
-
}
|
|
1335
|
-
let id;
|
|
1336
|
-
try {
|
|
1337
|
-
const json = await res.json();
|
|
1338
|
-
if (typeof json.id === "string") id = json.id;
|
|
1339
|
-
} catch {
|
|
1604
|
+
logger.warn(`chunk ${index} upload attempt ${attempt + 1} failed`, err);
|
|
1340
1605
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
return { ok: false, transient: true };
|
|
1606
|
+
attempt += 1;
|
|
1607
|
+
const backoff = Math.min(15e3, 500 * 2 ** attempt) + Math.floor(Math.random() * 250);
|
|
1608
|
+
await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
1345
1609
|
}
|
|
1346
|
-
|
|
1347
|
-
async function postNoteWithRetry(apiUrl, sessionId, atMs, text, logger) {
|
|
1348
|
-
const first = await postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
1349
|
-
if (first.ok || !first.transient) return first;
|
|
1350
|
-
await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
|
|
1351
|
-
return postNoteOnce(apiUrl, sessionId, atMs, text, logger);
|
|
1610
|
+
return "failed";
|
|
1352
1611
|
}
|
|
1353
1612
|
async function flushPendingFromIdb(store, ctx) {
|
|
1354
1613
|
if (!store.sessionId) return;
|
|
1355
1614
|
const pending = await idbListChunks(store.sessionId);
|
|
1356
1615
|
for (const chunk of pending) {
|
|
1357
|
-
const
|
|
1358
|
-
if (ok)
|
|
1616
|
+
const outcome = await uploadChunkWithRetry(chunk.apiUrl, chunk.sessionId, chunk.chunkIndex, chunk.blob, ctx.logger, 3);
|
|
1617
|
+
if (outcome === "ok") {
|
|
1618
|
+
await idbDeleteChunk(chunk.id);
|
|
1619
|
+
} else if (outcome === "closed") {
|
|
1620
|
+
handleSessionClosed(store);
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1359
1623
|
}
|
|
1360
1624
|
}
|
|
1625
|
+
function handleSessionClosed(store) {
|
|
1626
|
+
if (store.sessionClosed) return;
|
|
1627
|
+
store.sessionClosed = true;
|
|
1628
|
+
store.onSessionClosed?.();
|
|
1629
|
+
}
|
|
1361
1630
|
function enqueueChunk(store, ctx, blob) {
|
|
1362
|
-
if (store.cancelled || !store.sessionId || blob.size === 0) return;
|
|
1631
|
+
if (store.cancelled || store.sessionClosed || !store.sessionId || blob.size === 0) return;
|
|
1363
1632
|
const index = store.chunkIndex;
|
|
1364
1633
|
store.chunkIndex += 1;
|
|
1634
|
+
persistActiveSession(store, store.paused || store.finishFlowRan ? "paused" : "active");
|
|
1365
1635
|
store.pendingUploads += 1;
|
|
1366
1636
|
const sessionId = store.sessionId;
|
|
1367
1637
|
const apiUrl = store.options.apiUrl;
|
|
1368
1638
|
store.uploadQueue = store.uploadQueue.then(async () => {
|
|
1369
|
-
|
|
1370
|
-
|
|
1639
|
+
if (store.sessionClosed) {
|
|
1640
|
+
store.pendingUploads -= 1;
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
const outcome = await uploadChunkWithRetry(apiUrl, sessionId, index, blob, ctx.logger);
|
|
1644
|
+
if (outcome === "closed") {
|
|
1645
|
+
handleSessionClosed(store);
|
|
1646
|
+
} else if (outcome === "failed") {
|
|
1371
1647
|
ctx.logger.warn(`chunk ${index} stashed for offline retry`);
|
|
1372
1648
|
await idbStashChunk({
|
|
1373
1649
|
id: `${sessionId}:${index}:${Date.now()}`,
|
|
@@ -1381,10 +1657,6 @@ function enqueueChunk(store, ctx, blob) {
|
|
|
1381
1657
|
store.pendingUploads -= 1;
|
|
1382
1658
|
});
|
|
1383
1659
|
}
|
|
1384
|
-
var SILENCE_RMS_DB_THRESHOLD = -60;
|
|
1385
|
-
var SILENCE_FLOOR_DB = -100;
|
|
1386
|
-
var SILENCE_SUSTAINED_MS = 1800;
|
|
1387
|
-
var SILENCE_POLL_MS = 250;
|
|
1388
1660
|
function rmsDbFromSamples(samples) {
|
|
1389
1661
|
const n = samples.length;
|
|
1390
1662
|
if (n === 0) return SILENCE_FLOOR_DB;
|
|
@@ -1584,6 +1856,17 @@ function stopRecording(store) {
|
|
|
1584
1856
|
store.stream = null;
|
|
1585
1857
|
}
|
|
1586
1858
|
}
|
|
1859
|
+
|
|
1860
|
+
// src/plugins/user-test/lifecycle.ts
|
|
1861
|
+
function pauseFlow(store) {
|
|
1862
|
+
if (store.cancelled) return;
|
|
1863
|
+
if (store.finishFlowRan || store.indicatorState === "finishing" || store.indicatorState === "done") return;
|
|
1864
|
+
if (!store.sessionId) return;
|
|
1865
|
+
store.paused = true;
|
|
1866
|
+
flushMuteIfActive(store);
|
|
1867
|
+
stopRecording(store);
|
|
1868
|
+
persistActiveSession(store, "paused");
|
|
1869
|
+
}
|
|
1587
1870
|
async function finishFlow(store, ctx, opts) {
|
|
1588
1871
|
if (store.cancelled) return;
|
|
1589
1872
|
if (store.finishFlowRan) return;
|
|
@@ -1617,11 +1900,12 @@ async function finishFlow(store, ctx, opts) {
|
|
|
1617
1900
|
}
|
|
1618
1901
|
}
|
|
1619
1902
|
store.indicatorState = result.ok ? "done" : "error";
|
|
1903
|
+
if (result.ok) clearActiveSession();
|
|
1620
1904
|
} else {
|
|
1621
1905
|
store.indicatorState = "error";
|
|
1622
1906
|
}
|
|
1623
1907
|
renderIndicatorState(store);
|
|
1624
|
-
if (
|
|
1908
|
+
if (store.indicatorRoot && store.indicatorState === "done") {
|
|
1625
1909
|
showThanksScreen(store.indicatorRoot, {
|
|
1626
1910
|
payment,
|
|
1627
1911
|
onPayout: async (destination) => {
|
|
@@ -1634,6 +1918,7 @@ async function finishFlow(store, ctx, opts) {
|
|
|
1634
1918
|
store.startedAt = Date.now();
|
|
1635
1919
|
store.muted = false;
|
|
1636
1920
|
store.mutedSinceMs = null;
|
|
1921
|
+
persistActiveSession(store, "active");
|
|
1637
1922
|
renderIndicatorState(store);
|
|
1638
1923
|
void startRecording(store, ctx);
|
|
1639
1924
|
},
|
|
@@ -1656,6 +1941,8 @@ async function finishFlow(store, ctx, opts) {
|
|
|
1656
1941
|
});
|
|
1657
1942
|
}
|
|
1658
1943
|
}
|
|
1944
|
+
|
|
1945
|
+
// src/plugins/user-test.ts
|
|
1659
1946
|
function userTest(options = {}) {
|
|
1660
1947
|
const merged = {
|
|
1661
1948
|
queryParam: options.queryParam ?? DEFAULT_OPTIONS.queryParam,
|
|
@@ -1668,8 +1955,18 @@ function userTest(options = {}) {
|
|
|
1668
1955
|
name: "user-test",
|
|
1669
1956
|
onInit(ctx) {
|
|
1670
1957
|
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
1671
|
-
const
|
|
1958
|
+
const urlSlug = getTestSlug(merged.queryParam);
|
|
1959
|
+
const resumeState = readActiveSession();
|
|
1960
|
+
const resumable = resumeState && (!urlSlug || urlSlug === resumeState.slug) ? resumeState : null;
|
|
1961
|
+
if (resumeState && !resumable) {
|
|
1962
|
+
clearActiveSession();
|
|
1963
|
+
}
|
|
1964
|
+
const slug = resumable?.slug ?? urlSlug;
|
|
1672
1965
|
if (!slug) return;
|
|
1966
|
+
const isResume = resumable !== null;
|
|
1967
|
+
if (isResume && resumable?.sdkSessionId && ctx.reseatSdkSessionId) {
|
|
1968
|
+
ctx.reseatSdkSessionId(resumable.sdkSessionId);
|
|
1969
|
+
}
|
|
1673
1970
|
const apiUrl = merged.apiUrl || ctx.baseUrl || DEFAULT_API_URL;
|
|
1674
1971
|
const store = {
|
|
1675
1972
|
cancelled: false,
|
|
@@ -1678,10 +1975,14 @@ function userTest(options = {}) {
|
|
|
1678
1975
|
clientId: null,
|
|
1679
1976
|
recorder: null,
|
|
1680
1977
|
stream: null,
|
|
1681
|
-
|
|
1978
|
+
// On resume, continue the chunk index so we never overwrite a chunk
|
|
1979
|
+
// already shipped in the pre-navigation leg.
|
|
1980
|
+
chunkIndex: resumable?.nextChunkIndex ?? 0,
|
|
1682
1981
|
uploadQueue: Promise.resolve(),
|
|
1683
1982
|
pendingUploads: 0,
|
|
1684
|
-
|
|
1983
|
+
// On resume, keep the ORIGINAL session start so duration + staleness
|
|
1984
|
+
// stay anchored to when the test actually began, not the return moment.
|
|
1985
|
+
startedAt: resumable?.startedAt ?? Date.now(),
|
|
1685
1986
|
indicator: null,
|
|
1686
1987
|
indicatorRoot: null,
|
|
1687
1988
|
indicatorState: "recording",
|
|
@@ -1692,6 +1993,7 @@ function userTest(options = {}) {
|
|
|
1692
1993
|
tasksPanelOpen: readTasksPanelOpen(),
|
|
1693
1994
|
outsidePointerHandler: null,
|
|
1694
1995
|
keydownHandler: null,
|
|
1996
|
+
keyboardWatcherCleanup: null,
|
|
1695
1997
|
hasMicPermission: false,
|
|
1696
1998
|
micAcquiring: true,
|
|
1697
1999
|
micFailReason: null,
|
|
@@ -1702,16 +2004,32 @@ function userTest(options = {}) {
|
|
|
1702
2004
|
silenceMonitor: null,
|
|
1703
2005
|
muteToastShown: false,
|
|
1704
2006
|
muteToastTimers: [],
|
|
2007
|
+
resumeToastTimers: [],
|
|
1705
2008
|
notes: [],
|
|
1706
2009
|
notesPopoverOpen: false,
|
|
1707
2010
|
notePopoverAtMs: null,
|
|
1708
2011
|
endNote: "",
|
|
1709
2012
|
finishFlowRan: false,
|
|
2013
|
+
sessionClosed: false,
|
|
2014
|
+
onSessionClosed: null,
|
|
2015
|
+
paused: false,
|
|
2016
|
+
resumed: isResume,
|
|
2017
|
+
sdkSessionId: null,
|
|
1710
2018
|
replayOffsetAtStartMs: null
|
|
1711
2019
|
};
|
|
1712
2020
|
ctx.setStore(store);
|
|
2021
|
+
store.onSessionClosed = () => {
|
|
2022
|
+
if (store.cancelled) return;
|
|
2023
|
+
ctx.logger.info("user-test session closed by server during upload; stopping recording");
|
|
2024
|
+
stopRecording(store);
|
|
2025
|
+
clearActiveSession();
|
|
2026
|
+
store.indicatorState = "done";
|
|
2027
|
+
if (store.indicatorRoot && !merged.hideIndicator) {
|
|
2028
|
+
showSessionEndedScreen(store.indicatorRoot);
|
|
2029
|
+
}
|
|
2030
|
+
};
|
|
1713
2031
|
const onFinish = () => {
|
|
1714
|
-
void finishFlow(store, ctx
|
|
2032
|
+
void finishFlow(store, ctx);
|
|
1715
2033
|
};
|
|
1716
2034
|
const setPanelOpen = (open) => {
|
|
1717
2035
|
if (store.tasksPanelOpen === open) return;
|
|
@@ -1799,28 +2117,52 @@ function userTest(options = {}) {
|
|
|
1799
2117
|
document.addEventListener("pointerdown", outsidePointer, true);
|
|
1800
2118
|
document.addEventListener("keydown", onKeydown);
|
|
1801
2119
|
const pageHide = () => {
|
|
1802
|
-
|
|
2120
|
+
pauseFlow(store);
|
|
1803
2121
|
};
|
|
1804
2122
|
store.pageHideHandler = pageHide;
|
|
1805
2123
|
window.addEventListener("pagehide", pageHide);
|
|
1806
2124
|
const onVisibilityChange = () => {
|
|
1807
2125
|
if (document.visibilityState !== "hidden") return;
|
|
1808
|
-
|
|
2126
|
+
pauseFlow(store);
|
|
1809
2127
|
};
|
|
1810
2128
|
store.visibilityHandler = onVisibilityChange;
|
|
1811
2129
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
1812
2130
|
void (async () => {
|
|
1813
|
-
const adoptId = getAdoptSessionId();
|
|
1814
|
-
|
|
1815
|
-
if (
|
|
2131
|
+
const adoptId = resumable?.sessionId ?? getAdoptSessionId();
|
|
2132
|
+
let created;
|
|
2133
|
+
if (adoptId) {
|
|
2134
|
+
const adopted = await adoptSession(apiUrl, adoptId);
|
|
2135
|
+
if (store.cancelled) return;
|
|
2136
|
+
if (adopted.kind === "closed") {
|
|
2137
|
+
ctx.logger.info("user-test session already closed on adopt; not resuming");
|
|
2138
|
+
clearActiveSession();
|
|
2139
|
+
if (store.indicatorRoot && !merged.hideIndicator) {
|
|
2140
|
+
showSessionEndedScreen(store.indicatorRoot);
|
|
2141
|
+
}
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
if (adopted.kind === "error") {
|
|
2145
|
+
ctx.logger.warn("user-test adopt failed transiently on resume; keeping resume state for retry");
|
|
2146
|
+
store.indicatorState = "error";
|
|
2147
|
+
renderIndicatorState(store);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
created = adopted.kind === "ok" ? adopted : null;
|
|
2151
|
+
} else {
|
|
2152
|
+
created = await createSession(apiUrl, slug, readTesterName(merged.testerName));
|
|
2153
|
+
if (store.cancelled) return;
|
|
2154
|
+
}
|
|
1816
2155
|
if (!created) {
|
|
1817
2156
|
ctx.logger.error(adoptId ? "failed to adopt user-test session" : "failed to create user-test session");
|
|
2157
|
+
if (isResume) clearActiveSession();
|
|
1818
2158
|
store.indicatorState = "error";
|
|
1819
2159
|
renderIndicatorState(store);
|
|
1820
2160
|
return;
|
|
1821
2161
|
}
|
|
1822
2162
|
store.sessionId = created.sessionId;
|
|
1823
2163
|
store.clientId = created.clientId;
|
|
2164
|
+
store.sdkSessionId = ctx.getSdkSessionId ? ctx.getSdkSessionId() : null;
|
|
2165
|
+
persistActiveSession(store, "active");
|
|
1824
2166
|
const replayStartMs = ctx.getReplayStartMs ? ctx.getReplayStartMs() : null;
|
|
1825
2167
|
store.replayOffsetAtStartMs = replayStartMs === null ? null : Math.max(0, store.startedAt - replayStartMs);
|
|
1826
2168
|
store.tasks = created.tasks;
|
|
@@ -1834,6 +2176,7 @@ function userTest(options = {}) {
|
|
|
1834
2176
|
}
|
|
1835
2177
|
await startRecording(store, ctx);
|
|
1836
2178
|
renderIndicatorState(store);
|
|
2179
|
+
showResumedToast(store);
|
|
1837
2180
|
})();
|
|
1838
2181
|
},
|
|
1839
2182
|
onDestroy(ctx) {
|
|
@@ -1857,6 +2200,10 @@ function userTest(options = {}) {
|
|
|
1857
2200
|
document.removeEventListener("keydown", store.keydownHandler);
|
|
1858
2201
|
store.keydownHandler = null;
|
|
1859
2202
|
}
|
|
2203
|
+
if (store.keyboardWatcherCleanup) {
|
|
2204
|
+
store.keyboardWatcherCleanup();
|
|
2205
|
+
store.keyboardWatcherCleanup = null;
|
|
2206
|
+
}
|
|
1860
2207
|
for (const id of store.muteToastTimers) {
|
|
1861
2208
|
try {
|
|
1862
2209
|
window.clearTimeout(id);
|
|
@@ -1864,6 +2211,13 @@ function userTest(options = {}) {
|
|
|
1864
2211
|
}
|
|
1865
2212
|
}
|
|
1866
2213
|
store.muteToastTimers = [];
|
|
2214
|
+
for (const id of store.resumeToastTimers) {
|
|
2215
|
+
try {
|
|
2216
|
+
window.clearTimeout(id);
|
|
2217
|
+
} catch {
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
store.resumeToastTimers = [];
|
|
1867
2221
|
if (store.indicator && store.indicator.parentNode) {
|
|
1868
2222
|
store.indicator.parentNode.removeChild(store.indicator);
|
|
1869
2223
|
}
|
|
@@ -1876,11 +2230,22 @@ var __test__ = {
|
|
|
1876
2230
|
getTestSlug,
|
|
1877
2231
|
pickMimeType,
|
|
1878
2232
|
isMediaRecorderSupported,
|
|
2233
|
+
classifyChunkResponse,
|
|
2234
|
+
handleSessionClosed,
|
|
1879
2235
|
micChipState,
|
|
2236
|
+
computeKeyboardInset,
|
|
1880
2237
|
isStreamSilent,
|
|
1881
2238
|
rmsDbFromSamples,
|
|
1882
2239
|
SILENCE_RMS_DB_THRESHOLD,
|
|
1883
|
-
SILENCE_FLOOR_DB
|
|
2240
|
+
SILENCE_FLOOR_DB,
|
|
2241
|
+
parseActiveSession,
|
|
2242
|
+
readActiveSession,
|
|
2243
|
+
clearActiveSession,
|
|
2244
|
+
persistActiveSession,
|
|
2245
|
+
adoptSession,
|
|
2246
|
+
ACTIVE_SESSION_MAX_AGE_MS,
|
|
2247
|
+
RESUME_MAX_IDLE_MS,
|
|
2248
|
+
ACTIVE_SESSION_STORAGE_KEY
|
|
1884
2249
|
};
|
|
1885
2250
|
|
|
1886
2251
|
exports.__test__ = __test__;
|