@burdenoff/website-sdk 2026.1105.2 → 2026.1106.1

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.
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkXIZE37IB_js = require('../chunk-XIZE37IB.js');
3
+ var chunkZ2UP22GV_js = require('../chunk-Z2UP22GV.js');
4
4
  var chunkN7E6Z2GF_js = require('../chunk-N7E6Z2GF.js');
5
5
  var chunkC6OJIHNO_js = require('../chunk-C6OJIHNO.js');
6
6
  require('../chunk-ZSTFX7FV.js');
@@ -71,6 +71,25 @@ function readSttOffer(value) {
71
71
  }
72
72
  return { token, expiresAt };
73
73
  }
74
+ var MIC_MAX_PRESS_SECONDS = 30;
75
+ var AVATAR_MIC_PAUSE_MIN_MS = 2e3;
76
+ var AVATAR_MIC_PAUSE_MAX_MS = 2500;
77
+ var UNREADABLE_MIC = { mode: "unreadable" };
78
+ function readMicOffer(value) {
79
+ if (!isRecord(value)) return UNREADABLE_MIC;
80
+ const keys = Object.keys(value).sort();
81
+ const { mode, maxPressSeconds, pauseMs } = value;
82
+ if (keys[0] !== "maxPressSeconds" || keys[1] !== "mode" || mode !== "browser" || maxPressSeconds !== MIC_MAX_PRESS_SECONDS) {
83
+ return UNREADABLE_MIC;
84
+ }
85
+ if (keys.length === 2) {
86
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS };
87
+ }
88
+ if (keys.length !== 3 || keys[2] !== "pauseMs" || typeof pauseMs !== "number" || !Number.isSafeInteger(pauseMs) || pauseMs < AVATAR_MIC_PAUSE_MIN_MS || pauseMs > AVATAR_MIC_PAUSE_MAX_MS) {
89
+ return UNREADABLE_MIC;
90
+ }
91
+ return { mode: "browser", maxPressSeconds: MIC_MAX_PRESS_SECONDS, pauseMs };
92
+ }
74
93
  function readAvailability(status, body) {
75
94
  if (status !== 200 || !isRecord(body)) return INERT;
76
95
  const { available, reason } = body;
@@ -81,10 +100,11 @@ function readAvailability(status, body) {
81
100
  const { consentVersion, maxSessionSeconds } = body;
82
101
  const botCheck = readBotCheck(body.botCheck);
83
102
  const questions = readQuestions(body.questions);
84
- if (available !== true || typeof consentVersion !== "string" || !chunkXIZE37IB_js.isAvatarVersion(consentVersion) || typeof maxSessionSeconds !== "number" || !Number.isInteger(maxSessionSeconds) || maxSessionSeconds < 1 || botCheck === null || questions === null) {
103
+ if (available !== true || typeof consentVersion !== "string" || !chunkZ2UP22GV_js.isAvatarVersion(consentVersion) || typeof maxSessionSeconds !== "number" || !Number.isInteger(maxSessionSeconds) || maxSessionSeconds < 1 || botCheck === null || questions === null) {
85
104
  return INERT;
86
105
  }
87
106
  const stt = readSttOffer(body.stt);
107
+ const mic = Object.prototype.hasOwnProperty.call(body, "mic") ? readMicOffer(body.mic) : void 0;
88
108
  return {
89
109
  kind: "available",
90
110
  offer: {
@@ -92,15 +112,19 @@ function readAvailability(status, body) {
92
112
  maxSessionSeconds,
93
113
  botCheck,
94
114
  questions,
95
- ...stt === void 0 ? {} : { stt }
115
+ ...stt === void 0 ? {} : { stt },
116
+ ...mic === void 0 ? {} : { mic }
96
117
  }
97
118
  };
98
119
  }
99
120
  const meaning = available ? void 0 : AVAILABILITY_REASONS.get(reason);
100
- return meaning === void 0 || meaning === "inert" ? INERT : {
121
+ if (meaning === void 0 || meaning === "inert") return INERT;
122
+ const named = body.consentVersion;
123
+ return {
101
124
  kind: "refused",
102
125
  reason: meaning,
103
- questions: body.questions === "any" ? "any" : "suggested"
126
+ questions: body.questions === "any" ? "any" : "suggested",
127
+ ...typeof named === "string" && chunkZ2UP22GV_js.isAvatarVersion(named) ? { consentVersion: named } : {}
104
128
  };
105
129
  }
106
130
  var CREATE_REFUSALS = /* @__PURE__ */ new Map([
@@ -205,7 +229,7 @@ async function requestAvailability(target) {
205
229
  }
206
230
  }
207
231
  async function requestAvailabilityAnswer(target) {
208
- if (!chunkXIZE37IB_js.isAvatarSite(target.site) || !LOCALE.test(target.locale)) return INERT;
232
+ if (!chunkZ2UP22GV_js.isAvatarSite(target.site) || !LOCALE.test(target.locale)) return INERT;
209
233
  const query = new URLSearchParams({
210
234
  site: target.site,
211
235
  locale: target.locale
@@ -847,6 +871,7 @@ var STRINGS = {
847
871
  function avatarLiveStrings(locale) {
848
872
  return STRINGS[locale] ?? EN;
849
873
  }
874
+ var BROWSER_MIC_NOTICE_VERSION = "web-mic-2026-11-21-c";
850
875
  var CONSENT = /* @__PURE__ */ new Map([
851
876
  [
852
877
  "web-consent-2026-11",
@@ -1024,10 +1049,76 @@ var CONSENT = /* @__PURE__ */ new Map([
1024
1049
  followUps: true
1025
1050
  }
1026
1051
  }
1052
+ ],
1053
+ [
1054
+ // Stage C (BOFF-9797, the operator's decision of 2026-11-05): the mic through the browser's own speech recognition.
1055
+ // A DRAFT for the operator's privacy review that no environment uses yet (global-speech-svc reserves the same two
1056
+ // versions, and pairs them with its AVATAR_WEB_MIC=browser): web-consent-2026-11-19-d with three changes only. The
1057
+ // intro warns against personal details said into the microphone and says a tap on it stops the answer being read;
1058
+ // the processing says which speech service turns the voice into text (the browser's own, under its own terms, with
1059
+ // the vendors as examples) and that a live session is told of a tap and of a recording, never of the words; the
1060
+ // storage says the camera is not used and that the voice never reaches us, nor the text until Send, in place of
1061
+ // "Your microphone and camera are not used.", which is true only with no mic. The notice files of record and their
1062
+ // digests are a gitops change of their own, made with the review.
1063
+ "web-consent-2026-11-21-c",
1064
+ {
1065
+ en: {
1066
+ title: "Live AI avatar (beta)",
1067
+ introProduct: null,
1068
+ intro: "This is an AI avatar, not a real person. It reads aloud the answers to the questions you ask in the Avatar tab, provided each passes the speech checks. After an answer, it waits about thirty seconds for your next question, then ends. A session lasts at most three minutes; if an answer doesn't fit in the time left, it reads only the first part and says so, or doesn't read it, and the full answer stays in Chat. The answers are AI-generated and can be wrong. Don't put personal details in a question you type. Don't say them into the microphone either. Tapping the microphone while the avatar speaks stops that answer.",
1069
+ processing: "When you start, Cloudflare checks for bots. The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address. If you use the microphone, the speech service your browser uses turns your voice into text and may receive the audio: for example Google for Chrome on a computer or Android phone, Apple for Safari and on iPhone and iPad, and Microsoft for Edge on a computer, each under its own privacy terms. During a live session, our server is told when you tap the microphone and while you record (never what you say), so the avatar stops and waits.",
1070
+ storage: "Your camera is not used. We never receive your voice: the text appears in the question box for you to check, and none of what you said reaches us until you press Send. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
1071
+ privacyLink: "How we handle this",
1072
+ botCheck: "turnstile",
1073
+ questions: "any",
1074
+ followUps: true,
1075
+ micNotice: BROWSER_MIC_NOTICE_VERSION
1076
+ }
1077
+ }
1078
+ ],
1079
+ [
1080
+ // The words of web-consent-2026-11-21-c without "When you start, Cloudflare checks for bots.", for a server that runs
1081
+ // no bot check. A DRAFT, like it.
1082
+ "web-consent-2026-11-22-c",
1083
+ {
1084
+ en: {
1085
+ title: "Live AI avatar (beta)",
1086
+ introProduct: null,
1087
+ intro: "This is an AI avatar, not a real person. It reads aloud the answers to the questions you ask in the Avatar tab, provided each passes the speech checks. After an answer, it waits about thirty seconds for your next question, then ends. A session lasts at most three minutes; if an answer doesn't fit in the time left, it reads only the first part and says so, or doesn't read it, and the full answer stays in Chat. The answers are AI-generated and can be wrong. Don't put personal details in a question you type. Don't say them into the microphone either. Tapping the microphone while the avatar speaks stops that answer.",
1088
+ processing: "The answer text goes to Google to create the voice. Our server sends that voice through LiveKit to Protoface, which makes the avatar video streamed through LiveKit. Your browser connects to LiveKit, which sees your IP address. If you use the microphone, the speech service your browser uses turns your voice into text and may receive the audio: for example Google for Chrome on a computer or Android phone, Apple for Safari and on iPhone and iPad, and Microsoft for Edge on a computer, each under its own privacy terms. During a live session, our server is told when you tap the microphone and while you record (never what you say), so the avatar stops and waits.",
1089
+ storage: "Your camera is not used. We never receive your voice: the text appears in the question box for you to check, and none of what you said reaches us until you press Send. We do not record the live session or store the generated voice. We keep a record of this consent. The privacy notice explains our safeguards, storage periods and the providers' processing. For people aged eighteen and over.",
1090
+ privacyLink: "How we handle this",
1091
+ botCheck: "off",
1092
+ questions: "any",
1093
+ followUps: true,
1094
+ micNotice: BROWSER_MIC_NOTICE_VERSION
1095
+ }
1096
+ }
1097
+ ],
1098
+ [
1099
+ // Stage C3 (BOFF-9797, the operator's final decision of 2026-11-06, "nothing at all"): the voice conversation with NO
1100
+ // notice in the Avatar tab. Typed questions, Stage B's three minutes, Stage D's answers after the first, the
1101
+ // browser's own recognizer (AVATAR_WEB_MIC=browser) with the server's pause, and no bot check (alpha and prod both
1102
+ // run it with AVATAR_WEB_BOT_CHECK=off). No words, so nothing to agree to: the server must record no consent for it
1103
+ // and hold no notice digest for it, and the create names it as a protocol token only. Speech processing and the
1104
+ // question sent by itself are the site's privacy policy's to describe. PROD waits for the privacy reviewer's
1105
+ // approval of this experience. (The
1106
+ // notice versions web-consent-2026-11-23-v and -24-v, which no environment ever ran, are retired with the dialog
1107
+ // that showed them: this SDK has no copy for them, so a server naming one gets the Avatar tab as it was.)
1108
+ "web-voice-2026-11-25-n",
1109
+ {
1110
+ en: {
1111
+ notice: "none",
1112
+ botCheck: "off",
1113
+ questions: "any",
1114
+ followUps: true,
1115
+ voice: { pauseMs: 2e3 }
1116
+ }
1117
+ }
1027
1118
  ]
1028
1119
  ]);
1029
1120
  function avatarConsentCopy(version, locale) {
1030
- if (!chunkXIZE37IB_js.isAvatarLiveLocaleReviewed(locale)) return null;
1121
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1031
1122
  return CONSENT.get(version)?.[locale] ?? null;
1032
1123
  }
1033
1124
  var MIC_EN = {
@@ -1051,9 +1142,33 @@ var MIC_EN = {
1051
1142
  };
1052
1143
  var MIC_STRINGS = { en: MIC_EN };
1053
1144
  function avatarMicStrings(locale) {
1054
- if (!chunkXIZE37IB_js.isAvatarLiveLocaleReviewed(locale)) return null;
1145
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1055
1146
  return MIC_STRINGS[locale] ?? null;
1056
1147
  }
1148
+ var VOICE_EN = {
1149
+ talk: "Talk",
1150
+ talkInterrupt: "Stop the avatar and talk",
1151
+ stopListening: "Stop listening",
1152
+ start: "Start",
1153
+ cancelled: "The avatar didn't start. Press Start to try again.",
1154
+ starting: "Starting the microphone\u2026",
1155
+ listening: "Listening\u2026",
1156
+ pausing: "Still listening \u2014 take your time",
1157
+ sending: "Sending your question\u2026",
1158
+ again: "The mic listens again after the answer.",
1159
+ off: "Voice is off. Tap Talk to speak, or type your question.",
1160
+ inBox: "Voice is off. Your words are in the box: edit them or press Send.",
1161
+ atLimit: "The mic stopped at thirty seconds. Your words are in the box: press Send to ask.",
1162
+ noQuestion: "No question was heard. Tap Talk to try again, or type your question.",
1163
+ tapNext: "Tap Talk to ask your next question.",
1164
+ headline: "Tap Talk and ask. The avatar answers aloud.",
1165
+ placeholder: "Or type\u2026"
1166
+ };
1167
+ var VOICE_STRINGS = { en: VOICE_EN };
1168
+ function avatarVoiceStrings(locale) {
1169
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1170
+ return VOICE_STRINGS[locale] ?? null;
1171
+ }
1057
1172
  var AVATAR_MIC_NOTICE_VERSION = "web-mic-2026-11-05";
1058
1173
  var MIC_NOTICE = /* @__PURE__ */ new Map([
1059
1174
  [
@@ -1068,20 +1183,38 @@ var MIC_NOTICE = /* @__PURE__ */ new Map([
1068
1183
  privacyLink: "How we handle this"
1069
1184
  }
1070
1185
  }
1186
+ ],
1187
+ [
1188
+ // The browser's own speech recognition (Stage C): the browser's speech service, named by example where each is
1189
+ // true, under its own terms; nothing of the voice reaches us, nor the text before Send; a live session told only of
1190
+ // a tap and of a recording. No daily limits: nothing is counted on the server.
1191
+ BROWSER_MIC_NOTICE_VERSION,
1192
+ {
1193
+ en: {
1194
+ title: "Ask with your voice",
1195
+ processing: "The speech service your browser uses turns your voice into text and may receive the audio: for example Google for Chrome on a computer or Android phone, Apple for Safari and on iPhone and iPad, and Microsoft for Edge on a computer. Its own privacy terms apply.",
1196
+ storage: "We never receive your voice, and your camera is not used.",
1197
+ text: "The text appears in the box for you to check. None of what you said reaches us until you press Send; once you send it, it is a chat message, kept like any other. During a live session, we are told when you tap the microphone and while you record (never what you say), so the avatar stops and waits.",
1198
+ limits: "Each recording stops after thirty seconds at the latest.",
1199
+ privacyLink: "How we handle this"
1200
+ }
1201
+ }
1071
1202
  ]
1072
1203
  ]);
1073
1204
  function avatarMicNotice(version, locale) {
1074
- if (!chunkXIZE37IB_js.isAvatarLiveLocaleReviewed(locale)) return null;
1205
+ if (!chunkZ2UP22GV_js.isAvatarLiveLocaleReviewed(locale)) return null;
1075
1206
  return MIC_NOTICE.get(version)?.[locale] ?? null;
1076
1207
  }
1077
1208
  var PRIVACY_HREF = "/privacy#live-avatar";
1078
1209
  var LIMIT_SECONDS = 30;
1079
1210
  var TICK_MS = 250;
1080
1211
  var IDLE = { name: "idle" };
1081
- var FOR_GOOD = /* @__PURE__ */ new Set([
1082
- "unavailable",
1083
- "limit"
1084
- ]);
1212
+ var EN_IN = { lang: "en-IN", fallbackLang: "en-US" };
1213
+ var BROWSER_MIC_LANGUAGES = { en: EN_IN };
1214
+ var FOR_GOOD = {
1215
+ stream: /* @__PURE__ */ new Set(["unavailable", "limit"]),
1216
+ browser: /* @__PURE__ */ new Set(["unsupported"])
1217
+ };
1085
1218
  var FAILURE_TEXT = {
1086
1219
  denied: "micDenied",
1087
1220
  unsupported: "micUnsupported",
@@ -1090,14 +1223,33 @@ var FAILURE_TEXT = {
1090
1223
  busy: "micBusy",
1091
1224
  failed: "micFailed"
1092
1225
  };
1226
+ var loadedMic = null;
1093
1227
  function loadAvatarMic() {
1094
- return import('../avatar-mic/index.js').then((loaded) => ({
1095
- createAvatarMic: loaded.createAvatarMic
1096
- }));
1228
+ return import('../avatar-mic/index.js').then((loaded) => {
1229
+ const module = {
1230
+ createAvatarMic: loaded.createAvatarMic,
1231
+ createBrowserAvatarMic: loaded.createBrowserAvatarMic
1232
+ };
1233
+ loadedMic = module;
1234
+ return module;
1235
+ });
1097
1236
  }
1098
1237
  function canUseAvatarMic() {
1099
1238
  return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function" && typeof AudioContext !== "undefined" && typeof AudioWorkletNode !== "undefined" && typeof WebSocket !== "undefined";
1100
1239
  }
1240
+ function canUseBrowserMic() {
1241
+ if (typeof window === "undefined" || typeof navigator === "undefined") {
1242
+ return false;
1243
+ }
1244
+ if (window.isSecureContext !== true) return false;
1245
+ const recognizer = Reflect.get(window, "SpeechRecognition") ?? Reflect.get(window, "webkitSpeechRecognition");
1246
+ if (typeof recognizer !== "function") return false;
1247
+ const agent = navigator.userAgent ?? "";
1248
+ if (/\bFirefox\//.test(agent) || /\bFxiOS\//.test(agent)) return false;
1249
+ if (/\bOPR\//.test(agent)) return false;
1250
+ return !("brave" in navigator);
1251
+ }
1252
+ var REPLAY_PRIVATE = "rr-block rr-mask";
1101
1253
  function clock(seconds) {
1102
1254
  const whole = Math.max(0, Math.floor(seconds));
1103
1255
  return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
@@ -1108,47 +1260,110 @@ function withHeardText(current, heard, maxLength) {
1108
1260
  const joined = current.trim() === "" ? text : `${current.replace(/\s+$/, "")} ${text}`;
1109
1261
  return joined.slice(0, Math.max(0, maxLength));
1110
1262
  }
1263
+ function noticeOf(source) {
1264
+ return source.kind === "stream" ? AVATAR_MIC_NOTICE_VERSION : source.notice;
1265
+ }
1266
+ function micKeyOf(source, locale) {
1267
+ if (source.kind === "stream") return `stream ${source.base}`;
1268
+ const lang = BROWSER_MIC_LANGUAGES[locale]?.lang ?? "";
1269
+ return source.pauseMs === void 0 ? `browser ${lang}` : `browser ${lang} pause ${source.pauseMs}`;
1270
+ }
1271
+ function underWay(state) {
1272
+ return state.name === "starting" || state.name === "listening" || state.name === "finishing";
1273
+ }
1111
1274
  function useAvatarMic({
1112
1275
  enabled,
1113
1276
  locale,
1114
- base,
1115
- offer,
1116
- refreshOffer,
1277
+ source,
1117
1278
  onText,
1118
- onStart
1279
+ onStart,
1280
+ onTurn,
1281
+ onPress,
1282
+ echoOf,
1283
+ interruptible = false,
1284
+ note = null
1119
1285
  }) {
1120
1286
  const S = avatarMicStrings(locale);
1121
- const copy = avatarMicNotice(AVATAR_MIC_NOTICE_VERSION, locale);
1287
+ const noticeVersion = source === null ? null : noticeOf(source);
1288
+ const kind = source?.kind ?? null;
1289
+ const micKey = source === null ? null : micKeyOf(source, locale);
1290
+ const paused = source?.kind === "browser" && source.pauseMs !== void 0;
1291
+ const conversation = paused && noticeVersion !== null && avatarConsentCopy(noticeVersion, locale)?.notice === "none";
1292
+ const copy = noticeVersion === null || paused ? null : avatarMicNotice(noticeVersion, locale);
1293
+ const V = conversation ? avatarVoiceStrings(locale) : null;
1122
1294
  const uid = react.useId();
1123
1295
  const noticeTitleId = `${uid}-mic-notice`;
1124
- const [withdrawn, setWithdrawn] = react.useState(null);
1125
- const [supported, setSupported] = react.useState(false);
1126
- react.useEffect(() => setSupported(canUseAvatarMic()), []);
1127
- const possible = enabled && supported && base !== null && S !== null && copy !== null;
1128
- const offered = possible && withdrawn === null;
1296
+ const [withdrawn, setWithdrawn] = react.useState({});
1297
+ const [supported, setSupported] = react.useState({ stream: false, browser: false });
1298
+ react.useEffect(
1299
+ () => setSupported({ stream: canUseAvatarMic(), browser: canUseBrowserMic() }),
1300
+ []
1301
+ );
1302
+ const usable = source === null ? false : source.kind === "stream" ? supported.stream : supported.browser && BROWSER_MIC_LANGUAGES[locale] !== void 0;
1303
+ const showable = kind !== null && usable && S !== null && (paused ? conversation && V !== null : copy !== null);
1304
+ const possible = enabled && showable;
1305
+ const withdrawnReason = kind === null ? null : withdrawn[kind] ?? null;
1306
+ const offered = possible && withdrawnReason === null;
1129
1307
  const [state, setState] = react.useState(IDLE);
1130
1308
  const [noticeOpen, setNoticeOpen] = react.useState(false);
1131
- const agreedRef = react.useRef(false);
1132
- const [loading2, setLoading] = react.useState(false);
1309
+ const agreedRef = react.useRef(null);
1310
+ const [loading2, setLoadingState] = react.useState(false);
1311
+ const loadingRef = react.useRef(false);
1312
+ const setLoading = react.useCallback((value) => {
1313
+ loadingRef.current = value;
1314
+ setLoadingState(value);
1315
+ }, []);
1133
1316
  const [loadFailed, setLoadFailed] = react.useState(false);
1134
1317
  const [now, setNow] = react.useState(() => Date.now());
1135
- const micRef = react.useRef(null);
1318
+ const micRef = react.useRef(
1319
+ null
1320
+ );
1136
1321
  const seqRef = react.useRef(0);
1137
1322
  const buttonRef = react.useRef(null);
1138
1323
  const allowRef = react.useRef(null);
1139
- const usedEpochRef = react.useRef(0);
1140
- const latest = react.useRef({ offer, refreshOffer, onText, onStart, base });
1141
- latest.current = { offer, refreshOffer, onText, onStart, base };
1324
+ const usedRef = react.useRef(null);
1325
+ const gestureRef = react.useRef(false);
1326
+ const [, setUsedTick] = react.useState(0);
1327
+ const latest = react.useRef({
1328
+ source,
1329
+ locale,
1330
+ noticeVersion,
1331
+ onText,
1332
+ onStart,
1333
+ onTurn,
1334
+ onPress,
1335
+ echoOf,
1336
+ conversation,
1337
+ offered
1338
+ });
1339
+ latest.current = {
1340
+ source,
1341
+ locale,
1342
+ noticeVersion,
1343
+ onText,
1344
+ onStart,
1345
+ onTurn,
1346
+ onPress,
1347
+ echoOf,
1348
+ conversation,
1349
+ offered
1350
+ };
1142
1351
  const cancel = react.useCallback(() => {
1143
1352
  seqRef.current += 1;
1144
1353
  setLoading(false);
1145
1354
  setLoadFailed(false);
1146
1355
  setNoticeOpen(false);
1147
1356
  micRef.current?.mic.cancel();
1148
- }, []);
1357
+ }, [setLoading]);
1149
1358
  react.useEffect(() => {
1150
1359
  if (!offered) cancel();
1151
1360
  }, [offered, cancel]);
1361
+ const keyRef = react.useRef(micKey);
1362
+ react.useEffect(() => {
1363
+ if (keyRef.current === micKey) return;
1364
+ keyRef.current = micKey;
1365
+ cancel();
1366
+ }, [micKey, cancel]);
1152
1367
  react.useEffect(
1153
1368
  () => () => {
1154
1369
  seqRef.current += 1;
@@ -1156,43 +1371,134 @@ function useAvatarMic({
1156
1371
  },
1157
1372
  []
1158
1373
  );
1159
- const micFor = react.useCallback(async (target) => {
1160
- const held = micRef.current;
1161
- if (held !== null && held.base === target) return held.mic;
1162
- held?.mic.cancel();
1163
- const loaded = await loadAvatarMic();
1164
- const mic = loaded.createAvatarMic({
1165
- base: target,
1166
- notice: AVATAR_MIC_NOTICE_VERSION,
1167
- offer: () => latest.current.offer,
1168
- refreshOffer: () => latest.current.refreshOffer(),
1169
- onState: setState
1170
- });
1171
- micRef.current = { base: target, mic };
1172
- return mic;
1173
- }, []);
1174
- const press = react.useCallback(() => {
1175
- const target = latest.current.base;
1176
- if (target === null) return;
1177
- seqRef.current += 1;
1178
- const seq = seqRef.current;
1179
- setLoadFailed(false);
1180
- setLoading(true);
1181
- void micFor(target).then(
1182
- (mic) => {
1183
- if (seqRef.current !== seq) return;
1184
- setLoading(false);
1185
- mic.start();
1186
- },
1187
- () => {
1188
- if (seqRef.current !== seq) return;
1374
+ const micOf = react.useCallback(
1375
+ (loaded, wanted) => {
1376
+ const key = micKeyOf(wanted, latest.current.locale);
1377
+ const held = micRef.current;
1378
+ if (held !== null && held.key === key) return held.mic;
1379
+ held?.mic.cancel();
1380
+ let mic;
1381
+ if (wanted.kind === "stream") {
1382
+ const stream = () => {
1383
+ const current = latest.current.source;
1384
+ return current?.kind === "stream" ? current : null;
1385
+ };
1386
+ mic = loaded.createAvatarMic({
1387
+ base: wanted.base,
1388
+ notice: AVATAR_MIC_NOTICE_VERSION,
1389
+ offer: () => stream()?.offer ?? null,
1390
+ refreshOffer: () => stream()?.refreshOffer() ?? Promise.resolve(null),
1391
+ onState: setState
1392
+ });
1393
+ } else {
1394
+ const language = BROWSER_MIC_LANGUAGES[latest.current.locale] ?? EN_IN;
1395
+ const { pauseMs } = wanted;
1396
+ mic = loaded.createBrowserAvatarMic({
1397
+ lang: language.lang,
1398
+ fallbackLang: language.fallbackLang,
1399
+ maxPressMs: LIMIT_SECONDS * 1e3,
1400
+ onState: setState,
1401
+ // The voice conversation: each press ends itself on the server's pause. The avatar's own words heard back
1402
+ // are looked for only in a press the conversation started by itself: one the visitor's own tap started runs
1403
+ // while the avatar is quiet, or muted here by that tap (a Talk while it speaks interrupts it), so its words are
1404
+ // the visitor's, whatever of the answer they repeat ("Delivery is free across Tamil Nadu?").
1405
+ ...pauseMs === void 0 ? {} : {
1406
+ endpoint: {
1407
+ pauseMs,
1408
+ echoOf: () => gestureRef.current ? null : latest.current.echoOf?.() ?? null
1409
+ }
1410
+ }
1411
+ });
1412
+ }
1413
+ micRef.current = { key, kind: wanted.kind, mic };
1414
+ return mic;
1415
+ },
1416
+ []
1417
+ );
1418
+ const press = react.useCallback(
1419
+ (gesture) => {
1420
+ const wanted = latest.current.source;
1421
+ if (wanted === null) return;
1422
+ seqRef.current += 1;
1423
+ const seq = seqRef.current;
1424
+ setLoadFailed(false);
1425
+ gestureRef.current = gesture;
1426
+ if (latest.current.conversation) latest.current.onPress?.(gesture);
1427
+ const held = micRef.current;
1428
+ const ready = wanted.kind !== "browser" ? null : held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? held.mic : loadedMic !== null ? micOf(loadedMic, wanted) : null;
1429
+ if (ready !== null) {
1189
1430
  setLoading(false);
1190
- setLoadFailed(true);
1431
+ ready.start();
1432
+ return;
1191
1433
  }
1192
- );
1193
- }, [micFor]);
1434
+ setLoading(true);
1435
+ const arriving = held !== null && held.key === micKeyOf(wanted, latest.current.locale) ? Promise.resolve(held.mic) : loadAvatarMic().then((loaded) => micOf(loaded, wanted));
1436
+ void arriving.then(
1437
+ (mic) => {
1438
+ if (seqRef.current !== seq) return;
1439
+ setLoading(false);
1440
+ mic.start();
1441
+ },
1442
+ () => {
1443
+ if (seqRef.current !== seq) return;
1444
+ setLoading(false);
1445
+ setLoadFailed(true);
1446
+ if (latest.current.conversation) {
1447
+ latest.current.onTurn?.({
1448
+ by: "failed",
1449
+ text: "",
1450
+ echo: false,
1451
+ gesture,
1452
+ failure: "failed"
1453
+ });
1454
+ }
1455
+ }
1456
+ );
1457
+ },
1458
+ [micOf, setLoading]
1459
+ );
1194
1460
  const recording = loading2 || state.name === "starting" || state.name === "listening";
1461
+ const finishing = kind === "browser" && state.name === "finishing";
1462
+ const preload = react.useCallback(() => {
1463
+ const seq = seqRef.current;
1464
+ void loadAvatarMic().then(
1465
+ (loaded) => {
1466
+ const wanted = latest.current.source;
1467
+ if (seqRef.current === seq && wanted?.kind === "browser") {
1468
+ micOf(loaded, wanted);
1469
+ }
1470
+ },
1471
+ () => void 0
1472
+ );
1473
+ }, [micOf]);
1474
+ const pressing = react.useCallback(() => {
1475
+ const held = micRef.current?.mic.state() ?? IDLE;
1476
+ return loadingRef.current || underWay(held) || (held.name === "done" || held.name === "failed") && held !== usedRef.current;
1477
+ }, []);
1478
+ const stopListening = react.useCallback(() => {
1479
+ const held = micRef.current?.mic.state() ?? IDLE;
1480
+ let text = "";
1481
+ if (held.name === "listening" || held.name === "finishing") {
1482
+ text = held.text;
1483
+ } else if ((held.name === "done" || held.name === "failed") && held !== usedRef.current) {
1484
+ usedRef.current = held;
1485
+ text = held.text;
1486
+ }
1487
+ const gesture = gestureRef.current;
1488
+ cancel();
1489
+ latest.current.onTurn?.({ by: "tap", text, echo: false, gesture });
1490
+ }, [cancel]);
1195
1491
  const tap = react.useCallback(() => {
1492
+ if (latest.current.conversation) {
1493
+ if (pressing()) {
1494
+ stopListening();
1495
+ return;
1496
+ }
1497
+ latest.current.onStart?.();
1498
+ agreedRef.current = latest.current.noticeVersion;
1499
+ press(true);
1500
+ return;
1501
+ }
1196
1502
  if (state.name === "listening" || state.name === "starting") {
1197
1503
  micRef.current?.mic.stop();
1198
1504
  return;
@@ -1201,24 +1507,60 @@ function useAvatarMic({
1201
1507
  cancel();
1202
1508
  return;
1203
1509
  }
1510
+ if (finishing) return;
1204
1511
  latest.current.onStart?.();
1205
- if (!agreedRef.current) {
1512
+ if (agreedRef.current !== latest.current.noticeVersion) {
1206
1513
  setNoticeOpen(true);
1207
- void loadAvatarMic().catch(() => void 0);
1514
+ preload();
1208
1515
  return;
1209
1516
  }
1210
- press();
1211
- }, [cancel, loading2, press, state.name]);
1517
+ press(true);
1518
+ }, [
1519
+ cancel,
1520
+ finishing,
1521
+ loading2,
1522
+ preload,
1523
+ press,
1524
+ pressing,
1525
+ state.name,
1526
+ stopListening
1527
+ ]);
1212
1528
  const allow = react.useCallback(() => {
1213
- agreedRef.current = true;
1529
+ agreedRef.current = latest.current.noticeVersion;
1214
1530
  setNoticeOpen(false);
1215
1531
  buttonRef.current?.focus();
1216
- press();
1532
+ press(true);
1217
1533
  }, [press]);
1218
1534
  const notNow = react.useCallback(() => {
1219
1535
  setNoticeOpen(false);
1220
1536
  buttonRef.current?.focus();
1221
1537
  }, []);
1538
+ const preparing = conversation && offered;
1539
+ react.useEffect(() => {
1540
+ if (preparing) preload();
1541
+ }, [preparing, preload]);
1542
+ const listen = react.useCallback(() => {
1543
+ if (!latest.current.conversation || !latest.current.offered || // Never before the visitor's own Talk on this page, under this version.
1544
+ agreedRef.current !== latest.current.noticeVersion || // A press under way, or one that has ended and whose outcome has not gone to the view yet (still that press: a
1545
+ // question that ended on its pause is about to be sent): the mic must not open over it.
1546
+ pressing()) {
1547
+ return false;
1548
+ }
1549
+ press(false);
1550
+ return true;
1551
+ }, [press, pressing]);
1552
+ const heard = react.useCallback(() => {
1553
+ const held = micRef.current?.mic.state() ?? IDLE;
1554
+ return held.name === "listening" || held.name === "finishing" ? held.text : "";
1555
+ }, []);
1556
+ const dropLoopPress = react.useCallback(() => {
1557
+ const held = micRef.current?.mic.state() ?? IDLE;
1558
+ if (!latest.current.conversation || gestureRef.current || !loadingRef.current && !underWay(held)) {
1559
+ return false;
1560
+ }
1561
+ cancel();
1562
+ return true;
1563
+ }, [cancel]);
1222
1564
  react.useEffect(() => {
1223
1565
  if (noticeOpen) allowRef.current?.focus();
1224
1566
  }, [noticeOpen]);
@@ -1230,24 +1572,71 @@ function useAvatarMic({
1230
1572
  return () => clearInterval(timer);
1231
1573
  }, [listeningSince]);
1232
1574
  react.useEffect(() => {
1233
- if (state.name === "done" && state.epoch !== usedEpochRef.current) {
1234
- usedEpochRef.current = state.epoch;
1235
- if (state.text !== "") latest.current.onText(state.text);
1236
- } else if (state.name === "failed" && FOR_GOOD.has(state.reason)) {
1237
- setWithdrawn(state.reason);
1575
+ const voice = latest.current.conversation;
1576
+ if (state.name === "done" && state !== usedRef.current) {
1577
+ usedRef.current = state;
1578
+ if (voice) {
1579
+ latest.current.onTurn?.({
1580
+ by: state.by ?? (state.limit ? "limit" : "tap"),
1581
+ text: state.text,
1582
+ echo: state.echo === true,
1583
+ gesture: gestureRef.current
1584
+ });
1585
+ setUsedTick((tick) => tick + 1);
1586
+ } else if (state.text !== "") {
1587
+ latest.current.onText(state.text);
1588
+ }
1589
+ return;
1590
+ }
1591
+ const pressed = micRef.current?.kind;
1592
+ if (state.name === "failed" && voice && state !== usedRef.current) {
1593
+ usedRef.current = state;
1594
+ const gesture = gestureRef.current;
1595
+ if (gesture && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1596
+ const reason = state.reason;
1597
+ setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1598
+ }
1599
+ latest.current.onTurn?.({
1600
+ by: "failed",
1601
+ text: state.text,
1602
+ echo: false,
1603
+ gesture,
1604
+ failure: state.reason
1605
+ });
1606
+ setUsedTick((tick) => tick + 1);
1607
+ return;
1608
+ }
1609
+ if (state.name === "failed" && !voice && pressed !== void 0 && FOR_GOOD[pressed].has(state.reason)) {
1610
+ const reason = state.reason;
1611
+ setWithdrawn((held) => ({ ...held, [pressed]: reason }));
1238
1612
  }
1239
1613
  }, [state]);
1614
+ const outcomeDue = (state.name === "done" || state.name === "failed") && state !== usedRef.current;
1615
+ const busy = loading2 || state.name === "starting" || state.name === "listening" || state.name === "finishing" || outcomeDue;
1616
+ const voiceControls = {
1617
+ mode: conversation ? "conversation" : "editable",
1618
+ speaking: conversation && offered && state.name === "listening" && state.genuine === true,
1619
+ busy,
1620
+ pressing,
1621
+ listen,
1622
+ heard,
1623
+ dropLoopPress
1624
+ };
1625
+ if (conversation && showable && V !== null && S !== null) {
1626
+ return conversationControls(V, S);
1627
+ }
1240
1628
  if (!possible || S === null || copy === null) {
1241
1629
  return {
1242
1630
  button: null,
1243
1631
  notice: null,
1244
1632
  lines: null,
1245
1633
  cancel,
1246
- recording: false
1634
+ recording: false,
1635
+ ...voiceControls
1247
1636
  };
1248
1637
  }
1249
- const status = withdrawn !== null ? S[FAILURE_TEXT[withdrawn]] : loading2 ? S.micStarting : loadFailed ? S.micFailed : state.name === "starting" ? S.micStarting : state.name === "listening" ? S.micListening : state.name === "finishing" ? S.micFinishing : state.name === "done" ? state.text === "" ? S.micNoSpeech : state.limit ? S.micDoneLimit : S.micDone : state.name === "failed" ? S[FAILURE_TEXT[state.reason]] : "";
1250
- const heard = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1638
+ const status = withdrawnReason !== null ? S[FAILURE_TEXT[withdrawnReason]] : loading2 ? S.micStarting : loadFailed ? S.micFailed : state.name === "starting" ? S.micStarting : state.name === "listening" ? S.micListening : state.name === "finishing" ? S.micFinishing : state.name === "done" ? state.text === "" ? S.micNoSpeech : state.limit ? S.micDoneLimit : S.micDone : state.name === "failed" ? S[FAILURE_TEXT[state.reason]] : "";
1639
+ const heardText = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1251
1640
  const button = !offered ? null : /* @__PURE__ */ jsxRuntime.jsx(
1252
1641
  "button",
1253
1642
  {
@@ -1255,73 +1644,25 @@ function useAvatarMic({
1255
1644
  type: "button",
1256
1645
  "aria-label": recording ? S.micStop : S.micStart,
1257
1646
  "aria-pressed": recording,
1647
+ "aria-disabled": finishing ? true : void 0,
1258
1648
  title: recording ? S.micStop : S.micStart,
1259
1649
  "data-boff-explore": "avatar-live-mic",
1260
1650
  "data-mic": loading2 ? "starting" : state.name,
1651
+ "data-mic-source": kind ?? void 0,
1261
1652
  className: chunkC6OJIHNO_js.cn(
1262
1653
  "inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1263
- recording ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
1654
+ recording ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
1655
+ finishing && "cursor-wait opacity-50"
1264
1656
  ),
1265
1657
  onClick: tap,
1266
1658
  children: recording ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, { "aria-hidden": "true", className: "h-4 w-4" })
1267
1659
  }
1268
1660
  );
1269
- const notice = offered && noticeOpen ? /* @__PURE__ */ jsxRuntime.jsxs(
1270
- "section",
1271
- {
1272
- "aria-labelledby": noticeTitleId,
1273
- "data-boff-explore": "avatar-live-mic-notice",
1274
- className: "w-full rounded-lg border border-border p-3 text-start",
1275
- children: [
1276
- /* @__PURE__ */ jsxRuntime.jsx("h3", { id: noticeTitleId, className: "text-sm font-semibold", children: copy.title }),
1277
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.processing }),
1278
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.storage }),
1279
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.text }),
1280
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: copy.limits }),
1281
- /* @__PURE__ */ jsxRuntime.jsx(
1282
- "a",
1283
- {
1284
- href: PRIVACY_HREF,
1285
- target: "_blank",
1286
- rel: "noopener noreferrer",
1287
- "data-boff-explore": "avatar-live-mic-privacy",
1288
- className: "mt-1 inline-block text-xs font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1289
- children: copy.privacyLink
1290
- }
1291
- ),
1292
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
1293
- /* @__PURE__ */ jsxRuntime.jsx(
1294
- "button",
1295
- {
1296
- ref: allowRef,
1297
- type: "button",
1298
- "data-boff-explore": "avatar-live-mic-allow",
1299
- className: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1300
- onClick: allow,
1301
- children: S.micAllow
1302
- }
1303
- ),
1304
- /* @__PURE__ */ jsxRuntime.jsx(
1305
- "button",
1306
- {
1307
- type: "button",
1308
- "data-boff-explore": "avatar-live-mic-not-now",
1309
- className: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1310
- onClick: notNow,
1311
- children: S.micNotNow
1312
- }
1313
- )
1314
- ] })
1315
- ]
1316
- }
1317
- ) : null;
1661
+ const notice = offered && noticeOpen ? noticeCard(copy, S.micAllow, S.micNotNow) : null;
1318
1662
  const listening = offered && state.name === "listening";
1319
1663
  const lines = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1320
1664
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1321
- listening ? /* @__PURE__ */ jsxRuntime.jsxs("span", { "aria-hidden": "true", className: "relative flex h-2 w-2 shrink-0", children: [
1322
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75 motion-reduce:animate-none" }),
1323
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
1324
- ] }) : null,
1665
+ listening ? listeningDot : null,
1325
1666
  /* @__PURE__ */ jsxRuntime.jsx("p", { role: "status", "data-boff-explore": "avatar-live-mic-status", children: status }),
1326
1667
  listening ? /* @__PURE__ */ jsxRuntime.jsx(
1327
1668
  "span",
@@ -1338,24 +1679,142 @@ function useAvatarMic({
1338
1679
  }
1339
1680
  ) : null
1340
1681
  ] }),
1341
- heard === "" ? null : /* @__PURE__ */ jsxRuntime.jsx(
1342
- "p",
1343
- {
1344
- "data-boff-explore": "avatar-live-mic-heard",
1345
- dir: "auto",
1346
- className: "line-clamp-3 break-words text-sm italic text-muted-foreground",
1347
- children: heard
1348
- }
1349
- )
1682
+ heardLine(heardText)
1350
1683
  ] });
1351
1684
  return {
1352
1685
  button,
1353
1686
  notice,
1354
1687
  lines,
1355
1688
  cancel,
1356
- recording: offered && state.name === "listening"
1689
+ recording: offered && state.name === "listening",
1690
+ ...voiceControls
1357
1691
  };
1692
+ function noticeCard(words, allowLabel, notNowLabel) {
1693
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1694
+ "section",
1695
+ {
1696
+ "aria-labelledby": noticeTitleId,
1697
+ "data-boff-explore": "avatar-live-mic-notice",
1698
+ className: "w-full rounded-lg border border-border p-3 text-start",
1699
+ children: [
1700
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { id: noticeTitleId, className: "text-sm font-semibold", children: words.title }),
1701
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.processing }),
1702
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.storage }),
1703
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.text }),
1704
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: words.limits }),
1705
+ /* @__PURE__ */ jsxRuntime.jsx(
1706
+ "a",
1707
+ {
1708
+ href: PRIVACY_HREF,
1709
+ target: "_blank",
1710
+ rel: "noopener noreferrer",
1711
+ "data-boff-explore": "avatar-live-mic-privacy",
1712
+ className: "mt-1 inline-block text-xs font-medium text-primary underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1713
+ children: words.privacyLink
1714
+ }
1715
+ ),
1716
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "mt-2 flex flex-wrap gap-2", children: [
1717
+ /* @__PURE__ */ jsxRuntime.jsx(
1718
+ "button",
1719
+ {
1720
+ ref: allowRef,
1721
+ type: "button",
1722
+ "data-boff-explore": "avatar-live-mic-allow",
1723
+ className: "inline-flex min-h-11 items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1724
+ onClick: allow,
1725
+ children: allowLabel
1726
+ }
1727
+ ),
1728
+ /* @__PURE__ */ jsxRuntime.jsx(
1729
+ "button",
1730
+ {
1731
+ type: "button",
1732
+ "data-boff-explore": "avatar-live-mic-not-now",
1733
+ className: "inline-flex min-h-11 items-center justify-center rounded-md border border-border bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1734
+ onClick: notNow,
1735
+ children: notNowLabel
1736
+ }
1737
+ )
1738
+ ] })
1739
+ ]
1740
+ }
1741
+ );
1742
+ }
1743
+ function heardLine(text) {
1744
+ return text === "" ? null : /* @__PURE__ */ jsxRuntime.jsx(
1745
+ "p",
1746
+ {
1747
+ "data-boff-explore": "avatar-live-mic-heard",
1748
+ dir: "auto",
1749
+ className: chunkC6OJIHNO_js.cn(
1750
+ // rrweb's default block and mask classes: a site's session replay records none of what is heard.
1751
+ REPLAY_PRIVATE,
1752
+ "line-clamp-3 break-words text-sm italic text-muted-foreground"
1753
+ ),
1754
+ children: text
1755
+ }
1756
+ );
1757
+ }
1758
+ function conversationControls(V2, S2) {
1759
+ const going = loading2 || state.name === "starting" || state.name === "listening" || state.name === "finishing";
1760
+ const label = busy ? V2.stopListening : V2.talk;
1761
+ const name = busy ? V2.stopListening : interruptible ? V2.talkInterrupt : V2.talk;
1762
+ const talk = !offered ? null : /* @__PURE__ */ jsxRuntime.jsxs(
1763
+ "button",
1764
+ {
1765
+ ref: buttonRef,
1766
+ type: "button",
1767
+ "aria-label": name,
1768
+ title: name,
1769
+ "data-boff-explore": "avatar-live-mic",
1770
+ "data-mic": loading2 ? "starting" : state.name,
1771
+ "data-mic-source": "browser",
1772
+ "data-mic-mode": "conversation",
1773
+ className: chunkC6OJIHNO_js.cn(
1774
+ "inline-flex h-11 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
1775
+ busy ? "border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground" : "border-primary bg-primary text-primary-foreground hover:opacity-90"
1776
+ ),
1777
+ onClick: tap,
1778
+ children: [
1779
+ busy ? /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Square, { "aria-hidden": "true", className: "h-4 w-4" }) : /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Mic, { "aria-hidden": "true", className: "h-4 w-4" }),
1780
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: label })
1781
+ ]
1782
+ }
1783
+ );
1784
+ const pressLine = !offered ? null : loading2 || state.name === "starting" ? V2.starting : state.name === "listening" ? state.pausing === true ? V2.pausing : V2.listening : state.name === "finishing" ? state.limit ? S2.micFinishing : V2.sending : null;
1785
+ const noteLine = note === null ? "" : note.kind === "line" ? V2[note.line] : S2[FAILURE_TEXT[note.reason]];
1786
+ const line = withdrawnReason !== null ? S2[FAILURE_TEXT[withdrawnReason]] : pressLine ?? noteLine;
1787
+ const heardNow = offered && (state.name === "listening" || state.name === "finishing") ? state.text : "";
1788
+ const listeningNow = offered && state.name === "listening";
1789
+ const shown = possible || line !== "";
1790
+ return {
1791
+ button: talk,
1792
+ notice: null,
1793
+ lines: !shown ? null : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1794
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1795
+ listeningNow ? listeningDot : null,
1796
+ /* @__PURE__ */ jsxRuntime.jsx(
1797
+ "p",
1798
+ {
1799
+ role: "status",
1800
+ "data-boff-explore": "avatar-live-mic-status",
1801
+ "data-voice": going ? "pressing" : note?.kind ?? "idle",
1802
+ children: line
1803
+ }
1804
+ )
1805
+ ] }),
1806
+ heardLine(heardNow)
1807
+ ] }),
1808
+ cancel,
1809
+ recording: offered && state.name === "listening",
1810
+ ...voiceControls
1811
+ };
1812
+ }
1358
1813
  }
