@microsoft/omnichannel-chat-widget 2.0.0 → 2.0.1-main.38e7390

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.
Files changed (30) hide show
  1. package/README.md +4 -2
  2. package/lib/cjs/common/telemetry/TelemetryConstants.js +6 -0
  3. package/lib/cjs/components/chatbuttonstateful/ChatButtonStateful.js +42 -25
  4. package/lib/cjs/components/livechatwidget/common/endChat.js +39 -0
  5. package/lib/cjs/components/livechatwidget/common/initWebChatComposer.js +11 -6
  6. package/lib/cjs/components/livechatwidget/common/recoveryEligibilityTelemetry.js +60 -0
  7. package/lib/cjs/components/livechatwidget/common/startChat.js +107 -33
  8. package/lib/cjs/components/livechatwidget/livechatwidgetstateful/LiveChatWidgetStateful.js +8 -6
  9. package/lib/cjs/components/webchatcontainerstateful/WebChatContainerStateful.js +2 -2
  10. package/lib/cjs/components/webchatcontainerstateful/common/defaultProps/defaultMiddlewareLocalizedTexts.js +2 -1
  11. package/lib/cjs/components/webchatcontainerstateful/webchatcontroller/middlewares/renderingmiddlewares/attachments/AdaptiveCardAccessibilityWrapper.js +16 -2
  12. package/lib/cjs/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware.js +68 -40
  13. package/lib/cjs/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentUploadValidatorMiddleware.js +0 -27
  14. package/lib/esm/common/telemetry/TelemetryConstants.js +6 -0
  15. package/lib/esm/components/chatbuttonstateful/ChatButtonStateful.js +42 -25
  16. package/lib/esm/components/livechatwidget/common/endChat.js +39 -0
  17. package/lib/esm/components/livechatwidget/common/initWebChatComposer.js +12 -7
  18. package/lib/esm/components/livechatwidget/common/recoveryEligibilityTelemetry.js +52 -0
  19. package/lib/esm/components/livechatwidget/common/startChat.js +104 -31
  20. package/lib/esm/components/livechatwidget/livechatwidgetstateful/LiveChatWidgetStateful.js +8 -6
  21. package/lib/esm/components/webchatcontainerstateful/WebChatContainerStateful.js +2 -2
  22. package/lib/esm/components/webchatcontainerstateful/common/defaultProps/defaultMiddlewareLocalizedTexts.js +2 -1
  23. package/lib/esm/components/webchatcontainerstateful/webchatcontroller/middlewares/renderingmiddlewares/attachments/AdaptiveCardAccessibilityWrapper.js +16 -2
  24. package/lib/esm/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware.js +65 -39
  25. package/lib/esm/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentUploadValidatorMiddleware.js +0 -27
  26. package/lib/types/common/telemetry/TelemetryConstants.d.ts +3 -0
  27. package/lib/types/components/livechatwidget/common/recoveryEligibilityTelemetry.d.ts +17 -0
  28. package/lib/types/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware.d.ts +11 -4
  29. package/lib/types/contexts/common/ILiveChatWidgetLocalizedTexts.d.ts +9 -2
  30. package/package.json +7 -6
@@ -18,19 +18,86 @@ import { isMidAuthEnabled } from "./authHelper";
18
18
  import { setPostChatContextAndLoadSurvey } from "./setPostChatContextAndLoadSurvey";
19
19
  import { shouldSetPreChatIfPersistentChat } from "./persistentChatHelper";
20
20
  import { updateTelemetryData } from "./updateSessionDataForTelemetry";
21
+ /**
22
+ * Per widget instance mutexes for the two chat creation entry points.
23
+ *
24
+ * Every guard in the prepareStartChat -> setPreChatAndInitiateChat -> initStartChat chain
25
+ * checks `conversationState === Closed`, but React state is updated asynchronously. A second
26
+ * invocation fired within the same render cycle (for example a rapid double click on the chat
27
+ * button, or a chat button click racing a BroadcastEvent.StartChat) therefore still observes
28
+ * the stale `Closed` state, reaches `facadeChatSDK.startChat()` and creates a second
29
+ * conversation on the backend which is then orphaned and never ended.
30
+ *
31
+ * The locks are keyed by FacadeChatSDK instance rather than being module wide, because a host
32
+ * page can mount several independent widgets, each with its own widgetInstanceId and its own
33
+ * FacadeChatSDK (created once per widget in LiveChatWidget.tsx). A module wide lock would make
34
+ * widget A's start chat silently drop widget B's legitimate simultaneous start chat.
35
+ *
36
+ * The two maps are separate because prepareStartChat calls initStartChat; sharing a single lock
37
+ * would make the nested call see its own parent as in flight and drop it. prepareStartChat is
38
+ * additionally blocked by an in flight initStartChat (pre-chat submit, reconnect, or the
39
+ * hideStartChatButton bootstrap call initStartChat directly). Otherwise a later prepareStartChat
40
+ * would dispatch Loading and maximize the widget, and only then have its nested initStartChat
41
+ * dropped.
42
+ */
43
+ const prepareStartChatGuards = new WeakMap();
44
+ const initStartChatGuards = new WeakMap();
21
45
 
