@usero/sdk 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -623,12 +623,15 @@ function showMuteToast(store) {
623
623
  toast.setAttribute("role", "status");
624
624
  toast.innerHTML = `<strong>Mic off.</strong> Screen is still recording. Tap to unmute.`;
625
625
  slot.appendChild(toast);
626
- window.setTimeout(() => {
626
+ const outer = window.setTimeout(() => {
627
+ if (!toast.isConnected) return;
627
628
  toast.setAttribute("data-leaving", "true");
628
- window.setTimeout(() => {
629
- toast.remove();
629
+ const inner = window.setTimeout(() => {
630
+ if (toast.isConnected) toast.remove();
630
631
  }, 260);
632
+ store.muteToastTimers.push(inner);
631
633
  }, 3e3);
634
+ store.muteToastTimers.push(outer);
632
635
  }
633
636
  function openNotePopover(store, onSave, onCancel) {
634
637
  const root = store.indicatorRoot;
@@ -756,6 +759,17 @@ function showThanksScreen(root, opts) {
756
759
  sent.textContent = message;
757
760
  card.appendChild(sent);
758
761
  };
762
+ const ERROR_CLASS = "end-error";
763
+ const showError = (message) => {
764
+ const prior = form.querySelector(`.${ERROR_CLASS}`);
765
+ if (prior) prior.remove();
766
+ const err = document.createElement("p");
767
+ err.className = ERROR_CLASS;
768
+ err.textContent = message;
769
+ err.setAttribute("role", "alert");
770
+ err.style.cssText = "margin:10px 0 0;font-size:12.5px;color:#b91c1c;text-align:center;";
771
+ form.appendChild(err);
772
+ };
759
773
  const submit = async () => {
760
774
  const text = ta.value.trim();
761
775
  ta.disabled = true;
@@ -763,10 +777,21 @@ function showThanksScreen(root, opts) {
763
777
  const submitBtn = form.querySelector("button.primary");
764
778
  if (submitBtn) submitBtn.disabled = true;
765
779
  if (text) {
766
- await opts.onSubmitNote(text);
767
- swapToSent("Thanks. You can close this tab.");
780
+ try {
781
+ await Promise.race([
782
+ Promise.resolve(opts.onSubmitNote(text)),
783
+ new Promise((_, reject) => {
784
+ window.setTimeout(() => reject(new Error("timeout")), 3e4);
785
+ })
786
+ ]);
787
+ swapToSent("Thanks. You can close this tab.");
788
+ } catch {
789
+ ta.disabled = false;
790
+ skipBtn.disabled = false;
791
+ if (submitBtn) submitBtn.disabled = false;
792
+ showError("Couldn't save your note. Try again?");
793
+ }
768
794
  } else {
769
- opts.onSkip();
770
795
  swapToSent("All good. You can close this tab.");
771
796
  }
772
797
  };
@@ -823,6 +848,16 @@ async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {})
823
848
  }
824
849
  const trimmedEndNote = extras.endNote?.trim();
825
850
  if (trimmedEndNote) body.endNote = trimmedEndNote;
851
+ if (extras.notes && extras.notes.length > 0) {
852
+ body.notes = extras.notes.slice(0, 200).map((n) => ({
853
+ atMs: Math.max(0, Math.round(n.atMs)),
854
+ text: n.text
855
+ }));
856
+ }
857
+ if (extras.sdkSessionId) body.sdkSessionId = extras.sdkSessionId;
858
+ if (typeof extras.replayOffsetMs === "number") {
859
+ body.replayOffsetMs = Math.max(0, Math.round(extras.replayOffsetMs));
860
+ }
826
861
  const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/finalise`, {
827
862
  method: "POST",
828
863
  headers: { "Content-Type": "application/json" },
@@ -834,7 +869,7 @@ async function finaliseSession(apiUrl, sessionId, durationSeconds, extras = {})
834
869
  return false;
835
870
  }
836
871
  }
837
- async function postNote(apiUrl, sessionId, atMs, text, logger) {
872
+ async function postNoteOnce(apiUrl, sessionId, atMs, text, logger) {
838
873
  try {
839
874
  const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/user-test-sessions/${encodeURIComponent(sessionId)}/notes`, {
840
875
  method: "POST",
@@ -844,14 +879,26 @@ async function postNote(apiUrl, sessionId, atMs, text, logger) {
844
879
  });
845
880
  if (!res.ok) {
846
881
  logger.warn(`note POST rejected with ${res.status}`);
847
- return false;
882
+ return { ok: false, transient: res.status >= 500 || res.status === 408 || res.status === 429 };
848
883
  }
849
- return true;
884
+ let id;
885
+ try {
886
+ const json = await res.json();
887
+ if (typeof json.id === "string") id = json.id;
888
+ } catch {
889
+ }
890
+ return { ok: true, id, transient: false };
850
891
  } catch (err) {
851
892
  logger.warn("note POST failed", err);
852
- return false;
893
+ return { ok: false, transient: true };
853
894
  }
854
895
  }
896
+ async function postNoteWithRetry(apiUrl, sessionId, atMs, text, logger) {
897
+ const first = await postNoteOnce(apiUrl, sessionId, atMs, text, logger);
898
+ if (first.ok || !first.transient) return first;
899
+ await new Promise((resolve) => setTimeout(resolve, 400 + Math.floor(Math.random() * 200)));
900
+ return postNoteOnce(apiUrl, sessionId, atMs, text, logger);
901
+ }
855
902
  async function flushPendingFromIdb(store, ctx) {
856
903
  if (!store.sessionId) return;
857
904
  const pending = await idbListChunks(store.sessionId);
@@ -972,7 +1019,9 @@ function stopRecording(store) {
972
1019
  }
973
1020
  async function finishFlow(store, ctx, opts) {
974
1021
  if (store.cancelled) return;
1022
+ if (store.finishFlowRan) return;
975
1023
  if (store.indicatorState === "finishing" || store.indicatorState === "done") return;
1024
+ store.finishFlowRan = true;
976
1025
  store.indicatorState = "finishing";
977
1026
  flushMuteIfActive(store);
978
1027
  renderIndicatorState(store);
@@ -980,10 +1029,24 @@ async function finishFlow(store, ctx, opts) {
980
1029
  await store.uploadQueue;
981
1030
  await flushPendingFromIdb(store, ctx);
982
1031
  const durationSeconds = (Date.now() - store.startedAt) / 1e3;
1032
+ const replayLinkage = {};
1033
+ const linkageSdkSessionId = ctx.getSdkSessionId ? ctx.getSdkSessionId() : void 0;
1034
+ if (linkageSdkSessionId) replayLinkage.sdkSessionId = linkageSdkSessionId;
1035
+ if (store.replayOffsetAtStartMs !== null) {
1036
+ replayLinkage.replayOffsetMs = store.replayOffsetAtStartMs;
1037
+ }
983
1038
  if (store.sessionId) {
1039
+ const unackedNotes = store.notes.filter((n) => !n.acked).map((n) => ({ atMs: n.atMs, text: n.text }));
984
1040
  const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
985
- mutedSegments: store.mutedSegments
1041
+ mutedSegments: store.mutedSegments,
1042
+ notes: unackedNotes,
1043
+ ...replayLinkage
986
1044
  });
1045
+ if (ok) {
1046
+ for (const n of store.notes) {
1047
+ if (!n.acked) n.acked = true;
1048
+ }
1049
+ }
987
1050
  store.indicatorState = ok ? "done" : "error";
988
1051
  } else {
989
1052
  store.indicatorState = "error";
@@ -994,10 +1057,16 @@ async function finishFlow(store, ctx, opts) {
994
1057
  onSubmitNote: async (text) => {
995
1058
  if (!store.sessionId) return;
996
1059
  store.endNote = text;
997
- await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
998
- mutedSegments: store.mutedSegments,
999
- endNote: text
1060
+ const stillUnacked = store.notes.filter((n) => !n.acked).map((n) => ({ atMs: n.atMs, text: n.text }));
1061
+ const ok = await finaliseSession(store.options.apiUrl, store.sessionId, durationSeconds, {
1062
+ endNote: text,
1063
+ notes: stillUnacked,
1064
+ ...replayLinkage
1000
1065
  });
1066
+ if (!ok) throw new Error("finalise failed");
1067
+ for (const n of store.notes) {
1068
+ if (!n.acked) n.acked = true;
1069
+ }
1001
1070
  },
1002
1071
  onSkip: () => {
1003
1072
  }
@@ -1044,10 +1113,13 @@ function userTest(options = {}) {
1044
1113
  mutedSinceMs: null,
1045
1114
  mutedSegments: [],
1046
1115
  muteToastShown: false,
1116
+ muteToastTimers: [],
1047
1117
  notes: [],
1048
1118
  notesPopoverOpen: false,
1049
1119
  notePopoverAtMs: null,
1050
- endNote: ""
1120
+ endNote: "",
1121
+ finishFlowRan: false,
1122
+ replayOffsetAtStartMs: null
1051
1123
  };
1052
1124
  ctx.setStore(store);
1053
1125
  const onFinish = () => {
@@ -1077,11 +1149,19 @@ function userTest(options = {}) {
1077
1149
  store,
1078
1150
  (text) => {
1079
1151
  const atMs = store.notePopoverAtMs ?? Math.max(0, Date.now() - store.startedAt);
1080
- store.notes.push({ atMs, text });
1152
+ const note = { atMs, text, acked: false };
1153
+ store.notes.push(note);
1081
1154
  closeNote();
1082
1155
  renderNotesCount(store);
1083
1156
  if (store.sessionId) {
1084
- void postNote(store.options.apiUrl, store.sessionId, atMs, text, ctx.logger);
1157
+ const sessionId = store.sessionId;
1158
+ void (async () => {
1159
+ const result = await postNoteWithRetry(store.options.apiUrl, sessionId, atMs, text, ctx.logger);
1160
+ if (result.ok) {
1161
+ note.acked = true;
1162
+ if (result.id) note.serverId = result.id;
1163
+ }
1164
+ })();
1085
1165
  }
1086
1166
  },
1087
1167
  () => closeNote()
@@ -1134,6 +1214,8 @@ function userTest(options = {}) {
1134
1214
  }
1135
1215
  store.sessionId = created.sessionId;
1136
1216
  store.clientId = created.clientId;
1217
+ const replayStartMs = ctx.getReplayStartMs ? ctx.getReplayStartMs() : null;
1218
+ store.replayOffsetAtStartMs = replayStartMs === null ? null : Math.max(0, store.startedAt - replayStartMs);
1137
1219
  store.tasks = created.tasks;
1138
1220
  if (store.tasks.length > 0 && store.indicatorRoot && !merged.hideIndicator) {
1139
1221
  const bar = store.indicatorRoot.querySelector(".bar");
@@ -1164,6 +1246,13 @@ function userTest(options = {}) {
1164
1246
  document.removeEventListener("keydown", store.keydownHandler);
1165
1247
  store.keydownHandler = null;
1166
1248
  }
1249
+ for (const id of store.muteToastTimers) {
1250
+ try {
1251
+ window.clearTimeout(id);
1252
+ } catch {
1253
+ }
1254
+ }
1255
+ store.muteToastTimers = [];
1167
1256
  if (store.indicator && store.indicator.parentNode) {
1168
1257
  store.indicator.parentNode.removeChild(store.indicator);
1169
1258
  }