1814
+ var listeningDot = /* @__PURE__ */ jsxRuntime.jsxs("span", { "aria-hidden": "true", className: "relative flex h-2 w-2 shrink-0", children: [
1815
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75 motion-reduce:animate-none" }),
1816
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-primary" })
1817
+ ] });
1359
1818
 
1360
1819
  // src/components/explore-avatar-turnstile.ts
1361
1820
  var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
@@ -1480,9 +1939,13 @@ var PLAYOUT_TAIL_MS = 5e3;
1480
1939
  var QUIET_PLAYED_OUT_MS = 2500;
1481
1940
  var OWED_VOICE_WAIT_MS = 15e3;
1482
1941
  var LISTENING_MARK_MS = 1e4;
1942
+ var LISTENING_MARK_SPACING_MS = 5e3;
1483
1943
  var BUSY_RETRY_MS = 2e3;
1484
1944
  var BUSY_RETRY_MAX_MS = 8e3;
1485
1945
  var MAX_BUSY_RETRIES = 24;
1946
+ var PAUSE_NOTICE_TOLERANCE_MS = 500;
1947
+ var MAX_ECHO_DISCARDS = 2;
1948
+ var LISTEN_AGAIN_MIN_LEFT_MS = 2e4;
1486
1949
  var SERVICE_MAX_AGE_MS = 15 * 6e4;