22
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
- let optionalParams = {};
24
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
- let widgetInstanceId;
26
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
- let popoutWidgetInstanceId;
46
+ // Fallback owner used only when no FacadeChatSDK instance is available to key on, so the guard
47
+ // degrades to the module wide behaviour instead of disappearing altogether.
48
+ const unkeyedGuardOwner = {};
49
+ const getGuard = (guards, facadeChatSDK) => {
50
+ const owner = facadeChatSDK ?? unkeyedGuardOwner;
51
+ let guard = guards.get(owner);
52
+ if (!guard) {
53
+ guard = {
54
+ inFlight: false
55
+ };
56
+ guards.set(owner, guard);
57
+ }
58
+ return guard;
59
+ };
60
+
61
+ /**
62
+ * Runs `operation` only if no other invocation for the same widget instance is already in
63
+ * flight. Concurrent invocations are dropped (and reported through telemetry) rather than
64
+ * awaiting the in-flight promise, so a future re-entrant call can never deadlock on itself.
65
+ * No caller acts on the resolved value, so dropping is safe.
66
+ *
67
+ * `blockedBy` lets an operation also yield to a different, already running operation for the
68
+ * same widget instance without claiming that operation's lock.
69
+ */
70
+ const runExclusively = async (guards, facadeChatSDK, description, operation, props, blockedBy) => {
71
+ const guard = getGuard(guards, facadeChatSDK);
72
+ const blockingGuard = blockedBy ? getGuard(blockedBy, facadeChatSDK) : undefined;
73
+ if (guard.inFlight || blockingGuard !== null && blockingGuard !== void 0 && blockingGuard.inFlight) {
74
+ TelemetryHelper.logActionEvent(LogLevel.WARN, {
75
+ Event: TelemetryEvent.ConcurrentStartChatRejected,
76
+ Description: description,
77
+ CustomProperties: {
78
+ WidgetCacheId: props ? getWidgetCacheIdfromProps(props) : undefined
79
+ }
80
+ });
81
+ return;
82
+ }
83
+
84
+ // Claim the lock before invoking the operation, so a re-entrant call made from the
85
+ // operation's synchronous preamble (before its first await) cannot slip past the check above.
86
+ guard.inFlight = true;
87
+ try {
88
+ await operation();
89
+ } finally {
90
+ guard.inFlight = false;
91
+ }
92
+ };
28
93
 
29
94
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
95
  const prepareStartChat = async (props, facadeChatSDK, state, dispatch, setAdapter) => {
31
- optionalParams = {}; //Resetting to ensure no stale values
32
- widgetInstanceId = getWidgetCacheIdfromProps(props);
96
+ return runExclusively(prepareStartChatGuards, facadeChatSDK, "Concurrent prepareStartChat call ignored, a start chat operation is already in progress for this widget instance.", () => prepareStartChatInternal(props, facadeChatSDK, state, dispatch, setAdapter), props, initStartChatGuards);
97
+ };
33
98
 
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ const prepareStartChatInternal = async (props, facadeChatSDK, state, dispatch, setAdapter) => {
34
101
  // reconnect > chat from cache
35
102
  if (isReconnectEnabled(props.chatConfig) === true && !isPersistentEnabled(props.chatConfig)) {
36
103
  const shouldStartChatNormally = await handleChatReconnect(facadeChatSDK, props, dispatch, setAdapter, initStartChat, state);
@@ -156,6 +223,11 @@ const setPreChatAndInitiateChat = async (facadeChatSDK, dispatch, setAdapter, is
156
223
 
157
224
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
225
  const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState) => {
226
+ return runExclusively(initStartChatGuards, facadeChatSDK, "Concurrent initStartChat call ignored, a start chat operation is already in progress for this widget instance.", () => initStartChatInternal(facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState), props);
227
+ };
228
+
229
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
230
+ const initStartChatInternal = async (facadeChatSDK, dispatch, setAdapter, state, props, params, persistedState) => {
159
231
  var _state$domainStates7;
160
232
  let isStartChatSuccessful = false;
161
233
  const persistentChatEnabled = isPersistentChatEnabled(state === null || state === void 0 || (_state$domainStates7 = state.domainStates) === null || _state$domainStates7 === void 0 || (_state$domainStates7 = _state$domainStates7.liveChatConfig) === null || _state$domainStates7 === void 0 || (_state$domainStates7 = _state$domainStates7.LiveWSAndLiveChatEngJoin) === null || _state$domainStates7 === void 0 ? void 0 : _state$domainStates7.msdyn_conversationmode);
@@ -194,14 +266,15 @@ const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props,
194
266
  }
195
267
  try {
196
268
  var _window$Microsoft, _state$domainStates8;
197
- // Set custom context params
198
- await setCustomContextParams(state, props);
269
+ // Set custom context params. Returned (not stored module-side) so that concurrent
270
+ // widget instances cannot overwrite each other's custom context.
271
+ const customContextParams = await setCustomContextParams(state, props);
199
272
  const defaultOptionalParams = {
200
273
  sendDefaultInitContext: true,
201
274
  isProactiveChat: !!(params !== null && params !== void 0 && params.isProactiveChat),
202
275
  portalContactId: (_window$Microsoft = window.Microsoft) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.Dynamic365) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.Portal) === null || _window$Microsoft === void 0 || (_window$Microsoft = _window$Microsoft.User) === null || _window$Microsoft === void 0 ? void 0 : _window$Microsoft.contactId
203
276
  };
204
- const startChatOptionalParams = Object.assign({}, params, optionalParams, defaultOptionalParams);
277
+ const startChatOptionalParams = Object.assign({}, params, customContextParams, defaultOptionalParams);
205
278
 
206
279
  // MID-AUTH: Add wasAuthenticated flag for reconnect scenarios
207
280
  // Tells FacadeChatSDK whether the previous session was authenticated
@@ -284,9 +357,6 @@ const initStartChat = async (facadeChatSDK, dispatch, setAdapter, state, props,
284
357
  await updateTelemetryData(facadeChatSDK, dispatch);
285
358
  } catch (ex) {
286
359
  handleStartChatError(dispatch, facadeChatSDK, props, ex, isStartChatSuccessful);
287
- } finally {
288
- optionalParams = {};
289
- widgetInstanceId = "";
290
360
  }
291
361
  };
