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