1487
1950
  var ANSWER_OFFER_MS = SERVICE_MAX_AGE_MS - 6e4;
1488
1951
  var MAX_TIMER_MS = 2147483647;
@@ -1492,6 +1955,29 @@ var USED_ON_KEY = "boff.explore.avatarUsedOn";
1492
1955
  var PRIMARY = '[data-avatar-primary], [data-boff-explore="panel-continue-chat"]';
1493
1956
  var IDLE2 = { name: "idle" };
1494
1957
  var INERT2 = { name: "inert" };
1958
+ var VOICE_OFF = {
1959
+ on: false,
1960
+ arm: null,
1961
+ echoes: 0,
1962
+ note: null
1963
+ };
1964
+ function lineNote(line) {
1965
+ return { kind: "line", line };
1966
+ }
1967
+ function sameNote(a, b) {
1968
+ if (a === null || b === null) return a === b;
1969
+ return a.kind === "line" ? b.kind === "line" && a.line === b.line : b.kind === "failed" && a.reason === b.reason;
1970
+ }
1971
+ var SESSION_REFUSALS = /* @__PURE__ */ new Set([
1972
+ "no_time",
1973
+ "max_answers",
1974
+ "limits",
1975
+ "paused"
1976
+ ]);
1977
+ function playedOutNow(handle) {
1978
+ const run = handle.followUps;
1979
+ return run !== null && !run.voice && run.quiet === null && handle.playedOut && !run.owed;
1980
+ }
1495
1981
  function stopAttending(handle) {
1496
1982
  if (handle.unheard !== null) clearTimeout(handle.unheard);
1497
1983
  if (handle.tail !== null) clearTimeout(handle.tail);
@@ -1605,8 +2091,21 @@ function acceptsQuestion(slot) {
1605
2091
  function followUpBox(phase, run, token) {
1606
2092
  return phase.name === "live" && run !== null && phase.session.followUp !== null && !phase.session.followUp.closed && token === run.token;
1607
2093
  }
1608
- function micOfferOf(result) {
1609
- return result.kind === "available" && result.offer.stt !== void 0 ? { ...result.offer.stt, receivedAt: Date.now() } : null;
2094
+ var NO_MIC = { kind: "none" };
2095
+ function micChoiceOf(result, locale) {
2096
+ if (result.kind !== "available") return NO_MIC;
2097
+ const { offer } = result;
2098
+ const { mic } = offer;
2099
+ const copy = avatarConsentCopy(offer.consentVersion, locale);
2100
+ if (copy?.notice === "none") {
2101
+ const pauseMs = mic?.mode === "browser" ? mic.pauseMs : void 0;
2102
+ return pauseMs !== void 0 && Math.abs(pauseMs - copy.voice.pauseMs) <= PAUSE_NOTICE_TOLERANCE_MS && avatarVoiceStrings(locale) !== null ? { kind: "browser", notice: offer.consentVersion, pauseMs } : NO_MIC;
2103
+ }
2104
+ const micNotice = copy?.micNotice;
2105
+ if (mic !== void 0) {
2106
+ return mic.mode === "browser" && micNotice !== void 0 && mic.pauseMs === void 0 ? { kind: "browser", notice: micNotice } : NO_MIC;
2107
+ }
2108
+ return offer.stt !== void 0 && micNotice === void 0 ? { kind: "stream", offer: { ...offer.stt, receivedAt: Date.now() } } : NO_MIC;
1610
2109
  }
1611
2110
  function utcToday() {
1612
2111
  return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
@@ -1704,6 +2203,7 @@ function AvatarLiveView({
1704
2203
  conversation
1705
2204
  }) {
1706
2205
  const S = avatarLiveStrings(locale);
2206
+ const voiceWords = avatarVoiceStrings(locale);
1707
2207
  const uid = react.useId();
1708
2208
  const consentTitleId = `${uid}-consent`;
1709
2209
  const [questions, setQuestions] = react.useState("suggested");
@@ -1732,12 +2232,16 @@ function AvatarLiveView({
1732
2232
  const offerUntil = answerId === null ? null : exchange.offerUntil;
1733
2233
  const [phase, setPhase] = react.useState(IDLE2);
1734
2234
  const [blocked, setBlocked] = react.useState(false);
2235
+ const blockedRef = react.useRef(false);
2236
+ blockedRef.current = blocked;
1735
2237
  const [now, setNow] = react.useState(() => Date.now());
1736
2238
  const [draft, setDraft] = react.useState("");
1737
2239
  const chatMaxLength = conversation?.catalog.limits.maxMessageLength;
1738
2240
  const askMaxLength = chatMaxLength !== void 0 && Number.isFinite(chatMaxLength) && chatMaxLength >= 1 ? Math.min(ASK_MAX_LENGTH, Math.floor(chatMaxLength)) : ASK_MAX_LENGTH;
1739
2241
  const [note, setNote] = react.useState(null);
1740
2242
  const [usedToday, setUsedToday] = react.useState(false);
2243
+ const [voiceOffered, setVoiceOffered] = react.useState(false);
2244
+ const [noNotice, setNoNotice] = react.useState(false);
1741
2245
  const showing = active && !off;
1742
2246
  const behindChat = open && !active && !off;
1743
2247
  const phaseRef = react.useRef(IDLE2);
@@ -1756,8 +2260,36 @@ function AvatarLiveView({
1756
2260
  const waitRef = react.useRef(null);
1757
2261
  const sendingRef = react.useRef(false);
1758
2262
  const textBoxRef = react.useRef(false);
1759
- const [micOffer, setMicOffer] = react.useState(null);
2263
+ const [micChoice, setMicChoice] = react.useState(NO_MIC);
1760
2264
  const micCancelRef = react.useRef(() => void 0);
2265
+ const micControlRef = react.useRef(null);
2266
+ const voiceRef = react.useRef(VOICE_OFF);
2267
+ const [, setVoiceTick] = react.useState(0);
2268
+ const voiceSet = react.useCallback((next) => {
2269
+ voiceRef.current = { ...voiceRef.current, ...next };
2270
+ setVoiceTick((tick) => tick + 1);
2271
+ }, []);
2272
+ const askMaxLengthRef = react.useRef(ASK_MAX_LENGTH);
2273
+ const voiceOff = react.useCallback(
2274
+ (note2, keep) => {
2275
+ const run = voiceRef.current;
2276
+ if (!run.on && run.arm === null) {
2277
+ if (!sameNote(run.note, note2)) voiceSet({ note: note2 });
2278
+ return;
2279
+ }
2280
+ if (keep) {
2281
+ const heard = micControlRef.current?.heard() ?? "";
2282
+ if (heard !== "") {
2283
+ setDraft(
2284
+ (current) => withHeardText(current, heard, askMaxLengthRef.current)
2285
+ );
2286
+ }
2287
+ }
2288
+ micCancelRef.current();
2289
+ voiceSet({ ...VOICE_OFF, note: note2 });
2290
+ },
2291
+ [voiceSet]
2292
+ );
1761
2293
  const sendDueRef = react.useRef(() => void 0);
1762
2294
  const checkRef = react.useRef(null);
1763
2295
  const askedLocale = react.useRef(locale);
@@ -1814,6 +2346,8 @@ function AvatarLiveView({
1814
2346
  handle.done = true;
1815
2347
  stopAttending(handle);
1816
2348
  sessionRef.current = null;
2349
+ voiceOff(null, true);
2350
+ if (audioRef.current !== null) audioRef.current.muted = false;
1817
2351
  if (reason !== null) {
1818
2352
  sendEnd({
1819
2353
  base: handle.base,
@@ -1829,7 +2363,7 @@ function AvatarLiveView({
1829
2363
  setBlocked(false);
1830
2364
  go({ name: "ended", how });
1831
2365
  },
1832
- [go]
2366
+ [go, voiceOff]
1833
2367
  );
1834
2368
  const goLive = react.useCallback(() => {
1835
2369
  const current = phaseRef.current;
@@ -1902,6 +2436,18 @@ function AvatarLiveView({
1902
2436
  },
1903
2437
  []
1904
2438
  );
2439
+ const unmute = react.useCallback((handle) => {
2440
+ const run = handle.followUps;
2441
+ if (run === null || !run.muted) return;
2442
+ run.muted = false;
2443
+ if (audioRef.current !== null) audioRef.current.muted = false;
2444
+ }, []);
2445
+ const holdMute = react.useCallback(() => {
2446
+ const run = sessionRef.current?.followUps ?? null;
2447
+ const audio = audioRef.current;
2448
+ if (run === null || !run.muted || audio === null || audio.muted) return;
2449
+ audio.muted = true;
2450
+ }, []);
1905
2451
  const whenQuiet = react.useCallback(
1906
2452
  (handle, delay) => {
1907
2453
  const run = handle.followUps;
@@ -1915,10 +2461,12 @@ function AvatarLiveView({
1915
2461
  }
1916
2462
  if (run.owed) {
1917
2463
  run.owed = false;
2464
+ run.owedWait = true;
1918
2465
  wait(OWED_VOICE_WAIT_MS);
1919
2466
  return;
1920
2467
  }
1921
2468
  handle.playedOut = true;
2469
+ if (!run.mutedUnheard) unmute(handle);
1922
2470
  showFollowUp(handle, { speaking: false });
1923
2471
  attend();
1924
2472
  sendDueRef.current(handle);
@@ -1926,7 +2474,7 @@ function AvatarLiveView({
1926
2474
  };
1927
2475
  wait(delay);
1928
2476
  },
1929
- [attend, showFollowUp]
2477
+ [attend, showFollowUp, unmute]
1930
2478
  );
1931
2479
  const heardVoice = react.useCallback(
1932
2480
  (handle, speaking) => {
@@ -1942,7 +2490,10 @@ function AvatarLiveView({
1942
2490
  run.heard = true;
1943
2491
  run.starts += 1;
1944
2492
  run.owed = false;
2493
+ run.owedWait = false;
2494
+ run.mutedUnheard = false;
1945
2495
  handle.playedOut = false;
2496
+ if (voiceRef.current.on) micControlRef.current?.dropLoopPress();
1946
2497
  showFollowUp(handle, { speaking: true });
1947
2498
  },
1948
2499
  [showFollowUp, whenQuiet]
@@ -2009,6 +2560,7 @@ function AvatarLiveView({
2009
2560
  }
2010
2561
  run.due = null;
2011
2562
  run.answers += 1;
2563
+ unmute(handle);
2012
2564
  const heardSince = run.starts !== starts;
2013
2565
  run.owed = !heardSince;
2014
2566
  const playing = !heardSince || run.voice || run.quiet !== null;
@@ -2030,7 +2582,7 @@ function AvatarLiveView({
2030
2582
  if (run.owed && run.heard && !run.voice) whenQuiet(handle, 0);
2031
2583
  })();
2032
2584
  },
2033
- [attend, showFollowUp, whenQuiet]
2585
+ [attend, showFollowUp, unmute, whenQuiet]
2034
2586
  );
2035
2587
  sendDueRef.current = sendDue;
2036
2588
  const readFollowUp = react.useCallback(
@@ -2060,25 +2612,32 @@ function AvatarLiveView({
2060
2612
  const applyAvailability = react.useCallback(
2061
2613
  (result, locale2) => {
2062
2614
  const { live: live2 } = latest.current;
2063
- setMicOffer(micOfferOf(result));
2615
+ setMicChoice(micChoiceOf(result, locale2));
2064
2616
  if (result.kind === "inert") {
2065
2617
  setMode("suggested");
2066
2618
  go(INERT2);
2067
2619
  return;
2068
2620
  }
2069
2621
  if (result.kind === "refused") {
2622
+ if (result.consentVersion !== void 0) {
2623
+ setNoNotice(
2624
+ avatarConsentCopy(result.consentVersion, locale2)?.notice === "none"
2625
+ );
2626
+ }
2070
2627
  setMode(result.questions);
2071
2628
  go({ name: "refused", reason: result.reason });
2072
2629
  return;
2073
2630
  }
2074
2631
  setUsedToday(usedTodayHere());
2075
2632
  const copy = avatarConsentCopy(result.offer.consentVersion, locale2);
2633
+ setVoiceOffered(copy?.voice !== void 0);
2634
+ setNoNotice(copy?.notice === "none");
2076
2635
  if (copy === null || copy.botCheck !== result.offer.botCheck || copy.questions !== result.offer.questions) {
2077
2636
  setMode("suggested");
2078
2637
  go(INERT2);
2079
2638
  return;
2080
2639
  }
2081
- if (result.offer.botCheck === "turnstile" && chunkXIZE37IB_js.turnstileSiteKeyOf(live2) === null) {
2640
+ if (result.offer.botCheck === "turnstile" && chunkZ2UP22GV_js.turnstileSiteKeyOf(live2) === null) {
2082
2641
  setMode("suggested");
2083
2642
  go(INERT2);
2084
2643
  return;
@@ -2127,12 +2686,16 @@ function AvatarLiveView({
2127
2686
  heard: false,
2128
2687
  starts: 0,
2129
2688
  owed: true,
2689
+ owedWait: false,
2130
2690
  quiet: null,
2131
2691
  asked: null,
2132
2692
  due: null,
2133
2693
  retry: null,
2134
2694
  sent: /* @__PURE__ */ new Set([context.messageId]),
2135
- seq: 0
2695
+ seq: 0,
2696
+ listenedAt: null,
2697
+ muted: false,
2698
+ mutedUnheard: false
2136
2699
  } : null;
2137
2700
  sessionRef.current = {
2138
2701
  session,
@@ -2151,6 +2714,7 @@ function AvatarLiveView({
2151
2714
  followUps,
2152
2715
  done: false
2153
2716
  };
2717
+ if (audioRef.current !== null) audioRef.current.muted = false;
2154
2718
  setBlocked(false);
2155
2719
  setNow(received);
2156
2720
  go({
@@ -2238,7 +2802,7 @@ function AvatarLiveView({
2238
2802
  return;
2239
2803
  }
2240
2804
  const refusedFrom = exchangeAtStart.offerUntil - ANSWER_OFFER_MS + SERVICE_MAX_AGE_MS;
2241
- const siteKey = offer.botCheck === "turnstile" ? chunkXIZE37IB_js.turnstileSiteKeyOf(live2) : null;
2805
+ const siteKey = offer.botCheck === "turnstile" ? chunkZ2UP22GV_js.turnstileSiteKeyOf(live2) : null;
2242
2806
  if (offer.botCheck === "turnstile" && (siteKey === null || !canBindBotCheck())) {
2243
2807
  go(INERT2);
2244
2808
  return;
@@ -2406,6 +2970,7 @@ function AvatarLiveView({
2406
2970
  [applyAvailability, bindingHolds, cancelWaiting, exchangeNow, launch]
2407
2971
  );
2408
2972
  const stop = react.useCallback(() => {
2973
+ voiceOff(null, true);
2409
2974
  micCancelRef.current();
2410
2975
  const current = phaseRef.current;
2411
2976
  if (current.name === "waiting") {
@@ -2425,9 +2990,10 @@ function AvatarLiveView({
2425
2990
  if (current.name === "pending" || current.name === "live") {
2426
2991
  finish("stopped", "user");
2427
2992
  }
2428
- }, [cancelStart, cancelWaiting, finish, go, reoffer]);
2993
+ }, [cancelStart, cancelWaiting, finish, go, reoffer, voiceOff]);
2429
2994
  const leave = react.useCallback(
2430
2995
  (event) => {
2996
+ voiceOff(sessionRef.current === null ? null : lineNote("tapNext"), false);
2431
2997
  micCancelRef.current();
2432
2998
  const current = phaseRef.current;
2433
2999
  const reason = event === "user" || event === "chat" ? "user" : "page_hide";
@@ -2468,7 +3034,7 @@ function AvatarLiveView({
2468
3034
  return;
2469
3035
  }
2470
3036
  },
2471
- [cancelStart, cancelWaiting, finish, go, goLive, reoffer]
3037
+ [cancelStart, cancelWaiting, finish, go, goLive, reoffer, voiceOff]
2472
3038
  );
2473
3039
  react.useEffect(() => {
2474
3040
  if (!showing) {
@@ -2582,6 +3148,7 @@ function AvatarLiveView({
2582
3148
  const wasLive = phaseRef.current.name === "live";
2583
3149
  switch (event.type) {
2584
3150
  case "speaker-track":
3151
+ holdMute();
2585
3152
  if (event.kind !== "video") {
2586
3153
  handle.voiceArrived = true;
2587
3154
  attend();
@@ -2591,6 +3158,7 @@ function AvatarLiveView({
2591
3158
  if (!inSight()) goLive();
2592
3159
  return;
2593
3160
  case "playback-blocked":
3161
+ holdMute();
2594
3162
  setBlocked(event.blocked);
2595
3163
  handle.soundHeld = event.sound ?? event.blocked;
2596
3164
  attend();
@@ -2621,7 +3189,7 @@ function AvatarLiveView({
2621
3189
  return;
2622
3190
  }
2623
3191
  },
2624
- [attend, finish, goLive, heardVoice, inSight]
3192
+ [attend, finish, goLive, heardVoice, holdMute, inSight]
2625
3193
  );
2626
3194
  react.useEffect(() => {
2627
3195
  if (phase.name !== "pending") return;
@@ -2738,6 +3306,7 @@ function AvatarLiveView({
2738
3306
  wait?.check?.abort();
2739
3307
  const handle = sessionRef.current;
2740
3308
  sessionRef.current = null;
3309
+ if (audioRef.current !== null) audioRef.current.muted = false;
2741
3310
  if (handle && !handle.done) {
2742
3311
  handle.done = true;
2743
3312
  stopAttending(handle);
@@ -2754,7 +3323,8 @@ function AvatarLiveView({
2754
3323
  []
2755
3324
  );
2756
3325
  const any = questions === "any";
2757
- const textMode = any && (usedToday && (phase.name === "needs-answer" || phase.name === "consent" || phase.name === "declined" || phase.name === "ended") || phase.name === "refused" && phase.reason === "limits");
3326
+ const usedHere = usedToday && !voiceOffered;
3327
+ const textMode = any && (usedHere && (phase.name === "needs-answer" || phase.name === "consent" || phase.name === "declined" || phase.name === "ended") || phase.name === "refused" && phase.reason === "limits");
2758
3328
  textBoxRef.current = textMode;
2759
3329
  const askState = !any ? void 0 : textMode ? "text" : phase.name === "needs-answer" ? kind === "empty" ? "ready" : kind : phase.name;
2760
3330
  const followUp = phase.name === "live" ? phase.session.followUp : null;
@@ -2767,16 +3337,18 @@ function AvatarLiveView({
2767
3337
  const refreshMicOffer = react.useCallback(async () => {
2768
3338
  const { live: live2, locale: locale2, base: base2, headers: headers2 } = latest.current;
2769
3339
  if (base2 === null) return null;
2770
- const next = micOfferOf(
3340
+ const next = micChoiceOf(
2771
3341
  await requestAvailabilityAnswer({
2772
3342
  base: base2,
2773
3343
  headers: headers2,
2774
3344
  site: live2.site,
2775
3345
  locale: locale2
2776
- })
3346
+ }),
3347
+ locale2
2777
3348
  );
2778
- if (next !== null) setMicOffer(next);
2779
- return next;
3349
+ if (next.kind !== "stream") return null;
3350
+ setMicChoice(next);
3351
+ return next.offer;
2780
3352
  }, []);
2781
3353
  const onMicText = react.useCallback(
2782
3354
  (text) => {
@@ -2795,15 +3367,153 @@ function AvatarLiveView({
2795
3367
  return;
2796
3368
  }
2797
3369
  const run = handle.followUps;
3370
+ const voiced = micControlRef.current?.mode === "conversation";
2798
3371
  tell(handle, "interrupt");
3372
+ if (voiced) {
3373
+ run.muted = true;
3374
+ run.mutedUnheard = run.owed || run.owedWait;
3375
+ if (audioRef.current !== null) audioRef.current.muted = true;
3376
+ }
2799
3377
  if (run.quiet !== null) clearTimeout(run.quiet);
2800
3378
  run.quiet = null;
2801
3379
  run.owed = false;
2802
3380
  handle.playedOut = true;
2803
3381
  showFollowUp(handle, { speaking: false });
2804
- }, [showFollowUp, tell]);
3382
+ if (voiced && !run.voice) whenQuiet(handle, QUIET_PLAYED_OUT_MS);
3383
+ }, [showFollowUp, tell, whenQuiet]);
3384
+ const firstArmable = react.useCallback(() => {
3385
+ const current = phaseRef.current;
3386
+ return sessionRef.current === null && !textBoxRef.current && current.name === "needs-answer" && current.offer.copy.notice === "none" && exchangeNow().kind === "empty";
3387
+ }, [exchangeNow]);
3388
+ const armFirst = react.useCallback(
3389
+ (stage, askSeq) => {
3390
+ const current = phaseRef.current;
3391
+ if (current.name !== "needs-answer") return null;
3392
+ const { live: live2, locale: locale2 } = latest.current;
3393
+ unlockSound(audioRef.current);
3394
+ const module = loadModule(live2.load);
3395
+ module.catch(() => void 0);
3396
+ return {
3397
+ stage,
3398
+ consentVersion: current.offer.consentVersion,
3399
+ botCheck: current.offer.botCheck,
3400
+ site: live2.site,
3401
+ locale: locale2,
3402
+ path: pathNow(),
3403
+ module,
3404
+ askSeq
3405
+ };
3406
+ },
3407
+ [pathNow]
3408
+ );
3409
+ const onMicPress = react.useCallback(
3410
+ (gesture) => {
3411
+ const run = voiceRef.current;
3412
+ if (run.on && run.note === null && !gesture) return;
3413
+ const arm = gesture && firstArmable() ? armFirst("pressing", null) : null;
3414
+ voiceSet({
3415
+ on: true,
3416
+ note: null,
3417
+ ...gesture ? { echoes: 0, arm } : {}
3418
+ });
3419
+ },
3420
+ [armFirst, firstArmable, voiceSet]
3421
+ );
3422
+ const echoOf = react.useCallback(() => {
3423
+ const current = phaseRef.current;
3424
+ return current.name === "live" && current.session.followUp !== null && sessionRef.current?.followUps ? current.session.captions.text : null;
3425
+ }, []);
3426
+ const askRef = react.useRef(() => null);
3427
+ const askReasonRef = react.useRef(null);
3428
+ const draftRef = react.useRef("");
3429
+ draftRef.current = draft;
3430
+ askMaxLengthRef.current = askMaxLength;
3431
+ const autoSend = react.useCallback(
3432
+ (text) => {
3433
+ const question = text.trim();
3434
+ const refuse = () => {
3435
+ onMicText(text);
3436
+ voiceOff(lineNote("inBox"), false);
3437
+ };
3438
+ if (question === "" || askReasonRef.current !== null || sendingRef.current || question.length > askMaxLengthRef.current || draftRef.current.trim() !== "") {
3439
+ refuse();
3440
+ return;
3441
+ }
3442
+ const sending = askRef.current(question);
3443
+ if (sending === null) {
3444
+ refuse();
3445
+ return;
3446
+ }
3447
+ const arm = voiceRef.current.arm;
3448
+ voiceSet({
3449
+ echoes: 0,
3450
+ note: lineNote("sending"),
3451
+ ...arm === null ? {} : { arm: { ...arm, stage: "asked", askSeq: askSeqRef.current } }
3452
+ });
3453
+ void sending.then(
3454
+ (asked) => {
3455
+ if (!asked) {
3456
+ voiceOff(lineNote("off"), false);
3457
+ return;
3458
+ }
3459
+ const run = voiceRef.current;
3460
+ if (run.on && sameNote(run.note, lineNote("sending"))) {
3461
+ voiceSet({ note: lineNote("again") });
3462
+ }
3463
+ },
3464
+ () => voiceOff(lineNote("off"), false)
3465
+ );
3466
+ },
3467
+ [onMicText, voiceOff, voiceSet]
3468
+ );
3469
+ const onMicTurn = react.useCallback(
3470
+ (turn) => {
3471
+ const run = voiceRef.current;
3472
+ if (!run.on) return;
3473
+ if (turn.by === "pause" && turn.echo) {
3474
+ if (run.echoes >= MAX_ECHO_DISCARDS || run.arm !== null) {
3475
+ voiceOff(lineNote("off"), false);
3476
+ } else {
3477
+ voiceSet({ echoes: run.echoes + 1, note: null });
3478
+ }
3479
+ return;
3480
+ }
3481
+ if (turn.by === "pause") {
3482
+ autoSend(turn.text);
3483
+ return;
3484
+ }
3485
+ if (turn.by === "silence") {
3486
+ voiceOff(lineNote("noQuestion"), false);
3487
+ return;
3488
+ }
3489
+ if (turn.text.trim() !== "") {
3490
+ onMicText(turn.text);
3491
+ voiceOff(lineNote(turn.by === "limit" ? "atLimit" : "inBox"), false);
3492
+ return;
3493
+ }
3494
+ if (turn.by === "failed") {
3495
+ voiceOff(
3496
+ turn.gesture ? { kind: "failed", reason: turn.failure ?? "failed" } : lineNote("tapNext"),
3497
+ false
3498
+ );
3499
+ return;
3500
+ }
3501
+ voiceOff(lineNote("off"), false);
3502
+ },
3503
+ [autoSend, onMicText, voiceOff, voiceSet]
3504
+ );
3505
+ const micSource = micChoice.kind === "browser" ? {
3506
+ kind: "browser",
3507
+ notice: micChoice.notice,
3508
+ ...micChoice.pauseMs === void 0 ? {} : { pauseMs: micChoice.pauseMs }
3509
+ } : micChoice.kind === "stream" && base !== null ? {
3510
+ kind: "stream",
3511
+ base,
3512
+ offer: micChoice.offer,
3513
+ refreshOffer: refreshMicOffer
3514
+ } : null;
2805
3515
  const mic = useAvatarMic({
2806
- enabled: live.mic === true && micOffer !== null && !off && acceptsQuestion({
3516
+ enabled: live.mic === true && micSource !== null && !off && acceptsQuestion({
2807
3517
  any,
2808
3518
  phase: phase.name,
2809
3519
  kind,
@@ -2812,28 +3522,41 @@ function AvatarLiveView({
2812
3522
  followUp: followUpAsks
2813
3523
  }),
2814
3524
  locale,
2815
- base,
2816
- offer: micOffer,
2817
- refreshOffer: refreshMicOffer,
3525
+ source: micSource,
2818
3526
  onText: onMicText,
2819
- onStart: onMicStart
3527
+ onStart: onMicStart,
3528
+ onTurn: onMicTurn,
3529
+ onPress: onMicPress,
3530
+ echoOf,
3531
+ interruptible: followUp?.speaking === true,
3532
+ note: voiceRef.current.note
2820
3533
  });
2821
3534
  micCancelRef.current = mic.cancel;
2822
- const micRecording = mic.recording;
3535
+ micControlRef.current = mic;
3536
+ askReasonRef.current = askReason;
3537
+ const micRecording = mic.mode === "conversation" ? mic.speaking : mic.recording;
2823
3538
  react.useEffect(() => {
2824
3539
  const handle = sessionRef.current;
2825
3540
  if (!micRecording || handle === null || handle.followUps === null || handle.done) {
2826
3541
  return;
2827
3542
  }
2828
- tell(handle, "listening");
3543
+ const run = handle.followUps;
2829
3544
  const every = Math.min(
2830
3545
  LISTENING_MARK_MS,
2831
- Math.max(1e3, handle.followUps.limits.idleSeconds * 1e3 / 3)
3546
+ Math.max(1e3, run.limits.idleSeconds * 1e3 / 3)
2832
3547
  );
2833
- const timer = setInterval(() => tell(handle, "listening"), every);
3548
+ const spacing = Math.min(LISTENING_MARK_SPACING_MS, every / 2);
3549
+ const mark = () => {
3550
+ const at = Date.now();
3551
+ if (run.listenedAt !== null && at - run.listenedAt < spacing) return;
3552
+ run.listenedAt = at;
3553
+ tell(handle, "listening");
3554
+ };
3555
+ mark();
3556
+ const timer = setInterval(mark, every);
2834
3557
  return () => {
2835
3558
  clearInterval(timer);
2836
- if (sessionRef.current === handle) tell(handle, "listening");
3559
+ if (sessionRef.current === handle) mark();
2837
3560
  };
2838
3561
  }, [micRecording, tell]);
2839
3562
  const notNow = react.useCallback(() => {
@@ -2849,7 +3572,7 @@ function AvatarLiveView({
2849
3572
  const run = handle.followUps;
2850
3573
  const current = phaseRef.current;
2851
3574
  if (run === null || handle.done || current.name !== "live" || current.session.followUp?.pending !== null || !followUpBox(current, run, conversation2.getConversationToken())) {
2852
- return;
3575
+ return null;
2853
3576
  }
2854
3577
  sendingRef.current = true;
2855
3578
  run.seq += 1;
@@ -2857,17 +3580,17 @@ function AvatarLiveView({
2857
3580
  setDraft("");
2858
3581
  tell(handle, "asking");
2859
3582
  showFollowUp(handle, { pending: "asking", unread: null });
2860
- void conversation2.send(question, [], { surface: "AVATAR" }).then((added) => {
3583
+ return conversation2.send(question, [], { surface: "AVATAR" }).then((added) => {
2861
3584
  if (added === null) {
2862
3585
  setDraft((current2) => current2 === "" ? question : current2);
2863
3586
  }
2864
3587
  if (sessionRef.current !== handle || handle.done || run.seq !== seq) {
2865
- return;
3588
+ return false;
2866
3589
  }
2867
3590
  if (added === null || added.conversationToken !== run.token) {
2868
3591
  showFollowUp(handle, { pending: null });
2869
3592
  attend();
2870
- return;
3593
+ return false;
2871
3594
  }
2872
3595
  run.asked = {
2873
3596
  messageId: added.userMessageId,
@@ -2875,6 +3598,7 @@ function AvatarLiveView({
2875
3598
  answerId: null
2876
3599
  };
2877
3600
  showFollowUp(handle, { pending: "asking" });
3601
+ return true;
2878
3602
  }).finally(() => {
2879
3603
  sendingRef.current = false;
2880
3604
  });
@@ -2886,37 +3610,135 @@ function AvatarLiveView({
2886
3610
  const conversation2 = conversationRef.current;
2887
3611
  const question = text.trim();
2888
3612
  if (conversation2 === null || question === "" || sendingRef.current) {
2889
- return;
3613
+ return null;
2890
3614
  }
2891
3615
  const spoken = !textBoxRef.current;
2892
3616
  const handle = sessionRef.current;
2893
3617
  if (spoken && handle !== null && handle.followUps !== null) {
2894
- askFollowUp(handle, conversation2, question);
2895
- return;
3618
+ return askFollowUp(handle, conversation2, question);
2896
3619
  }
2897
- if (spoken && conversation2.messages.length > 0) return;
3620
+ if (spoken && conversation2.messages.length > 0) return null;
2898
3621
  sendingRef.current = true;
2899
3622
  setAskedHere(null);
2900
3623
  const seq = askSeqRef.current;
2901
3624
  setNote(null);
2902
3625
  setDraft("");
2903
- void conversation2.send(question, [], spoken ? { surface: "AVATAR" } : {}).then((added) => {
3626
+ return conversation2.send(question, [], spoken ? { surface: "AVATAR" } : {}).then((added) => {
2904
3627
  if (added === null) {
2905
3628
  setDraft((current) => current === "" ? question : current);
2906
3629
  }
2907
- if (!spoken || added === null || askSeqRef.current !== seq || added.conversationToken !== (conversationRef.current?.getConversationToken() ?? null)) {
2908
- return;
3630
+ if (added === null || spoken && (askSeqRef.current !== seq || added.conversationToken !== (conversationRef.current?.getConversationToken() ?? null))) {
3631
+ return false;
2909
3632
  }
3633
+ if (!spoken) return true;
2910
3634
  setAskedHere({
2911
3635
  messageId: added.userMessageId,
2912
3636
  token: added.conversationToken
2913
3637
  });
3638
+ return true;
2914
3639
  }).finally(() => {
2915
3640
  sendingRef.current = false;
2916
3641
  });
2917
3642
  },
2918
3643
  [askFollowUp, setAskedHere]
2919
3644
  );
3645
+ askRef.current = ask;
3646
+ const askTyped = react.useCallback(
3647
+ (text) => {
3648
+ voiceOff(null, false);
3649
+ const armable = firstArmable();
3650
+ const sending = ask(text);
3651
+ if (sending !== null && armable) {
3652
+ voiceSet({ arm: armFirst("asked", askSeqRef.current) });
3653
+ }
3654
+ return sending;
3655
+ },
3656
+ [armFirst, ask, firstArmable, voiceOff, voiceSet]
3657
+ );
3658
+ const onDraftChange = react.useCallback(
3659
+ (value) => {
3660
+ setDraft(value);
3661
+ const run = voiceRef.current;
3662
+ if (run.on || run.arm !== null) voiceOff(lineNote("off"), true);
3663
+ },
3664
+ [voiceOff]
3665
+ );
3666
+ const armVoiceStart = react.useCallback(() => {
3667
+ const arm = voiceRef.current.arm;
3668
+ if (arm === null || arm.stage !== "asked") return;
3669
+ const current = phaseRef.current;
3670
+ const exchange2 = exchangeNow();
3671
+ const drop = () => voiceSet({ arm: null });
3672
+ if (arm.askSeq !== askSeqRef.current || current.name !== "needs-answer" && current.name !== "consent") {
3673
+ drop();
3674
+ return;
3675
+ }
3676
+ if (sendingRef.current || exchange2.kind === "asking" && (!exchange2.askedHere || exchange2.pendingAnswerId === null)) {
3677
+ return;
3678
+ }
3679
+ const answerId2 = exchange2.kind === "asking" ? exchange2.pendingAnswerId : exchange2.kind === "answer" ? exchange2.answerId : null;
3680
+ const token2 = conversationRef.current?.getConversationToken() ?? null;
3681
+ const { live: live2, locale: locale2 } = latest.current;
3682
+ const path = pathNow();
3683
+ if (answerId2 === null || !exchange2.askedHere || exchange2.question === null || token2 === null || !isAvatarConversationToken(token2) || arm.consentVersion !== current.offer.consentVersion || arm.botCheck !== current.offer.botCheck || arm.site !== live2.site || arm.locale !== locale2 || arm.path !== path) {
3684
+ drop();
3685
+ return;
3686
+ }
3687
+ voiceSet({ arm: null });
3688
+ waitRef.current = {
3689
+ bound: {
3690
+ token: token2,
3691
+ questionId: exchange2.question.id,
3692
+ answerId: answerId2,
3693
+ site: live2.site,
3694
+ locale: locale2,
3695
+ path,
3696
+ consentVersion: arm.consentVersion,
3697
+ botCheck: arm.botCheck
3698
+ },
3699
+ module: arm.module,
3700
+ check: null
3701
+ };
3702
+ setNote(null);
3703
+ go({ name: "waiting", offer: current.offer });
3704
+ }, [exchangeNow, go, pathNow, voiceSet]);
3705
+ react.useEffect(() => {
3706
+ armVoiceStart();
3707
+ });
3708
+ react.useEffect(() => {
3709
+ const run = voiceRef.current;
3710
+ if (!run.on && run.arm === null) return;
3711
+ const current = phaseRef.current;
3712
+ const live2 = current.name === "live" ? current.session.followUp : null;
3713
+ const keeps = (mic.mode === "conversation" || !run.on) && !textMode && (current.name === "needs-answer" && (sendingRef.current || kind === "empty" || kind === "asking" || kind === "answer") || current.name === "consent" || current.name === "waiting" || current.name === "bot-check" || current.name === "creating" || current.name === "pending" || current.name === "live" && followUpAsks && live2 !== null && !live2.closed && (live2.unread === null || !SESSION_REFUSALS.has(live2.unread)));
3714
+ if (!keeps) voiceOff(lineNote("off"), true);
3715
+ });
3716
+ react.useEffect(() => {
3717
+ const run = voiceRef.current;
3718
+ const controls = micControlRef.current;
3719
+ const handle = sessionRef.current;
3720
+ const followUps = handle?.followUps ?? null;
3721
+ const current = phaseRef.current;
3722
+ if (!run.on || controls === null || controls.mode !== "conversation" || handle === null || followUps === null || handle.done || current.name !== "live") {
3723
+ return;
3724
+ }
3725
+ const shownFollowUp = current.session.followUp;
3726
+ if (shownFollowUp === null || shownFollowUp.speaking || shownFollowUp.pending !== null || shownFollowUp.closed || shownFollowUp.unread !== null && SESSION_REFUSALS.has(shownFollowUp.unread) || !followUpBox(
3727
+ current,
3728
+ followUps,
3729
+ conversationRef.current?.getConversationToken() ?? null
3730
+ )) {
3731
+ return;
3732
+ }
3733
+ if (sendingRef.current || followUps.asked !== null || followUps.due !== null || !playedOutNow(handle) || blockedRef.current || !inSight() || controls.pressing()) {
3734
+ return;
3735
+ }
3736
+ if (handle.endsAt - Date.now() < LISTEN_AGAIN_MIN_LEFT_MS) {
3737
+ voiceOff(lineNote("off"), false);
3738
+ return;
3739
+ }
3740
+ controls.listen();
3741
+ });
2920
3742
  react.useEffect(() => {
2921
3743
  const handle = sessionRef.current;
2922
3744
  const run = handle?.followUps ?? null;
@@ -2991,6 +3813,8 @@ function AvatarLiveView({
2991
3813
  check();
2992
3814
  }
2993
3815
  }, [check, exchangeNow, go, setAskedHere]);
3816
+ const voiceTalk = mic.mode === "conversation" && mic.button !== null && voiceWords !== null;
3817
+ const voiceArming = voiceRef.current.arm?.stage === "asked";
2994
3818
  react.useEffect(() => {
2995
3819
  if (!active) return;
2996
3820
  const focused = document.activeElement;
@@ -3006,8 +3830,12 @@ function AvatarLiveView({
3006
3830
  ]);
3007
3831
  const onPlaying = goLive;
3008
3832
  const enablePlayback = react.useCallback(() => {
3009
- void sessionRef.current?.joined?.enablePlayback().catch(() => void 0);
3010
- }, []);
3833
+ const room = sessionRef.current?.joined ?? null;
3834
+ if (room === null) return;
3835
+ const resumed = room.enablePlayback();
3836
+ holdMute();
3837
+ void resumed.catch(() => void 0).then(holdMute);
3838
+ }, [holdMute]);
3011
3839
  if (off || phase.name === "inert") {
3012
3840
  return (
3013
3841
  // `display: contents`: the earlier Avatar view's picture, line and button lay out in the pane exactly as before.
@@ -3126,7 +3954,7 @@ function AvatarLiveView({
3126
3954
  ]
3127
3955
  }
3128
3956
  );
3129
- const consentActions = (whenReady) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3957
+ const consentActions = (whenReady, label = whenReady ? S.startWhenReady : S.start) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
3130
3958
  /* @__PURE__ */ jsxRuntime.jsx(
3131
3959
  "button",
3132
3960
  {
@@ -3135,7 +3963,7 @@ function AvatarLiveView({
3135
3963
  "data-boff-explore": whenReady ? "avatar-live-start-when-ready" : "avatar-live-start",
3136
3964
  className: primaryButton,
3137
3965
  onClick: whenReady ? startWhenReady : start,
3138
- children: whenReady ? S.startWhenReady : S.start
3966
+ children: label
3139
3967
  }
3140
3968
  ),
3141
3969
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3179,7 +4007,14 @@ function AvatarLiveView({
3179
4007
  className: "absolute inset-0 h-full w-full object-cover"
3180
4008
  }
3181
4009
  ),
3182
- any ? null : /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, "data-boff-explore": "avatar-live-audio" })
4010
+ any ? null : /* @__PURE__ */ jsxRuntime.jsx(
4011
+ "audio",
4012
+ {
4013
+ ref: audioRef,
4014
+ "data-boff-explore": "avatar-live-audio",
4015
+ onVolumeChange: holdMute
4016
+ }
4017
+ )
3183
4018
  ]
3184
4019
  }
3185
4020
  );
@@ -3312,20 +4147,24 @@ function AvatarLiveView({
3312
4147
  switch (shown.kind) {
3313
4148
  case "empty":
3314
4149
  status = note === "new-chat" ? S.newChatStarted : "";
3315
- lead = /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-base font-semibold", children: S.askHeadline });
4150
+ lead = /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-base font-semibold", children: voiceTalk && voiceWords !== null ? voiceWords.headline : S.askHeadline });
3316
4151
  box = "spoken";
3317
4152
  break;
3318
4153
  case "asking":
3319
4154
  case "answer": {
3320
- const card = shown.askedHere && shown.pendingAnswerId !== null;
3321
- status = note === "cancelled" ? S.armedCancelled : S.gettingAnswer;
3322
- if (card) media2 = stillPicture(true);
4155
+ const card = shown.askedHere && shown.pendingAnswerId !== null && !voiceArming;
4156
+ const words = phase.offer.copy.notice === "none" ? null : phase.offer.copy;
4157
+ status = note === "cancelled" ? words === null && voiceWords !== null ? voiceWords.cancelled : S.armedCancelled : S.gettingAnswer;
4158
+ if (card && words !== null) media2 = stillPicture(true);
3323
4159
  body2 = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3324
4160
  questionLine,
3325
4161
  activityLine,
3326
- card ? consentCard(phase.offer.copy) : null
4162
+ card && words !== null ? consentCard(words) : null
3327
4163
  ] });
3328
- actions2 = card ? consentActions(true) : null;
4164
+ actions2 = card ? consentActions(
4165
+ true,
4166
+ words === null ? voiceWords?.start ?? S.start : S.startWhenReady
4167
+ ) : null;
3329
4168
  break;
3330
4169
  }
3331
4170
  case "text-only":
@@ -3357,6 +4196,16 @@ function AvatarLiveView({
3357
4196
  }
3358
4197
  break;
3359
4198
  case "consent":
4199
+ if (voiceArming) {
4200
+ status = S.armed;
4201
+ body2 = questionLine;
4202
+ break;
4203
+ }
4204
+ if (phase.offer.copy.notice === "none") {
4205
+ body2 = questionLine;
4206
+ actions2 = consentActions(false, voiceWords?.start ?? S.start);
4207
+ break;
4208
+ }
3360
4209
  media2 = stillPicture(true);
3361
4210
  body2 = /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3362
4211
  questionLine,
@@ -3448,7 +4297,7 @@ function AvatarLiveView({
3448
4297
  "aria-disabled": reason !== null ? "true" : void 0,
3449
4298
  className: "min-h-11 rounded-full border border-border bg-background px-3 py-2 text-start text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring aria-disabled:cursor-not-allowed aria-disabled:opacity-50 motion-reduce:transition-none",
3450
4299
  onClick: () => {
3451
- if (reason === null) ask(prompt);
4300
+ if (reason === null) void askTyped(prompt);
3452
4301
  },
3453
4302
  children: prompt
3454
4303
  },
@@ -3476,7 +4325,7 @@ function AvatarLiveView({
3476
4325
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: S.newChatNote })
3477
4326
  ] }) : null,
3478
4327
  chips,
3479
- box !== null && usedToday && phase.name !== "live" ? /* @__PURE__ */ jsxRuntime.jsx(
4328
+ box !== null && usedHere && phase.name !== "live" ? /* @__PURE__ */ jsxRuntime.jsx(
3480
4329
  "p",
3481
4330
  {
3482
4331
  "data-boff-explore": "avatar-live-used-today",
@@ -3490,13 +4339,16 @@ function AvatarLiveView({
3490
4339
  {
3491
4340
  value: draft,
3492
4341
  maxLength: askMaxLength,
3493
- onChange: setDraft,
3494
- onAsk: ask,
4342
+ onChange: onDraftChange,
4343
+ onAsk: askTyped,
3495
4344
  reason,
3496
4345
  label: S.askLabel,
3497
- placeholder: S.askPlaceholder,
4346
+ placeholder: box === "spoken" && voiceTalk && voiceWords !== null ? (
4347
+ // None while the mic listens: its line says so, and the box is at its narrowest then.
4348
+ mic.busy ? "" : voiceWords.placeholder
4349
+ ) : S.askPlaceholder,
3498
4350
  send,
3499
- footer: S.askFooter,
4351
+ footer: noNotice ? null : S.askFooter,
3500
4352
  noFiles: S.askNoFiles,
3501
4353
  hint: box === "spoken" ? S.needsAnswerAny : null,
3502
4354
  touchFirst,
@@ -3534,7 +4386,14 @@ function AvatarLiveView({
3534
4386
  actions2
3535
4387
  ] }),
3536
4388
  foot,
3537
- /* @__PURE__ */ jsxRuntime.jsx("audio", { ref: audioRef, "data-boff-explore": "avatar-live-audio" })
4389
+ /* @__PURE__ */ jsxRuntime.jsx(
4390
+ "audio",
4391
+ {
4392
+ ref: audioRef,
4393
+ "data-boff-explore": "avatar-live-audio",
4394
+ onVolumeChange: holdMute
4395
+ }
4396
+ )
3538
4397
  ]
3539
4398
  }
3540
4399
  );
@@ -3566,6 +4425,7 @@ function AvatarLiveView({
3566
4425
  actions = startOver ? startOverActions : continueButton;
3567
4426
  break;
3568
4427
  case "consent":
4428
+ if (phase.offer.copy.notice === "none") break;
3569
4429
  media = stillPicture(true);
3570
4430
  body = consentCard(phase.offer.copy);
3571
4431
  actions = consentActions(false);
@@ -3658,6 +4518,7 @@ function AskBox({
3658
4518
  const footerId = `${uid}-footer`;
3659
4519
  const [refusedFiles, setRefusedFiles] = react.useState(false);
3660
4520
  const empty = value.trim() === "";
4521
+ const describedBy = [hint === null ? null : hintId, footer === null ? null : footerId].filter((id) => id !== null).join(" ") || void 0;
3661
4522
  const submit = () => {
3662
4523
  if (reason !== null || empty) return;
3663
4524
  onAsk(value);
@@ -3700,12 +4561,17 @@ function AskBox({
3700
4561
  rows: 1,
3701
4562
  maxLength,
3702
4563
  "aria-label": label,
3703
- "aria-describedby": hint === null ? footerId : `${hintId} ${footerId}`,
4564
+ "aria-describedby": describedBy,
3704
4565
  placeholder,
3705
4566
  dir: "auto",
3706
4567
  "data-avatar-primary": touchFirst ? void 0 : "",
3707
4568
  "data-boff-explore": "avatar-live-ask-input",
3708
- className: "max-h-32 min-h-11 flex-1 resize-none rounded-md border border-border bg-background px-3 py-2.5 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm",
4569
+ className: chunkC6OJIHNO_js.cn(
4570
+ // Kept out of a site's session replay before Send (rrweb's default classes; `rr-ignore` for its input
4571
+ // events): the text may be what the mic heard, or what the visitor typed.
4572
+ REPLAY_PRIVATE,
4573
+ "rr-ignore max-h-32 min-h-11 flex-1 resize-none rounded-md border border-border bg-background px-3 py-2.5 text-base text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm"
4574
+ ),
3709
4575
  onChange: (event) => {
3710
4576
  setRefusedFiles(false);
3711
4577
  onChange(event.target.value);
@@ -3748,7 +4614,7 @@ function AskBox({
3748
4614
  }
3749
4615
  ) : null,
3750
4616
  hint === null ? null : /* @__PURE__ */ jsxRuntime.jsx("p", { id: hintId, className: "sr-only", children: hint }),
3751
- /* @__PURE__ */ jsxRuntime.jsx("p", { id: footerId, className: "text-xs text-muted-foreground", children: footer })
4617
+ footer === null ? null : /* @__PURE__ */ jsxRuntime.jsx("p", { id: footerId, className: "text-xs text-muted-foreground", children: footer })
3752
4618
  ]
3753
4619
  }
3754
4620
  );