292
362
 
@@ -330,19 +400,22 @@ const canConnectToExistingChat = async (props, facadeChatSDK, state, dispatch, s
330
400
  return false;
331
401
  };
332
402
 
333
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
+ /**
404
+ * Resolves the custom context to start the chat with.
405
+ *
406
+ * Returns the resolved params instead of mutating module-level state: a page can host several
407
+ * independent widgets, and once per-instance start-chat guards allow them to run concurrently,
408
+ * shared mutable state would let one widget overwrite another widget's custom context.
409
+ */
334
410
  const setCustomContextParams = async (state, props) => {
335
411
  var _state$domainStates9, _persistedState$domai5;
336
412
  if (state !== null && state !== void 0 && (_state$domainStates9 = state.domainStates) !== null && _state$domainStates9 !== void 0 && _state$domainStates9.customContext) {
337
413
  var _state$domainStates0;
338
- optionalParams = Object.assign({}, optionalParams, {
414
+ return {
339
415
  customContext: JSON.parse(JSON.stringify(state === null || state === void 0 || (_state$domainStates0 = state.domainStates) === null || _state$domainStates0 === void 0 ? void 0 : _state$domainStates0.customContext))
340
- });
341
- return;
342
- }
343
- if (isNullOrEmptyString(widgetInstanceId)) {
344
- widgetInstanceId = getWidgetCacheIdfromProps(props);
416
+ };
345
417
  }
418
+ const widgetInstanceId = props ? getWidgetCacheIdfromProps(props) : "";
346
419
  // Add custom context only for unauthenticated chat
347
420
  const persistedState = getStateFromCache(widgetInstanceId);
348
421
  const customContextLocal = (persistedState === null || persistedState === void 0 || (_persistedState$domai5 = persistedState.domainStates) === null || _persistedState$domai5 === void 0 ? void 0 : _persistedState$domai5.customContext) ?? (props === null || props === void 0 ? void 0 : props.initialCustomContext);
@@ -351,23 +424,23 @@ const setCustomContextParams = async (state, props) => {
351
424
  Event: TelemetryEvent.SettingCustomContext,
352
425
  Description: "Setting custom context for unauthenticated chat"
353
426
  });
354
- optionalParams = Object.assign({}, optionalParams, {
427
+ return {
355
428
  customContext: JSON.parse(JSON.stringify(customContextLocal))
356
- });
357
- } else {
358
- const customContextFromParent = await getInitContextParamsForPopout();
359
- if (!isUndefinedOrEmpty(customContextFromParent === null || customContextFromParent === void 0 ? void 0 : customContextFromParent.contextVariables)) {
360
- optionalParams = Object.assign({}, optionalParams, {
361
- customContext: JSON.parse(JSON.stringify(customContextFromParent.contextVariables))
362
- });
363
- }
429
+ };
430
+ }
431
+ const customContextFromParent = await getInitContextParamsForPopout();
432
+ if (!isUndefinedOrEmpty(customContextFromParent === null || customContextFromParent === void 0 ? void 0 : customContextFromParent.contextVariables)) {
433
+ return {
434
+ customContext: JSON.parse(JSON.stringify(customContextFromParent.contextVariables))
435
+ };
364
436
  }
437
+ return {};
365
438
  };
