@openreplay/tracker 10.0.3 → 11.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -1
- package/bun.lockb +0 -0
- package/cjs/app/canvas.d.ts +20 -0
- package/cjs/app/canvas.js +107 -0
- package/cjs/app/guards.d.ts +1 -0
- package/cjs/app/index.d.ts +3 -0
- package/cjs/app/index.js +36 -6
- package/cjs/app/messages.gen.d.ts +1 -0
- package/cjs/app/messages.gen.js +9 -1
- package/cjs/common/messages.gen.d.ts +8 -2
- package/cjs/index.js +1 -1
- package/cjs/modules/Network/fetchProxy.d.ts +1 -1
- package/cjs/modules/Network/fetchProxy.js +1 -1
- package/cjs/modules/userTesting/dnd.d.ts +1 -0
- package/cjs/modules/userTesting/dnd.js +40 -0
- package/cjs/modules/userTesting/index.d.ts +45 -0
- package/cjs/modules/userTesting/index.js +476 -0
- package/cjs/modules/userTesting/recorder.d.ts +24 -0
- package/cjs/modules/userTesting/recorder.js +119 -0
- package/cjs/modules/userTesting/styles.d.ts +260 -0
- package/cjs/modules/userTesting/styles.js +229 -0
- package/lib/app/canvas.d.ts +20 -0
- package/lib/app/canvas.js +105 -0
- package/lib/app/guards.d.ts +1 -0
- package/lib/app/index.d.ts +3 -0
- package/lib/app/index.js +36 -6
- package/lib/app/messages.gen.d.ts +1 -0
- package/lib/app/messages.gen.js +7 -0
- package/lib/common/messages.gen.d.ts +8 -2
- package/lib/common/tsconfig.tsbuildinfo +1 -1
- package/lib/index.js +1 -1
- package/lib/modules/Network/fetchProxy.d.ts +1 -1
- package/lib/modules/Network/fetchProxy.js +1 -1
- package/lib/modules/userTesting/dnd.d.ts +1 -0
- package/lib/modules/userTesting/dnd.js +37 -0
- package/lib/modules/userTesting/index.d.ts +45 -0
- package/lib/modules/userTesting/index.js +473 -0
- package/lib/modules/userTesting/recorder.d.ts +24 -0
- package/lib/modules/userTesting/recorder.js +115 -0
- package/lib/modules/userTesting/styles.d.ts +260 -0
- package/lib/modules/userTesting/styles.js +226 -0
- package/package.json +4 -3
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import * as styles from './styles.js';
|
|
2
|
+
import Recorder, { Quality } from './recorder.js';
|
|
3
|
+
import attachDND from './dnd.js';
|
|
4
|
+
function createElement(tag, className, styles, textContent, id) {
|
|
5
|
+
const element = document.createElement(tag);
|
|
6
|
+
element.className = className;
|
|
7
|
+
Object.assign(element.style, styles);
|
|
8
|
+
if (textContent) {
|
|
9
|
+
element.textContent = textContent;
|
|
10
|
+
}
|
|
11
|
+
if (id) {
|
|
12
|
+
element.id = id;
|
|
13
|
+
}
|
|
14
|
+
return element;
|
|
15
|
+
}
|
|
16
|
+
export default class UserTestManager {
|
|
17
|
+
constructor(app, storageKey) {
|
|
18
|
+
this.app = app;
|
|
19
|
+
this.storageKey = storageKey;
|
|
20
|
+
this.bg = createElement('div', 'bg', styles.bgStyle, undefined, '__or_ut_bg');
|
|
21
|
+
this.container = createElement('div', 'container', styles.containerStyle, undefined, '__or_ut_ct');
|
|
22
|
+
this.widgetGuidelinesVisible = true;
|
|
23
|
+
this.widgetTasksVisible = false;
|
|
24
|
+
this.widgetVisible = true;
|
|
25
|
+
this.descriptionSection = null;
|
|
26
|
+
this.taskSection = null;
|
|
27
|
+
this.endSection = null;
|
|
28
|
+
this.stopButton = null;
|
|
29
|
+
this.test = null;
|
|
30
|
+
this.testId = null;
|
|
31
|
+
this.token = null;
|
|
32
|
+
this.durations = {
|
|
33
|
+
testStart: 0,
|
|
34
|
+
tasks: [],
|
|
35
|
+
};
|
|
36
|
+
this.signalTask = (taskId, status, answer) => {
|
|
37
|
+
if (!taskId)
|
|
38
|
+
return console.error('OR: no task id');
|
|
39
|
+
const taskStart = this.durations.tasks.find((t) => t.taskId === taskId);
|
|
40
|
+
const timestamp = this.app.timestamp();
|
|
41
|
+
const duration = taskStart ? timestamp - taskStart.started : 0;
|
|
42
|
+
const ingest = this.app.options.ingestPoint;
|
|
43
|
+
return fetch(`${ingest}/v1/web/uxt/signals/task`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: {
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
47
|
+
Authorization: `Bearer ${this.token}`,
|
|
48
|
+
},
|
|
49
|
+
body: JSON.stringify({
|
|
50
|
+
testId: this.testId,
|
|
51
|
+
taskId,
|
|
52
|
+
status,
|
|
53
|
+
duration,
|
|
54
|
+
timestamp,
|
|
55
|
+
answer,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
this.signalTest = (status) => {
|
|
60
|
+
const timestamp = this.app.timestamp();
|
|
61
|
+
if (status === 'begin' && this.testId) {
|
|
62
|
+
this.app.localStorage.setItem(this.storageKey, this.testId.toString());
|
|
63
|
+
this.app.localStorage.setItem('or_uxt_test_start', timestamp.toString());
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this.app.localStorage.removeItem(this.storageKey);
|
|
67
|
+
this.app.localStorage.removeItem('or_uxt_task_index');
|
|
68
|
+
this.app.localStorage.removeItem('or_uxt_test_start');
|
|
69
|
+
}
|
|
70
|
+
const ingest = this.app.options.ingestPoint;
|
|
71
|
+
const start = this.durations.testStart || timestamp;
|
|
72
|
+
const duration = timestamp - start;
|
|
73
|
+
return fetch(`${ingest}/v1/web/uxt/signals/test`, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {
|
|
76
|
+
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
77
|
+
Authorization: `Bearer ${this.token}`,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
testId: this.testId,
|
|
81
|
+
status,
|
|
82
|
+
duration,
|
|
83
|
+
timestamp,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
this.getTest = (id, token, inProgress) => {
|
|
88
|
+
this.testId = id;
|
|
89
|
+
this.token = token;
|
|
90
|
+
const ingest = this.app.options.ingestPoint;
|
|
91
|
+
fetch(`${ingest}/v1/web/uxt/test/${id}`, {
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${token}`,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
.then((res) => res.json())
|
|
97
|
+
.then(({ test }) => {
|
|
98
|
+
this.test = test;
|
|
99
|
+
this.createGreeting(test.title, test.reqMic, test.reqCamera);
|
|
100
|
+
if (inProgress) {
|
|
101
|
+
if (test.reqMic || test.reqCamera) {
|
|
102
|
+
void this.userRecorder.startRecording(30, Quality.Standard, test.reqMic, test.reqCamera);
|
|
103
|
+
}
|
|
104
|
+
this.showWidget(test.description, test.tasks, true);
|
|
105
|
+
this.showTaskSection();
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
.catch((err) => {
|
|
109
|
+
console.log('OR: Error fetching test', err);
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
this.hideTaskSection = () => false;
|
|
113
|
+
this.showTaskSection = () => true;
|
|
114
|
+
this.collapseWidget = () => false;
|
|
115
|
+
this.removeGreeting = () => false;
|
|
116
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
117
|
+
this.toggleDescriptionVisibility = () => { };
|
|
118
|
+
this.currentTaskIndex = 0;
|
|
119
|
+
this.userRecorder = new Recorder(app);
|
|
120
|
+
const sessionId = this.app.getSessionID();
|
|
121
|
+
const savedSessionId = this.app.localStorage.getItem('or_uxt_session_id');
|
|
122
|
+
if (sessionId !== savedSessionId) {
|
|
123
|
+
this.app.localStorage.removeItem(this.storageKey);
|
|
124
|
+
this.app.localStorage.removeItem('or_uxt_session_id');
|
|
125
|
+
this.app.localStorage.removeItem('or_uxt_test_id');
|
|
126
|
+
this.app.localStorage.removeItem('or_uxt_task_index');
|
|
127
|
+
this.app.localStorage.removeItem('or_uxt_test_start');
|
|
128
|
+
}
|
|
129
|
+
const taskIndex = this.app.localStorage.getItem('or_uxt_task_index');
|
|
130
|
+
if (taskIndex) {
|
|
131
|
+
this.currentTaskIndex = parseInt(taskIndex, 10);
|
|
132
|
+
this.durations.testStart = parseInt(this.app.localStorage.getItem('or_uxt_test_start'), 10);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
createGreeting(title, micRequired, cameraRequired) {
|
|
136
|
+
const titleElement = createElement('div', 'title', styles.titleStyle, title);
|
|
137
|
+
const descriptionElement = createElement('div', 'description', styles.descriptionStyle, 'Welcome, this session will be recorded. You have complete control, and can stop the session at any time.');
|
|
138
|
+
const noticeElement = createElement('div', 'notice', styles.noticeStyle, `Please note that your ${micRequired ? 'audio,' : ''} ${cameraRequired ? 'video,' : ''} ${micRequired || cameraRequired ? 'and' : ''} screen will be recorded for research purposes during this test.`);
|
|
139
|
+
const buttonElement = createElement('div', 'button', styles.buttonStyle, 'Read guidelines to begin');
|
|
140
|
+
this.removeGreeting = () => {
|
|
141
|
+
// this.container.innerHTML = ''
|
|
142
|
+
if (micRequired || cameraRequired) {
|
|
143
|
+
void this.userRecorder.startRecording(30, Quality.Standard, micRequired, cameraRequired);
|
|
144
|
+
}
|
|
145
|
+
this.container.removeChild(buttonElement);
|
|
146
|
+
this.container.removeChild(noticeElement);
|
|
147
|
+
this.container.removeChild(descriptionElement);
|
|
148
|
+
this.container.removeChild(titleElement);
|
|
149
|
+
return false;
|
|
150
|
+
};
|
|
151
|
+
buttonElement.onclick = () => {
|
|
152
|
+
var _a, _b;
|
|
153
|
+
this.removeGreeting();
|
|
154
|
+
this.durations.testStart = this.app.timestamp();
|
|
155
|
+
void this.signalTest('begin');
|
|
156
|
+
this.showWidget(((_a = this.test) === null || _a === void 0 ? void 0 : _a.guidelines) || '', ((_b = this.test) === null || _b === void 0 ? void 0 : _b.tasks) || []);
|
|
157
|
+
};
|
|
158
|
+
this.container.append(titleElement, descriptionElement, noticeElement, buttonElement);
|
|
159
|
+
this.bg.appendChild(this.container);
|
|
160
|
+
document.body.appendChild(this.bg);
|
|
161
|
+
}
|
|
162
|
+
showWidget(guidelines, tasks, inProgress) {
|
|
163
|
+
this.container.innerHTML = '';
|
|
164
|
+
Object.assign(this.bg.style, {
|
|
165
|
+
position: 'fixed',
|
|
166
|
+
zIndex: 99999999999999,
|
|
167
|
+
right: '8px',
|
|
168
|
+
left: 'unset',
|
|
169
|
+
width: 'fit-content',
|
|
170
|
+
top: '8px',
|
|
171
|
+
height: 'fit-content',
|
|
172
|
+
background: 'unset',
|
|
173
|
+
display: 'unset',
|
|
174
|
+
alignItems: 'unset',
|
|
175
|
+
justifyContent: 'unset',
|
|
176
|
+
});
|
|
177
|
+
// Create title section
|
|
178
|
+
const titleSection = this.createTitleSection();
|
|
179
|
+
Object.assign(this.container.style, styles.containerWidgetStyle);
|
|
180
|
+
const descriptionSection = this.createDescriptionSection(guidelines);
|
|
181
|
+
const tasksSection = this.createTasksSection(tasks);
|
|
182
|
+
const stopButton = createElement('div', 'stop_bn_or', styles.stopWidgetStyle, 'Abort Session');
|
|
183
|
+
this.container.append(titleSection, descriptionSection, tasksSection, stopButton);
|
|
184
|
+
this.taskSection = tasksSection;
|
|
185
|
+
this.descriptionSection = descriptionSection;
|
|
186
|
+
this.stopButton = stopButton;
|
|
187
|
+
stopButton.onclick = () => {
|
|
188
|
+
this.userRecorder.discard();
|
|
189
|
+
void this.signalTest('skipped');
|
|
190
|
+
document.body.removeChild(this.bg);
|
|
191
|
+
};
|
|
192
|
+
if (!inProgress) {
|
|
193
|
+
this.hideTaskSection();
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.toggleDescriptionVisibility();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
createTitleSection() {
|
|
200
|
+
var _a;
|
|
201
|
+
const title = createElement('div', 'title', styles.titleWidgetStyle);
|
|
202
|
+
const leftIcon = generateGrid();
|
|
203
|
+
const titleText = createElement('div', 'title_text', {}, (_a = this.test) === null || _a === void 0 ? void 0 : _a.title);
|
|
204
|
+
const rightIcon = generateChevron();
|
|
205
|
+
title.append(leftIcon, titleText, rightIcon);
|
|
206
|
+
const toggleWidget = (isVisible) => {
|
|
207
|
+
this.widgetVisible = isVisible;
|
|
208
|
+
Object.assign(this.container.style, this.widgetVisible
|
|
209
|
+
? styles.containerWidgetStyle
|
|
210
|
+
: { border: 'none', background: 'none', padding: 0 });
|
|
211
|
+
if (this.taskSection) {
|
|
212
|
+
Object.assign(this.taskSection.style, this.widgetVisible ? styles.descriptionWidgetStyle : { display: 'none' });
|
|
213
|
+
}
|
|
214
|
+
if (this.descriptionSection) {
|
|
215
|
+
Object.assign(this.descriptionSection.style, this.widgetVisible ? styles.descriptionWidgetStyle : { display: 'none' });
|
|
216
|
+
}
|
|
217
|
+
if (this.endSection) {
|
|
218
|
+
Object.assign(this.endSection.style, this.widgetVisible ? styles.descriptionWidgetStyle : { display: 'none' });
|
|
219
|
+
}
|
|
220
|
+
if (this.stopButton) {
|
|
221
|
+
Object.assign(this.stopButton.style, this.widgetVisible ? styles.stopWidgetStyle : { display: 'none' });
|
|
222
|
+
}
|
|
223
|
+
return isVisible;
|
|
224
|
+
};
|
|
225
|
+
rightIcon.onclick = () => {
|
|
226
|
+
Object.assign(rightIcon.style, {
|
|
227
|
+
transform: this.widgetVisible ? 'rotate(0deg)' : 'rotate(180deg)',
|
|
228
|
+
});
|
|
229
|
+
toggleWidget(!this.widgetVisible);
|
|
230
|
+
};
|
|
231
|
+
attachDND(this.bg, leftIcon);
|
|
232
|
+
this.collapseWidget = () => toggleWidget(false);
|
|
233
|
+
return title;
|
|
234
|
+
}
|
|
235
|
+
createDescriptionSection(guidelines) {
|
|
236
|
+
const section = createElement('div', 'description_section_or', styles.descriptionWidgetStyle);
|
|
237
|
+
const titleContainer = createElement('div', 'description_s_title_or', styles.sectionTitleStyle);
|
|
238
|
+
const title = createElement('div', 'title', {}, 'Introduction & Guidelines');
|
|
239
|
+
const icon = createElement('div', 'icon', styles.symbolIcon, '-');
|
|
240
|
+
const content = createElement('div', 'content', styles.contentStyle);
|
|
241
|
+
const descriptionC = createElement('div', 'text_description', {
|
|
242
|
+
maxHeight: '250px',
|
|
243
|
+
overflowY: 'auto',
|
|
244
|
+
whiteSpace: 'pre-wrap',
|
|
245
|
+
});
|
|
246
|
+
descriptionC.innerHTML = guidelines;
|
|
247
|
+
const button = createElement('div', 'button_begin_or', styles.buttonWidgetStyle, 'Begin Test');
|
|
248
|
+
titleContainer.append(title, icon);
|
|
249
|
+
content.append(descriptionC, button);
|
|
250
|
+
section.append(titleContainer, content);
|
|
251
|
+
const toggleDescriptionVisibility = () => {
|
|
252
|
+
this.widgetGuidelinesVisible = !this.widgetGuidelinesVisible;
|
|
253
|
+
icon.textContent = this.widgetGuidelinesVisible ? '-' : '+';
|
|
254
|
+
Object.assign(content.style, this.widgetGuidelinesVisible ? styles.contentStyle : { display: 'none' });
|
|
255
|
+
};
|
|
256
|
+
titleContainer.onclick = toggleDescriptionVisibility;
|
|
257
|
+
this.toggleDescriptionVisibility = () => {
|
|
258
|
+
this.widgetGuidelinesVisible = false;
|
|
259
|
+
icon.textContent = this.widgetGuidelinesVisible ? '-' : '+';
|
|
260
|
+
Object.assign(content.style, this.widgetGuidelinesVisible ? styles.contentStyle : { display: 'none' });
|
|
261
|
+
content.removeChild(button);
|
|
262
|
+
};
|
|
263
|
+
button.onclick = () => {
|
|
264
|
+
toggleDescriptionVisibility();
|
|
265
|
+
if (this.test) {
|
|
266
|
+
if (this.durations.tasks.findIndex((t) => this.test && t.taskId === this.test.tasks[0].task_id) === -1) {
|
|
267
|
+
this.durations.tasks.push({
|
|
268
|
+
taskId: this.test.tasks[0].task_id,
|
|
269
|
+
started: this.app.timestamp(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
void this.signalTask(this.test.tasks[0].task_id, 'begin');
|
|
273
|
+
}
|
|
274
|
+
this.showTaskSection();
|
|
275
|
+
content.removeChild(button);
|
|
276
|
+
};
|
|
277
|
+
return section;
|
|
278
|
+
}
|
|
279
|
+
createTasksSection(tasks) {
|
|
280
|
+
const section = createElement('div', 'task_section_or', styles.descriptionWidgetStyle);
|
|
281
|
+
const titleContainer = createElement('div', 'description_t_title_or', styles.sectionTitleStyle);
|
|
282
|
+
const title = createElement('div', 'title', {}, 'Tasks');
|
|
283
|
+
const icon = createElement('div', 'icon', styles.symbolIcon, '-');
|
|
284
|
+
const content = createElement('div', 'content', styles.contentStyle);
|
|
285
|
+
const pagination = createElement('div', 'pagination', styles.paginationStyle);
|
|
286
|
+
const leftArrow = createElement('span', 'leftArrow', {}, '<');
|
|
287
|
+
const rightArrow = createElement('span', 'rightArrow', {}, '>');
|
|
288
|
+
const taskCard = createElement('div', 'taskCard', styles.taskDescriptionCard);
|
|
289
|
+
const taskText = createElement('div', 'taskText', styles.taskTextStyle);
|
|
290
|
+
const taskDescription = createElement('div', 'taskDescription', styles.taskDescriptionStyle);
|
|
291
|
+
const taskButtons = createElement('div', 'taskButtons', styles.taskButtonsRow);
|
|
292
|
+
const inputTitle = createElement('div', 'taskText', styles.taskTextStyle);
|
|
293
|
+
inputTitle.textContent = 'Your answer';
|
|
294
|
+
const inputArea = createElement('textarea', 'taskDescription', {
|
|
295
|
+
resize: 'vertical',
|
|
296
|
+
});
|
|
297
|
+
const inputContainer = createElement('div', 'inputArea', styles.taskDescriptionCard);
|
|
298
|
+
inputContainer.append(inputTitle, inputArea);
|
|
299
|
+
const closePanelButton = createElement('div', 'closePanelButton', styles.taskButtonStyle, 'Collapse panel');
|
|
300
|
+
const nextButton = createElement('div', 'nextButton', styles.taskButtonBorderedStyle, 'Done, next');
|
|
301
|
+
titleContainer.append(title, icon);
|
|
302
|
+
taskCard.append(taskText, taskDescription);
|
|
303
|
+
taskButtons.append(closePanelButton, nextButton);
|
|
304
|
+
content.append(pagination, taskCard, inputContainer, taskButtons);
|
|
305
|
+
section.append(titleContainer, content);
|
|
306
|
+
const updateTaskContent = () => {
|
|
307
|
+
const task = tasks[this.currentTaskIndex];
|
|
308
|
+
taskText.textContent = task.title;
|
|
309
|
+
taskDescription.textContent = task.description;
|
|
310
|
+
if (task.allow_typing) {
|
|
311
|
+
inputContainer.style.display = 'flex';
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
inputContainer.style.display = 'none';
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
pagination.appendChild(leftArrow);
|
|
318
|
+
tasks.forEach((_, index) => {
|
|
319
|
+
const pageNumber = createElement('span', `or_task_${index}`, {}, (index + 1).toString());
|
|
320
|
+
pageNumber.id = `or_task_${index}`;
|
|
321
|
+
pagination.append(pageNumber);
|
|
322
|
+
});
|
|
323
|
+
pagination.appendChild(rightArrow);
|
|
324
|
+
const toggleTasksVisibility = () => {
|
|
325
|
+
this.widgetTasksVisible = !this.widgetTasksVisible;
|
|
326
|
+
icon.textContent = this.widgetTasksVisible ? '-' : '+';
|
|
327
|
+
Object.assign(content.style, this.widgetTasksVisible ? styles.contentStyle : { display: 'none' });
|
|
328
|
+
};
|
|
329
|
+
this.hideTaskSection = () => {
|
|
330
|
+
icon.textContent = '+';
|
|
331
|
+
Object.assign(content.style, {
|
|
332
|
+
display: 'none',
|
|
333
|
+
});
|
|
334
|
+
this.widgetTasksVisible = false;
|
|
335
|
+
return false;
|
|
336
|
+
};
|
|
337
|
+
this.showTaskSection = () => {
|
|
338
|
+
icon.textContent = '-';
|
|
339
|
+
Object.assign(content.style, styles.contentStyle);
|
|
340
|
+
this.widgetTasksVisible = true;
|
|
341
|
+
return true;
|
|
342
|
+
};
|
|
343
|
+
const highlightActive = () => {
|
|
344
|
+
const activeTaskEl = document.getElementById(`or_task_${this.currentTaskIndex}`);
|
|
345
|
+
if (activeTaskEl) {
|
|
346
|
+
Object.assign(activeTaskEl.style, styles.taskNumberActive);
|
|
347
|
+
}
|
|
348
|
+
for (let i = 0; i < this.currentTaskIndex; i++) {
|
|
349
|
+
const taskEl = document.getElementById(`or_task_${i}`);
|
|
350
|
+
if (taskEl) {
|
|
351
|
+
Object.assign(taskEl.style, styles.taskNumberDone);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
titleContainer.onclick = toggleTasksVisibility;
|
|
356
|
+
closePanelButton.onclick = this.collapseWidget;
|
|
357
|
+
nextButton.onclick = () => {
|
|
358
|
+
const textAnswer = tasks[this.currentTaskIndex].allow_typing ? inputArea.value : undefined;
|
|
359
|
+
inputArea.value = '';
|
|
360
|
+
void this.signalTask(tasks[this.currentTaskIndex].task_id, 'done', textAnswer);
|
|
361
|
+
if (this.currentTaskIndex < tasks.length - 1) {
|
|
362
|
+
this.currentTaskIndex++;
|
|
363
|
+
updateTaskContent();
|
|
364
|
+
if (this.durations.tasks.findIndex((t) => t.taskId === tasks[this.currentTaskIndex].task_id) === -1) {
|
|
365
|
+
this.durations.tasks.push({
|
|
366
|
+
taskId: tasks[this.currentTaskIndex].task_id,
|
|
367
|
+
started: this.app.timestamp(),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
void this.signalTask(tasks[this.currentTaskIndex].task_id, 'begin');
|
|
371
|
+
highlightActive();
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
this.showEndSection();
|
|
375
|
+
}
|
|
376
|
+
this.app.localStorage.setItem('or_uxt_task_index', this.currentTaskIndex.toString());
|
|
377
|
+
};
|
|
378
|
+
setTimeout(() => {
|
|
379
|
+
const firstTaskEl = document.getElementById('or_task_0');
|
|
380
|
+
if (firstTaskEl) {
|
|
381
|
+
Object.assign(firstTaskEl.style, styles.taskNumberActive);
|
|
382
|
+
}
|
|
383
|
+
updateTaskContent();
|
|
384
|
+
highlightActive();
|
|
385
|
+
}, 1);
|
|
386
|
+
return section;
|
|
387
|
+
}
|
|
388
|
+
showEndSection() {
|
|
389
|
+
var _a, _b, _c, _d, _e, _f;
|
|
390
|
+
let isLoading = true;
|
|
391
|
+
void this.signalTest('done');
|
|
392
|
+
const section = createElement('div', 'end_section_or', styles.endSectionStyle);
|
|
393
|
+
const title = createElement('div', 'end_title_or', {
|
|
394
|
+
fontSize: '1.25rem',
|
|
395
|
+
fontWeight: '500',
|
|
396
|
+
}, ((_a = this.test) === null || _a === void 0 ? void 0 : _a.reqMic) || ((_b = this.test) === null || _b === void 0 ? void 0 : _b.reqCamera) ? 'Uploading test recording...' : 'Thank you! 👍');
|
|
397
|
+
const description = createElement('div', 'end_description_or', {}, (_d = (_c = this.test) === null || _c === void 0 ? void 0 : _c.conclusion) !== null && _d !== void 0 ? _d : 'Thank you for participating in our usability test. Your feedback has been captured and will be used to enhance our website. \n' +
|
|
398
|
+
'\n' +
|
|
399
|
+
'We appreciate your time and valuable input.');
|
|
400
|
+
const button = createElement('div', 'end_button_or', styles.buttonWidgetStyle, 'Uploading session...');
|
|
401
|
+
if (((_e = this.test) === null || _e === void 0 ? void 0 : _e.reqMic) || ((_f = this.test) === null || _f === void 0 ? void 0 : _f.reqCamera)) {
|
|
402
|
+
void this.userRecorder.sendToAPI().then(() => {
|
|
403
|
+
title.textContent = 'Thank you! 👍';
|
|
404
|
+
button.textContent = 'End Session';
|
|
405
|
+
isLoading = false;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
if (this.taskSection) {
|
|
409
|
+
this.container.removeChild(this.taskSection);
|
|
410
|
+
}
|
|
411
|
+
if (this.descriptionSection) {
|
|
412
|
+
this.container.removeChild(this.descriptionSection);
|
|
413
|
+
}
|
|
414
|
+
if (this.stopButton) {
|
|
415
|
+
this.container.removeChild(this.stopButton);
|
|
416
|
+
}
|
|
417
|
+
button.onclick = () => {
|
|
418
|
+
if (isLoading)
|
|
419
|
+
return;
|
|
420
|
+
window.close();
|
|
421
|
+
document.body.removeChild(this.bg);
|
|
422
|
+
};
|
|
423
|
+
section.append(title, description, button);
|
|
424
|
+
this.endSection = section;
|
|
425
|
+
this.container.append(section);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function generateGrid() {
|
|
429
|
+
const grid = document.createElement('div');
|
|
430
|
+
grid.className = 'grid';
|
|
431
|
+
for (let i = 0; i < 16; i++) {
|
|
432
|
+
const cell = document.createElement('div');
|
|
433
|
+
Object.assign(cell.style, {
|
|
434
|
+
width: '2px',
|
|
435
|
+
height: '2px',
|
|
436
|
+
borderRadius: '10px',
|
|
437
|
+
background: 'white',
|
|
438
|
+
});
|
|
439
|
+
cell.className = 'cell';
|
|
440
|
+
grid.appendChild(cell);
|
|
441
|
+
}
|
|
442
|
+
Object.assign(grid.style, {
|
|
443
|
+
display: 'grid',
|
|
444
|
+
gridTemplateColumns: 'repeat(4, 1fr)',
|
|
445
|
+
gridTemplateRows: 'repeat(4, 1fr)',
|
|
446
|
+
gap: '2px',
|
|
447
|
+
cursor: 'grab',
|
|
448
|
+
});
|
|
449
|
+
return grid;
|
|
450
|
+
}
|
|
451
|
+
function generateChevron() {
|
|
452
|
+
const triangle = document.createElement('div');
|
|
453
|
+
Object.assign(triangle.style, {
|
|
454
|
+
width: '0',
|
|
455
|
+
height: '0',
|
|
456
|
+
borderLeft: '7px solid transparent',
|
|
457
|
+
borderRight: '7px solid transparent',
|
|
458
|
+
borderBottom: '7px solid white',
|
|
459
|
+
});
|
|
460
|
+
const container = document.createElement('div');
|
|
461
|
+
container.appendChild(triangle);
|
|
462
|
+
Object.assign(container.style, {
|
|
463
|
+
display: 'flex',
|
|
464
|
+
alignItems: 'center',
|
|
465
|
+
justifyContent: 'center',
|
|
466
|
+
width: '16px',
|
|
467
|
+
height: '16px',
|
|
468
|
+
cursor: 'pointer',
|
|
469
|
+
marginLeft: 'auto',
|
|
470
|
+
transform: 'rotate(180deg)',
|
|
471
|
+
});
|
|
472
|
+
return container;
|
|
473
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import App from '../../app/index.js';
|
|
2
|
+
export declare const Quality: {
|
|
3
|
+
Standard: {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
High: {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export default class Recorder {
|
|
13
|
+
private readonly app;
|
|
14
|
+
private mediaRecorder;
|
|
15
|
+
private recordedChunks;
|
|
16
|
+
private stream;
|
|
17
|
+
private recStartTs;
|
|
18
|
+
constructor(app: App);
|
|
19
|
+
startRecording(fps: number, quality: (typeof Quality)[keyof typeof Quality], micReq: boolean, camReq: boolean): Promise<void>;
|
|
20
|
+
stopRecording(): Promise<Blob>;
|
|
21
|
+
sendToAPI(): Promise<void | Response>;
|
|
22
|
+
saveToFile(fileName?: string): Promise<void>;
|
|
23
|
+
discard(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
export const Quality = {
|
|
11
|
+
Standard: { width: 1280, height: 720 },
|
|
12
|
+
High: { width: 1920, height: 1080 },
|
|
13
|
+
};
|
|
14
|
+
export default class Recorder {
|
|
15
|
+
constructor(app) {
|
|
16
|
+
this.app = app;
|
|
17
|
+
this.mediaRecorder = null;
|
|
18
|
+
this.recordedChunks = [];
|
|
19
|
+
this.stream = null;
|
|
20
|
+
this.recStartTs = null;
|
|
21
|
+
}
|
|
22
|
+
startRecording(fps, quality, micReq, camReq) {
|
|
23
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
24
|
+
this.recStartTs = this.app.timestamp();
|
|
25
|
+
const videoConstraints = quality;
|
|
26
|
+
try {
|
|
27
|
+
this.stream = yield navigator.mediaDevices.getUserMedia({
|
|
28
|
+
video: camReq ? Object.assign(Object.assign({}, videoConstraints), { frameRate: { ideal: fps } }) : false,
|
|
29
|
+
audio: micReq,
|
|
30
|
+
});
|
|
31
|
+
this.mediaRecorder = new MediaRecorder(this.stream, {
|
|
32
|
+
mimeType: 'video/webm;codecs=vp9',
|
|
33
|
+
});
|
|
34
|
+
this.recordedChunks = [];
|
|
35
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
36
|
+
if (event.data.size > 0) {
|
|
37
|
+
this.recordedChunks.push(event.data);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
this.mediaRecorder.start();
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error(error);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
stopRecording() {
|
|
48
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
if (!this.mediaRecorder)
|
|
51
|
+
return;
|
|
52
|
+
this.mediaRecorder.onstop = () => {
|
|
53
|
+
const blob = new Blob(this.recordedChunks, {
|
|
54
|
+
type: 'video/webm',
|
|
55
|
+
});
|
|
56
|
+
resolve(blob);
|
|
57
|
+
};
|
|
58
|
+
this.mediaRecorder.stop();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
sendToAPI() {
|
|
63
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
64
|
+
const blob = yield this.stopRecording();
|
|
65
|
+
// const formData = new FormData()
|
|
66
|
+
// formData.append('file', blob, 'record.webm')
|
|
67
|
+
// formData.append('start', this.recStartTs?.toString() ?? '')
|
|
68
|
+
return fetch(`${this.app.options.ingestPoint}/v1/web/uxt/upload-url`, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `Bearer ${this.app.session.getSessionToken()}`,
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
.then((r) => {
|
|
74
|
+
if (r.ok) {
|
|
75
|
+
return r.json();
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
throw new Error('Failed to get upload url');
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.then(({ url }) => {
|
|
82
|
+
return fetch(url, {
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
headers: {
|
|
85
|
+
'Content-Type': 'video/webm',
|
|
86
|
+
},
|
|
87
|
+
body: blob,
|
|
88
|
+
});
|
|
89
|
+
})
|
|
90
|
+
.catch(console.error)
|
|
91
|
+
.finally(() => {
|
|
92
|
+
this.discard();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
saveToFile(fileName = 'recorded-video.webm') {
|
|
97
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
98
|
+
const blob = yield this.stopRecording();
|
|
99
|
+
const url = URL.createObjectURL(blob);
|
|
100
|
+
const a = document.createElement('a');
|
|
101
|
+
a.style.display = 'none';
|
|
102
|
+
a.href = url;
|
|
103
|
+
a.download = fileName;
|
|
104
|
+
document.body.appendChild(a);
|
|
105
|
+
a.click();
|
|
106
|
+
window.URL.revokeObjectURL(url);
|
|
107
|
+
document.body.removeChild(a);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
discard() {
|
|
111
|
+
var _a, _b;
|
|
112
|
+
(_a = this.mediaRecorder) === null || _a === void 0 ? void 0 : _a.stop();
|
|
113
|
+
(_b = this.stream) === null || _b === void 0 ? void 0 : _b.getTracks().forEach((track) => track.stop());
|
|
114
|
+
}
|
|
115
|
+
}
|