366
439
  const canStartPopoutChat = async props => {
367
440
  if (props.allowSdkChatSupport === false) {
368
441
  return false;
369
442
  }
370
- popoutWidgetInstanceId = getWidgetCacheIdfromProps(props, true);
443
+ const popoutWidgetInstanceId = getWidgetCacheIdfromProps(props, true);
371
444
  if (!isNullOrEmptyString(popoutWidgetInstanceId)) {
372
445
  var _persistedState$domai6, _persistedState$appSt3;
373
446
  const persistedState = getStateFromCache(popoutWidgetInstanceId);
@@ -62,6 +62,7 @@ import useChatAdapterStore from "../../../hooks/useChatAdapterStore";
62
62
  import useChatContextStore from "../../../hooks/useChatContextStore";
63
63
  import useFacadeSDKStore from "../../../hooks/useFacadeChatSDKStore";
64
64
  import { getPostChatContext, initiatePostChat } from "../common/renderSurveyHelpers";
65
+ import { logRecoveryEligibilityTelemetry } from "../common/recoveryEligibilityTelemetry";
65
66
  let uiTimer;
66
67
  export const LiveChatWidgetStateful = props => {
67
68
  var _props$webChatContain, _props$webChatContain2, _props$webChatContain3, _props$webChatContain4, _props$webChatContain5, _props$styleProps, _props$webChatContain6, _props$controlProps, _props$controlProps3, _state$appStates7, _props$webChatContain9, _state$appStates8, _props$webChatContain1, _props$webChatContain10, _props$controlProps10, _props$draggableChatW, _props$draggableChatW2, _props$draggableChatW3, _props$draggableChatW4, _props$draggableChatW5, _livechatProps$webCha, _livechatProps$styleP, _props$headerProps, _livechatProps$contro, _livechatProps$contro2, _livechatProps$compon, _livechatProps$contro3, _livechatProps$compon2, _livechatProps$contro4, _livechatProps$compon3, _livechatProps$contro5, _livechatProps$compon4, _livechatProps$contro6, _livechatProps$compon5, _livechatProps$contro7, _livechatProps$compon6, _livechatProps$contro8, _livechatProps$compon7, _livechatProps$contro9, _livechatProps$compon8, _livechatProps$contro0, _livechatProps$contro1, _livechatProps$compon9, _livechatProps$contro10, _livechatProps$compon0, _livechatProps$contro11, _livechatProps$compon1, _livechatProps$compon10, _livechatProps$compon11;
@@ -150,6 +151,7 @@ export const LiveChatWidgetStateful = props => {
150
151
  activeCachedChatExist = false;
151
152
  optionalParams = {};
152
153
  }
154
+ logRecoveryEligibilityTelemetry(state, activeCachedChatExist);
153
155
  };
154
156
 
155
157
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1165,18 +1167,18 @@ export const LiveChatWidgetStateful = props => {
1165
1167
  .webchat__auto-resize-textarea.webchat__send-box-text-box__text-area {
1166
1168
  min-height: ${sendBoxTextArea === null || sendBoxTextArea === void 0 ? void 0 : sendBoxTextArea.minHeight} !important;
1167
1169
  }`}
1168
- `), /*#__PURE__*/React.createElement(DraggableChatWidget, chatWidgetDraggableConfig, /*#__PURE__*/React.createElement(Composer, _extends({}, webChatProps, {
1169
- userID: userID,
1170
- styleOptions: styleOptions,
1171
- directLine: directLine
1172
- }), /*#__PURE__*/React.createElement(Stack, {
1170
+ `), /*#__PURE__*/React.createElement(DraggableChatWidget, chatWidgetDraggableConfig, /*#__PURE__*/React.createElement(Stack, {
1173
1171
  id: widgetElementId,
1174
1172
  styles: generalStyles,
1175
1173
  className: (_livechatProps$styleP = livechatProps.styleProps) === null || _livechatProps$styleP === void 0 ? void 0 : _livechatProps$styleP.className,
1176
1174
  role: isWidgetOpen ? "dialog" : undefined,
1177
1175
  "aria-modal": isWidgetOpen ? true : undefined,
1178
1176
  "aria-label": isWidgetOpen ? ((_props$headerProps = props.headerProps) === null || _props$headerProps === void 0 || (_props$headerProps = _props$headerProps.controlProps) === null || _props$headerProps === void 0 || (_props$headerProps = _props$headerProps.headerTitleProps) === null || _props$headerProps === void 0 ? void 0 : _props$headerProps.text) ?? "Live Chat" : undefined
1179
- }, !((_livechatProps$contro = livechatProps.controlProps) !== null && _livechatProps$contro !== void 0 && _livechatProps$contro.hideChatButton) && !((_livechatProps$contro2 = livechatProps.controlProps) !== null && _livechatProps$contro2 !== void 0 && _livechatProps$contro2.hideStartChatButton) && shouldShowChatButton(state) && (decodeComponentString((_livechatProps$compon = livechatProps.componentOverrides) === null || _livechatProps$compon === void 0 ? void 0 : _livechatProps$compon.chatButton) || /*#__PURE__*/React.createElement(ChatButtonStateful, {
1177
+ }, /*#__PURE__*/React.createElement(Composer, _extends({}, webChatProps, {
1178
+ userID: userID,
1179
+ styleOptions: styleOptions,
1180
+ directLine: directLine
1181
+ }), !((_livechatProps$contro = livechatProps.controlProps) !== null && _livechatProps$contro !== void 0 && _livechatProps$contro.hideChatButton) && !((_livechatProps$contro2 = livechatProps.controlProps) !== null && _livechatProps$contro2 !== void 0 && _livechatProps$contro2.hideStartChatButton) && shouldShowChatButton(state) && (decodeComponentString((_livechatProps$compon = livechatProps.componentOverrides) === null || _livechatProps$compon === void 0 ? void 0 : _livechatProps$compon.chatButton) || /*#__PURE__*/React.createElement(ChatButtonStateful, {
1180
1182
  buttonProps: livechatProps.chatButtonProps,
1181
1183
  outOfOfficeButtonProps: livechatProps.outOfOfficeChatButtonProps,
1182
1184
  startChat: prepareStartChatRelay
@@ -529,9 +529,9 @@ export const WebChatContainerStateful = props => {
529
529
 
530
530
  /* Suggested actions carousel previous/next navigation focus */
531
531
  .webchat__suggested-actions .webchat__suggested-actions__carousel .react-film__flipper:focus-visible .react-film__flipper__body {
532
- outline: ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp20 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp20 === void 0 ? void 0 : _webChatContainerProp20.suggestedActionKeyboardFocusIndicatorBorderStyle) ?? "dashed"} ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp21 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp21 === void 0 ? void 0 : _webChatContainerProp21.suggestedActionKeyboardFocusIndicatorBorderWidth) ?? "1px"} ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp22 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp22 === void 0 ? void 0 : _webChatContainerProp22.suggestedActionKeyboardFocusIndicatorBorderColor) ?? "#605E5C"} !important;
532
+ outline: ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp20 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp20 === void 0 ? void 0 : _webChatContainerProp20.suggestedActionKeyboardFocusIndicatorBorderStyle) ?? "solid"} ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp21 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp21 === void 0 ? void 0 : _webChatContainerProp21.suggestedActionKeyboardFocusIndicatorBorderWidth) ?? "2px"} ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp22 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp22 === void 0 ? void 0 : _webChatContainerProp22.suggestedActionKeyboardFocusIndicatorBorderColor) ?? "#605E5C"} !important;
533
533
  outline-offset: ${(webChatContainerProps === null || webChatContainerProps === void 0 || (_webChatContainerProp23 = webChatContainerProps.webChatStyles) === null || _webChatContainerProp23 === void 0 ? void 0 : _webChatContainerProp23.suggestedActionKeyboardFocusIndicatorInset) ?? "2px"} !important;
534
-
534
+ }
535
535
  `), /*#__PURE__*/React.createElement(Stack, {
536
536
  styles: containerStyles,
537
537
  className: "webchat__stacked-layout_container"
@@ -27,7 +27,8 @@ export const defaultMiddlewareLocalizedTexts = {
27
27
  MIDDLEWARE_BANNER_CHAT_DISCONNECT: "Your conversation has been disconnected. For additional assistance, please start a new chat.",
28
28
  THIRD_PARTY_COOKIES_BLOCKED_ALERT_MESSAGE: "Allow sites to save/read cookies in browser settings. Reloading page starts a new chat.",
29
29
  MIDDLEWARE_BANNER_FILE_IS_MALICIOUS: "{0} has been blocked because the file may contain a malware.",
30
- MIDDLEWARE_BANNER_FILE_SENT: "File sent successfully.",
30
+ MIDDLEWARE_BANNER_FILE_SENT: "File {2} sent",
31
+ MIDDLEWARE_BANNER_FILES_SENT: "{0} files sent",
31
32
  MIDDLEWARE_BANNER_FILE_EMAIL_ADDRESS_RECORDED_SUCCESS: "Email will be sent after chat ends!",
32
33
  MIDDLEWARE_BANNER_FILE_EMAIL_ADDRESS_RECORDED_ERROR: "Email {0} could not be saved, try again later.",
33
34
  MIDDLEWARE_SR_PREFIX_SUCCESS: "Success. ",
@@ -88,6 +88,19 @@ const AdaptiveCardAccessibilityWrapper = _ref => {
88
88
  }
89
89
  });
90
90
 
91
+ // action-button-double-read: the adaptivecards renderer writes the action
92
+ // title to the button text, aria-label AND title. The title becomes the
93
+ // accessible description, so TalkBack reads the label twice ("Submit,
94
+ // button, Submit"). Drop the title only when it repeats the button name;
95
+ // a distinct title (an action tooltip) adds information and is kept.
96
+ container.querySelectorAll("button[title]").forEach(btn => {
97
+ const title = (btn.getAttribute("title") || "").trim();
98
+ const name = (btn.getAttribute("aria-label") ?? btn.textContent ?? "").trim();
99
+ if (title && title === name) {
100
+ btn.removeAttribute("title");
101
+ }
102
+ });
103
+
91
104
  // Find all radio inputs inside the adaptive card
92
105
  const radios = container.querySelectorAll("input[type='radio']");
93
106
  if (radios.length === 0) return;
@@ -169,7 +182,8 @@ const AdaptiveCardAccessibilityWrapper = _ref => {
169
182
 
170
183
  // Use MutationObserver to patch after WebChat finishes rendering the card.
171
184
  // Also watch attribute changes on aria-hidden so we can re-apply if React
172
- // reconciliation removes it from labels.
185
+ // reconciliation removes it from labels, and on title so a re-added
186
+ // duplicate button title is removed again.
173
187
  const observer = new MutationObserver(() => {
174
188
  patchRadioGroups();
175
189
  });
@@ -177,7 +191,7 @@ const AdaptiveCardAccessibilityWrapper = _ref => {
177
191
  childList: true,
178
192
  subtree: true,
179
193
  attributes: true,
180
- attributeFilter: ["aria-hidden"]
194
+ attributeFilter: ["aria-hidden", "title"]
181
195
  });
182
196
 
183
197
  // Also run immediately in case the card is already rendered
@@ -2,26 +2,36 @@
2
2
  * AttachmentSentAnnouncementMiddleware
3
3
  *
4
4
  * When a file attachment is successfully sent, announces the result to
5
- * screen readers via an aria-live region so that assistive technology
6
- * users receive immediate feedback.
5
+ * screen readers so that assistive technology users receive immediate
6
+ * feedback ("File report.pdf sent").
7
+ *
8
+ * The announcement region lives inside the chat dialog. While the dialog is
9
+ * open it is aria-modal and its host siblings are aria-hidden, so screen
10
+ * readers (TalkBack, VoiceOver) ignore live regions outside it.
7
11
  ******/
8
12
 
9
13
  import { isWebChatAction } from "../../../interfaces/IWebChatAction";
10
14
  import { WebChatActionType } from "../../enums/WebChatActionType";
11
- const ARIA_LIVE_REGION_ID = "oc-lcw-attachment-announcement";
15
+ export const ATTACHMENT_ANNOUNCEMENT_REGION_ID = "oc-lcw-attachment-announcement";
16
+ const DEFAULT_FILE_SENT_TEXT = "File {2} sent";
17
+ const DEFAULT_FILES_SENT_TEXT = "{0} files sent";
18
+
19
+ // Let the send-box focus shift settle before speaking, then keep the node
20
+ // long enough for the screen reader to read it.
21
+ const ANNOUNCE_DELAY_MS = 100;
22
+ const ANNOUNCEMENT_LIFETIME_MS = 5000;
12
23
 
13
24
  /**
14
- * Ensures a visually-hidden aria-live region exists in the DOM for
15
- * announcing attachment upload results to screen readers.
25
+ * Ensures a visually-hidden aria-live region exists inside the chat dialog
26
+ * for announcing attachment upload results to screen readers.
16
27
  */
17
- const ensureAriaLiveRegion = () => {
18
- let region = document.getElementById(ARIA_LIVE_REGION_ID);
28
+ const ensureAriaLiveRegion = widgetElementId => {
29
+ const host = document.getElementById(widgetElementId) || document.body;
30
+ let region = document.getElementById(ATTACHMENT_ANNOUNCEMENT_REGION_ID);
19
31
  if (!region) {
20
32
  region = document.createElement("div");
21
- region.id = ARIA_LIVE_REGION_ID;
33
+ region.id = ATTACHMENT_ANNOUNCEMENT_REGION_ID;
22
34
  region.setAttribute("aria-live", "polite");
23
- region.setAttribute("role", "status");
24
- region.setAttribute("aria-atomic", "true");
25
35
  // Visually hidden but available to screen readers
26
36
  Object.assign(region.style, {
27
37
  position: "absolute",
@@ -34,44 +44,60 @@ const ensureAriaLiveRegion = () => {
34
44
  whiteSpace: "nowrap",
35
45
  border: "0"
36
46
  });
37
- const container = document.getElementById("oc-lcw-container");
38
- (container || document.body).appendChild(region);
47
+ }
48
+ if (region.parentElement !== host) {
49
+ host.appendChild(region);
39
50
  }
40
51
  return region;
41
52
  };
42
53
 
43
54
  /**
44
- * Announces a message to screen readers by updating the aria-live region.
45
- * Clears it briefly first so repeated identical messages are still announced.
55
+ * Announces a message by appending a fresh node to the live region.
56
+ * TalkBack reliably speaks newly added nodes but often misses text changes
57
+ * on an existing node, so each announcement gets its own element.
46
58
  */
47
- const announce = message => {
48
- const region = ensureAriaLiveRegion();
49
- region.textContent = "";
50
- // Small delay so the screen reader detects the content change
59
+ const announce = (message, widgetElementId) => {
60
+ const region = ensureAriaLiveRegion(widgetElementId);
51
61
  setTimeout(() => {
52
- region.textContent = message;
53
- }, 100);
62
+ const node = document.createElement("div");
63
+ node.textContent = message;
64
+ region.appendChild(node);
65
+ setTimeout(() => node.remove(), ANNOUNCEMENT_LIFETIME_MS);
66
+ }, ANNOUNCE_DELAY_MS);
54
67
  };
55
68
 
56
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
57
- const attachmentSentAnnouncementMiddleware = _ref => {
58
- let dispatch = _ref.dispatch;
59
- return next => action => {
60
- if (!isWebChatAction(action)) {
61
- return next(action);
62
- }
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ export const getFileSentMessage = (attachments, localizedTexts) => {
71
+ var _attachments$;
72
+ if (attachments.length > 1) {
73
+ // Replacer callbacks insert values literally; a string would treat "$&" or "$$" as patterns.
74
+ return ((localizedTexts === null || localizedTexts === void 0 ? void 0 : localizedTexts.MIDDLEWARE_BANNER_FILES_SENT) || DEFAULT_FILES_SENT_TEXT).replace("{0}", () => String(attachments.length));
75
+ }
76
+ const name = typeof ((_attachments$ = attachments[0]) === null || _attachments$ === void 0 ? void 0 : _attachments$.name) === "string" ? attachments[0].name.trim() : "";
77
+ return ((localizedTexts === null || localizedTexts === void 0 ? void 0 : localizedTexts.MIDDLEWARE_BANNER_FILE_SENT) || DEFAULT_FILE_SENT_TEXT).replace("{2}", () => name).replace(/\s{2,}/g, " ").trim();
78
+ };
79
+ export const createAttachmentSentAnnouncementMiddleware = function (localizedTexts) {
80
+ let widgetElementId = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "oc-lcw";
81
+ return (
82
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
83
+ _ref => {
84
+ let dispatch = _ref.dispatch;
85
+ return next => action => {
86
+ if (!isWebChatAction(action)) {
87
+ return next(action);
88
+ }
63
89
 
64
- // Intercept successfully posted activities that contain attachments
65
- if (action.type === WebChatActionType.DIRECT_LINE_POST_ACTIVITY_FULFILLED) {
66
- var _action$payload;
67
- const attachments = (_action$payload = action.payload) === null || _action$payload === void 0 || (_action$payload = _action$payload.activity) === null || _action$payload === void 0 ? void 0 : _action$payload.attachments;
68
- if (attachments && attachments.length > 0) {
69
- const count = attachments.length;
70
- const message = count === 1 ? "File sent" : `${count} files sent`;
71
- announce(message);
72
- }
90
+ // Intercept successfully posted activities that contain attachments
91
+ if (action.type === WebChatActionType.DIRECT_LINE_POST_ACTIVITY_FULFILLED) {
92
+ var _action$payload;
93
+ const attachments = (_action$payload = action.payload) === null || _action$payload === void 0 || (_action$payload = _action$payload.activity) === null || _action$payload === void 0 ? void 0 : _action$payload.attachments;
94
+ if (attachments && attachments.length > 0) {
95
+ announce(getFileSentMessage(attachments, localizedTexts), widgetElementId);
96
+ }
97
+ }
98
+ return next(action);
99
+ };
73
100
  }
74
- return next(action);
75
- };
101
+ );
76
102
  };
77
- export default attachmentSentAnnouncementMiddleware;
103
+ export default createAttachmentSentAnnouncementMiddleware;
@@ -12,27 +12,6 @@ import { NotificationScenarios } from "../../enums/NotificationScenarios";
12
12
  import { TelemetryHelper } from "../../../../../common/telemetry/TelemetryHelper";
13
13
  import { WebChatActionType } from "../../enums/WebChatActionType";
14
14
  const MBtoBRatio = 1000000;
15
- const announceFileSent = message => {
16
- // TalkBack on Android WebView reliably announces newly *appended*
17
- // role="alert" nodes but often misses text-content updates on existing
18
- // nodes. Wait 500ms for the send-box focus shift to settle, then inject
19
- // a fresh alert element appended to document.body and remove it after 3s.
20
- setTimeout(() => {
21
- const el = document.createElement("div");
22
- el.setAttribute("role", "alert");
23
- el.setAttribute("aria-live", "assertive");
24
- el.setAttribute("aria-atomic", "true");
25
- el.style.cssText = "position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;";
26
- el.textContent = message;
27
- document.body.appendChild(el);
28
- // Remove after TalkBack has had time to read it
29
- setTimeout(() => {
30
- if (el.parentNode) {
31
- el.parentNode.removeChild(el);
32
- }
33
- }, 3000);
34
- }, 500);
35
- };
36
15
 
37
16
  /*
38
17
  * If an attachment is invalid, delete this attachment from the attachments list
@@ -221,13 +200,7 @@ const createAttachmentUploadValidatorMiddleware = (allowedFileExtensions, maxFil
221
200
  var _payload$activity, _payload$activity2;
222
201
  const payload = action.payload;
223
202
  if (payload !== null && payload !== void 0 && payload.activity.attachments && payload.activity.attachments.length > 0 && (payload === null || payload === void 0 || (_payload$activity = payload.activity) === null || _payload$activity === void 0 || (_payload$activity = _payload$activity.attachments) === null || _payload$activity === void 0 ? void 0 : _payload$activity.length) === (payload === null || payload === void 0 || (_payload$activity2 = payload.activity) === null || _payload$activity2 === void 0 || (_payload$activity2 = _payload$activity2.channelData) === null || _payload$activity2 === void 0 || (_payload$activity2 = _payload$activity2.attachmentSizes) === null || _payload$activity2 === void 0 ? void 0 : _payload$activity2.length)) {
224
- var _validatedAction$payl;
225
203
  const validatedAction = validateAttachment(action, allowedFileExtensions, maxFileSizeSupportedByDynamics, localizedTexts);
226
-
227
- // Announce to screen readers that the file was sent when validation passes
228
- if (((_validatedAction$payl = validatedAction.payload) === null || _validatedAction$payl === void 0 || (_validatedAction$payl = _validatedAction$payl.activity) === null || _validatedAction$payl === void 0 || (_validatedAction$payl = _validatedAction$payl.attachments) === null || _validatedAction$payl === void 0 ? void 0 : _validatedAction$payl.length) > 0) {
229
- announceFileSent(localizedTexts.MIDDLEWARE_BANNER_FILE_SENT ?? "File sent successfully.");
230
- }
231
204
  return next(validatedAction);
232
205
  }
233
206
  }
@@ -95,6 +95,7 @@ export declare enum TelemetryEvent {
95
95
  MidConversationAuthSucceeded = "MidConversationAuthSucceeded",
96
96
  MidConversationAuthFailed = "MidConversationAuthFailed",
97
97
  MidConversationAuthReset = "MidConversationAuthReset",
98
+ RecoveryEligibilityEvaluated = "RecoveryEligibilityEvaluated",
98
99
  GetConversationDetailsCallStarted = "GetConversationDetailsCallStarted",
99
100
  GetConversationDetailsCallFailed = "GetConversationDetailsCallFailed",
100
101
  EndChatSDKCallFailed = "EndChatSDKCallFailed",
@@ -126,6 +127,8 @@ export declare enum TelemetryEvent {
126
127
  PrechatSubmitted = "PrechatSubmitted",
127
128
  StartChatSDKCall = "StartChatCall",
128
129
  StartChatEventReceived = "StartChatEventReceived",
130
+ ConcurrentStartChatRejected = "ConcurrentStartChatRejected",
131
+ ChatSDKStateCleanUpDisconnectFailed = "ChatSDKStateCleanUpDisconnectFailed",
129
132
  EndChatSDKCall = "EndChatSDKCall",
130
133
  PrepareEndChat = "PrepareEndChat",
131
134
  EndChatEventReceived = "EndChatEventReceived",
@@ -0,0 +1,17 @@
1
+ import { ILiveChatWidgetContext } from "../../../contexts/common/ILiveChatWidgetContext";
2
+ export declare enum RecoveryEligibilityReason {
3
+ ReconnectIdPresent = "ReconnectIdPresent",
4
+ ActiveContextPresent = "ActiveContextPresent",
5
+ MissingContext = "MissingContext",
6
+ CachedContextNonActive = "CachedContextNonActive"
7
+ }
8
+ export interface RecoveryEligibilityTelemetryProperties {
9
+ conversationState: string;
10
+ hasReconnectId: boolean;
11
+ hasLiveChatContext: boolean;
12
+ hasRequestId: boolean;
13
+ eligible: boolean;
14
+ reason: RecoveryEligibilityReason;
15
+ }
16
+ export declare const getRecoveryEligibilityTelemetryProperties: (state: ILiveChatWidgetContext, eligible: boolean) => RecoveryEligibilityTelemetryProperties;
17
+ export declare const logRecoveryEligibilityTelemetry: (state: ILiveChatWidgetContext, eligible: boolean) => void;
@@ -2,9 +2,16 @@
2
2
  * AttachmentSentAnnouncementMiddleware
3
3
  *
4
4
  * When a file attachment is successfully sent, announces the result to
5
- * screen readers via an aria-live region so that assistive technology
6
- * users receive immediate feedback.
5
+ * screen readers so that assistive technology users receive immediate
6
+ * feedback ("File report.pdf sent").
7
+ *
8
+ * The announcement region lives inside the chat dialog. While the dialog is
9
+ * open it is aria-modal and its host siblings are aria-hidden, so screen
10
+ * readers (TalkBack, VoiceOver) ignore live regions outside it.
7
11
  ******/
12
+ import { ILiveChatWidgetLocalizedTexts } from "../../../../../contexts/common/ILiveChatWidgetLocalizedTexts";
8
13
  import { WebChatStoreMiddleware } from "../../../interfaces/IWebChatAction";
9
- declare const attachmentSentAnnouncementMiddleware: WebChatStoreMiddleware;
10
- export default attachmentSentAnnouncementMiddleware;
14
+ export declare const ATTACHMENT_ANNOUNCEMENT_REGION_ID = "oc-lcw-attachment-announcement";
15
+ export declare const getFileSentMessage: (attachments: any[], localizedTexts?: ILiveChatWidgetLocalizedTexts) => string;
16
+ export declare const createAttachmentSentAnnouncementMiddleware: (localizedTexts?: ILiveChatWidgetLocalizedTexts, widgetElementId?: string) => WebChatStoreMiddleware;
17
+ export default createAttachmentSentAnnouncementMiddleware;
@@ -35,10 +35,17 @@ export interface ILiveChatWidgetLocalizedTexts {
35
35
  */
36
36
  MIDDLEWARE_BANNER_FILE_IS_MALICIOUS?: string;
37
37
  /**
38
- * Success message announced by screen readers when a file is successfully sent.
39
- * e.g. "File sent successfully."
38
+ * Message announced by screen readers when one file is successfully sent.
39
+ * {2} - File name
40
+ * e.g. "File {2} sent"
40
41
  */
41
42
  MIDDLEWARE_BANNER_FILE_SENT?: string;
43
+ /**
44
+ * Message announced by screen readers when several files are sent in one message.
45
+ * {0} - Number of files
46
+ * e.g. "{0} files sent"
47
+ */
48
+ MIDDLEWARE_BANNER_FILES_SENT?: string;
42
49
  /**
43
50
  * Success message, indicating the email address introduced has been registered to receive the transcript.
44
51
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/omnichannel-chat-widget",
3
- "version": "2.0.0",
3
+ "version": "2.0.1-main.38e7390",
4
4
  "description": "Microsoft Omnichannel Chat Widget",
5
5
  "main": "lib/cjs/index.js",
6
6
  "types": "lib/types/index.d.ts",
@@ -72,7 +72,7 @@
72
72
  "@axe-core/react": "^4.10.2",
73
73
  "accessibility-insights-scan": "^3.0.0",
74
74
  "@guidepup/guidepup": "^0.24.1",
75
- "postcss": "^8.3.9",
75
+ "postcss": "8.5.19",
76
76
  "react": "^18.3.1",
77
77
  "react-dom": "^18.3.1",
78
78
  "react-native-web": "^0.21.2",
@@ -93,18 +93,18 @@
93
93
  "@azure/core-tracing": "^1.2.0",
94
94
  "@microsoft/applicationinsights-web": "^3.3.6",
95
95
  "@microsoft/omnichannel-chat-components": "1.2.0",
96
- "@microsoft/omnichannel-chat-sdk": "2.0.0",
96
+ "@microsoft/omnichannel-chat-sdk": "2.0.1",
97
97
  "@opentelemetry/api": "^1.9.0",
98
98
  "abort-controller": "^3",
99
99
  "botframework-webchat": "4.18.1-hotfix.20260308.b15b405",
100
100
  "core-js-pure": "^3.42.0",
101
- "dompurify": "^3.4.12",
101
+ "dompurify": "3.4.16",
102
102
  "markdown-it": "^14.3.0",
103
103
  "markdown-it-attrs": "^4.1.6",
104
104
  "markdown-it-for-inline": "^0.1.1",
105
105
  "md5-typescript": "^1.0.5",
106
106
  "rxjs": "5.5.12",
107
- "sanitize-html": "2.17.6",
107
+ "sanitize-html": "2.17.7",
108
108
  "simple-update-in": "2.2.0",
109
109
  "slack-markdown-it": "^1.0.5"
110
110
  },
@@ -163,7 +163,8 @@
163
163
  "**/@types/minimatch": "3.0.5",
164
164
  "**/@types/trusted-types": "2.0.7",
165
165
  "**/join-images": "1.1.5",
166
- "**/valibot": "1.4.2"
166
+ "**/valibot": "1.4.2",
167
+ "**/nanoid": "3.3.18"
167
168
  },
168
169
  "jest": {
169
170
  "verbose": true,