@microsoft/omnichannel-chat-widget 2.0.1-main.a2ef2a3 → 2.0.1-main.eaef039

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 (24) hide show
  1. package/lib/cjs/common/telemetry/TelemetryConstants.js +4 -0
  2. package/lib/cjs/components/chatbuttonstateful/ChatButtonStateful.js +42 -25
  3. package/lib/cjs/components/livechatwidget/common/endChat.js +39 -0
  4. package/lib/cjs/components/livechatwidget/common/initWebChatComposer.js +2 -2
  5. package/lib/cjs/components/livechatwidget/common/startChat.js +107 -33
  6. package/lib/cjs/components/webchatcontainerstateful/WebChatContainerStateful.js +2 -2
  7. package/lib/cjs/components/webchatcontainerstateful/common/defaultProps/defaultMiddlewareLocalizedTexts.js +2 -1
  8. package/lib/cjs/components/webchatcontainerstateful/webchatcontroller/middlewares/renderingmiddlewares/attachments/AdaptiveCardAccessibilityWrapper.js +16 -2
  9. package/lib/cjs/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware.js +68 -40
  10. package/lib/cjs/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentUploadValidatorMiddleware.js +0 -27
  11. package/lib/esm/common/telemetry/TelemetryConstants.js +4 -0
  12. package/lib/esm/components/chatbuttonstateful/ChatButtonStateful.js +42 -25
  13. package/lib/esm/components/livechatwidget/common/endChat.js +39 -0
  14. package/lib/esm/components/livechatwidget/common/initWebChatComposer.js +3 -3
  15. package/lib/esm/components/livechatwidget/common/startChat.js +104 -31
  16. package/lib/esm/components/webchatcontainerstateful/WebChatContainerStateful.js +2 -2
  17. package/lib/esm/components/webchatcontainerstateful/common/defaultProps/defaultMiddlewareLocalizedTexts.js +2 -1
  18. package/lib/esm/components/webchatcontainerstateful/webchatcontroller/middlewares/renderingmiddlewares/attachments/AdaptiveCardAccessibilityWrapper.js +16 -2
  19. package/lib/esm/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware.js +65 -39
  20. package/lib/esm/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentUploadValidatorMiddleware.js +0 -27
  21. package/lib/types/common/telemetry/TelemetryConstants.d.ts +2 -0
  22. package/lib/types/components/webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware.d.ts +11 -4
  23. package/lib/types/contexts/common/ILiveChatWidgetLocalizedTexts.d.ts +9 -2
  24. package/package.json +5 -5
@@ -18,27 +18,6 @@ var _WebChatActionType = require("../../enums/WebChatActionType");
18
18
  ******/
19
19
 
20
20
  const MBtoBRatio = 1000000;
21
- const announceFileSent = message => {
22
- // TalkBack on Android WebView reliably announces newly *appended*
23
- // role="alert" nodes but often misses text-content updates on existing
24
- // nodes. Wait 500ms for the send-box focus shift to settle, then inject
25
- // a fresh alert element appended to document.body and remove it after 3s.
26
- setTimeout(() => {
27
- const el = document.createElement("div");
28
- el.setAttribute("role", "alert");
29
- el.setAttribute("aria-live", "assertive");
30
- el.setAttribute("aria-atomic", "true");
31
- el.style.cssText = "position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;";
32
- el.textContent = message;
33
- document.body.appendChild(el);
34
- // Remove after TalkBack has had time to read it
35
- setTimeout(() => {
36
- if (el.parentNode) {
37
- el.parentNode.removeChild(el);
38
- }
39
- }, 3000);
40
- }, 500);
41
- };
42
21
 
43
22
  /*
44
23
  * If an attachment is invalid, delete this attachment from the attachments list
@@ -227,13 +206,7 @@ const createAttachmentUploadValidatorMiddleware = (allowedFileExtensions, maxFil
227
206
  var _payload$activity, _payload$activity2;
228
207
  const payload = action.payload;
229
208
  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)) {
230
- var _validatedAction$payl;
231
209
  const validatedAction = validateAttachment(action, allowedFileExtensions, maxFileSizeSupportedByDynamics, localizedTexts);
232
-
233
- // Announce to screen readers that the file was sent when validation passes
234
- 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) {
235
- announceFileSent(localizedTexts.MIDDLEWARE_BANNER_FILE_SENT ?? "File sent successfully.");
236
- }
237
210
  return next(validatedAction);
238
211
  }
239
212
  }
@@ -141,6 +141,8 @@ export let TelemetryEvent = /*#__PURE__*/function (TelemetryEvent) {
141
141
  TelemetryEvent["PrechatSubmitted"] = "PrechatSubmitted";
142
142
  TelemetryEvent["StartChatSDKCall"] = "StartChatCall";
143
143
  TelemetryEvent["StartChatEventReceived"] = "StartChatEventReceived";
144
+ TelemetryEvent["ConcurrentStartChatRejected"] = "ConcurrentStartChatRejected";
145
+ TelemetryEvent["ChatSDKStateCleanUpDisconnectFailed"] = "ChatSDKStateCleanUpDisconnectFailed";
144
146
  TelemetryEvent["EndChatSDKCall"] = "EndChatSDKCall";
145
147
  TelemetryEvent["PrepareEndChat"] = "PrepareEndChat";
146
148
  TelemetryEvent["EndChatEventReceived"] = "EndChatEventReceived";
@@ -413,6 +415,8 @@ export let TelemetryConstants = /*#__PURE__*/function () {
413
415
  case TelemetryEvent.SecureEventBusDispatchError:
414
416
  case TelemetryEvent.AgentJoinedConversation:
415
417
  case TelemetryEvent.BrowserTabHidden:
418
+ case TelemetryEvent.ConcurrentStartChatRejected:
419
+ case TelemetryEvent.ChatSDKStateCleanUpDisconnectFailed:
416
420
  case TelemetryEvent.RecoveryEligibilityEvaluated:
417
421
  return ScenarioType.ACTIONS;
418
422
  case TelemetryEvent.StartChatSDKCall:
@@ -50,37 +50,54 @@ export const ChatButtonStateful = props => {
50
50
  const ref = useRef(() => {
51
51
  return;
52
52
  });
53
+ // Optimistic guard against rapid double-clicks. `conversationState` only flips to
54
+ // `Loading` after React re-renders, so the button stays visible and clickable for the
55
+ // whole duration of the first click's async work. Without this ref two concurrent
56
+ // startChat() calls can create two conversations on the backend.
57
+ const startChatInProgressRef = useRef(false);
53
58
  ref.current = async () => {
54
- TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
55
- Event: TelemetryEvent.LCWChatButtonClicked,
56
- Description: "Chat button click action started",
57
- CustomProperties: {
58
- ConversationStage: ConversationStage.Initialization
59
- }
60
- });
61
- if (state.appStates.isMinimized) {
62
- dispatch({
63
- type: LiveChatWidgetActionType.SET_MINIMIZED,
64
- payload: false
59
+ if (startChatInProgressRef.current) {
60
+ TelemetryHelper.logActionEvent(LogLevel.WARN, {
61
+ Event: TelemetryEvent.ConcurrentStartChatRejected,
62
+ Description: "Chat button click ignored, a start chat operation is already in progress."
65
63
  });
66
- dispatch({
67
- type: LiveChatWidgetActionType.SET_UNREAD_MESSAGE_COUNT,
68
- payload: 0
64
+ return;
65
+ }
66
+ startChatInProgressRef.current = true;
67
+ try {
68
+ TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
69
+ Event: TelemetryEvent.LCWChatButtonClicked,
70
+ Description: "Chat button click action started",
71
+ CustomProperties: {
72
+ ConversationStage: ConversationStage.Initialization
73
+ }
69
74
  });
70
- // If chat is minimized and then unminimized, start a chat if convesation state is closed.
71
- if (state.appStates.conversationState === ConversationState.Closed) {
75
+ if (state.appStates.isMinimized) {
76
+ dispatch({
77
+ type: LiveChatWidgetActionType.SET_MINIMIZED,
78
+ payload: false
79
+ });
80
+ dispatch({
81
+ type: LiveChatWidgetActionType.SET_UNREAD_MESSAGE_COUNT,
82
+ payload: 0
83
+ });
84
+ // If chat is minimized and then unminimized, start a chat if convesation state is closed.
85
+ if (state.appStates.conversationState === ConversationState.Closed) {
86
+ await startChat();
87
+ }
88
+ } else {
72
89
  await startChat();
73
90
  }
74
- } else {
75
- await startChat();
91
+ TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
92
+ Event: TelemetryEvent.LCWChatButtonActionCompleted,
93
+ Description: "Chat button action completed",
94
+ CustomProperties: {
95
+ ConversationStage: ConversationStage.Initialization
96
+ }
97
+ });
98
+ } finally {
99
+ startChatInProgressRef.current = false;
76
100
  }
77
- TelemetryHelper.logActionEventToAllTelemetry(LogLevel.INFO, {
78
- Event: TelemetryEvent.LCWChatButtonActionCompleted,
79
- Description: "Chat button action completed",
80
- CustomProperties: {
81
- ConversationStage: ConversationStage.Initialization
82
- }
83
- });
84
101
  };
85
102
  const outOfOfficeStyleProps = Object.assign({}, defaultOutOfOfficeChatButtonStyleProps, outOfOfficeButtonProps === null || outOfOfficeButtonProps === void 0 ? void 0 : outOfOfficeButtonProps.styleProps);
86
103
  const controlProps = {
@@ -349,14 +349,53 @@ export const closeChatStateCleanUp = dispatch => {
349
349
  });
350
350
  };
351
351
 
352
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
+ const logCleanUpDisconnectFailure = error => {
354
+ TelemetryHelper.logActionEvent(LogLevel.WARN, {
355
+ Event: TelemetryEvent.ChatSDKStateCleanUpDisconnectFailed,
356
+ Description: "Failed to disconnect the previous conversation during chat SDK state clean up.",
357
+ ExceptionDetails: {
358
+ exception: (error === null || error === void 0 ? void 0 : error.message) ?? error
359
+ }
360
+ });
361
+ };
362
+
363
+ // Releases the previous session's conversation. Mirrors the cleanup OmnichannelChatSDK.endChat()
364
+ // runs in its finally block (disconnect, then drop the reference). Without it, a conversation
365
+ // left over from a failed or orphaned start chat, or from a chat ended in another tab (which
366
+ // skips the SDK endChat), keeps its messaging session open and leaks into the next chat.
367
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
368
+ const disconnectConversation = chatSDK => {
369
+ const conversation = chatSDK.conversation;
370
+ if (!conversation || typeof conversation.disconnect !== "function") {
371
+ return;
372
+ }
373
+ try {
374
+ // ACS conversations disconnect asynchronously; legacy IC3 conversations may not return a promise.
375
+ const result = conversation.disconnect();
376
+ if (result && typeof result.catch === "function") {
377
+ result.catch(logCleanUpDisconnectFailure);
378
+ }
379
+ } catch (error) {
380
+ logCleanUpDisconnectFailure(error);
381
+ }
382
+ };
383
+
352
384
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
353
385
  export const chatSDKStateCleanUp = chatSDK => {
386
+ if (!chatSDK) {
387
+ return;
388
+ }
389
+
354
390
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
355
391
  chatSDK.requestId = uuidv4();
356
392
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
393
  chatSDK.chatToken = {};
358
394
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
359
395
  chatSDK.reconnectId = null;
396
+ disconnectConversation(chatSDK);
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
398
+ chatSDK.conversation = null;
360
399
  };
361
400
 
362
401
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -11,7 +11,7 @@ import { LiveChatWidgetActionType } from "../../../contexts/common/LiveChatWidge
11
11
  import { TelemetryHelper } from "../../../common/telemetry/TelemetryHelper";
12
12
  import { WebChatStoreLoader } from "../../webchatcontainerstateful/webchatcontroller/WebChatStoreLoader";
13
13
  import attachmentProcessingMiddleware from "../../webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentProcessingMiddleware";
14
- import attachmentSentAnnouncementMiddleware from "../../webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware";
14
+ import createAttachmentSentAnnouncementMiddleware from "../../webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/attachmentSentAnnouncementMiddleware";
15
15
  import channelDataMiddleware from "../../webchatcontainerstateful/webchatcontroller/middlewares/storemiddlewares/channelDataMiddleware";
16
16
  import { createActivityMiddleware } from "../../webchatcontainerstateful/webchatcontroller/middlewares/renderingmiddlewares/activityMiddleware";
17
17
  import { createActivityStatusMiddleware } from "../../webchatcontainerstateful/webchatcontroller/middlewares/renderingmiddlewares/activityStatusMiddleware";
@@ -62,7 +62,7 @@ export const initWebChatComposer = (props, state, dispatch, facadeChatSDK, endCh
62
62
  // Initialize Web Chat's redux store
63
63
  let webChatStore = WebChatStoreLoader.store;
64
64
  if (!webChatStore) {
65
- var _state$domainStates$l, _state$domainStates$l2, _state$domainStates$l3, _props$webChatContain7;
65
+ var _state$domainStates$l, _state$domainStates$l2, _props$controlProps, _state$domainStates$l3, _props$webChatContain7;
66
66
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
67
  const addConversationalSurveyTagsCallback = action => {
68
68
  var _inMemoryState$appSta;
@@ -121,7 +121,7 @@ export const initWebChatComposer = (props, state, dispatch, facadeChatSDK, endCh
121
121
  };
122
122
  webChatStore = createStore({},
123
123
  //initial state
124
- preProcessingMiddleware, attachmentProcessingMiddleware, createAttachmentUploadValidatorMiddleware((_state$domainStates$l = state.domainStates.liveChatConfig) === null || _state$domainStates$l === void 0 ? void 0 : _state$domainStates$l.allowedFileExtensions, (_state$domainStates$l2 = state.domainStates.liveChatConfig) === null || _state$domainStates$l2 === void 0 ? void 0 : _state$domainStates$l2.maxUploadFileSize, localizedTexts), attachmentSentAnnouncementMiddleware, createCustomEventMiddleware(BroadcastService), createQueueOverflowMiddleware(state, dispatch), channelDataMiddleware(addConversationalSurveyTagsCallback), createConversationEndMiddleware(conversationEndCallback, startConversationalSurveyCallback, endConversationalSurveyCallback), createDataMaskingMiddleware((_state$domainStates$l3 = state.domainStates.liveChatConfig) === null || _state$domainStates$l3 === void 0 ? void 0 : _state$domainStates$l3.DataMaskingInfo), createMessageTimeStampMiddleware, createMessageSequenceIdOverrideMiddleware, createCitationsMiddleware(state, dispatch), gifUploadMiddleware, htmlPlayerMiddleware, htmlTextMiddleware(honorsTargetInHTMLLinks), createMaxMessageSizeValidator(localizedTexts), sanitizationMiddleware, createCallActionMiddleware(),
124
+ preProcessingMiddleware, attachmentProcessingMiddleware, createAttachmentUploadValidatorMiddleware((_state$domainStates$l = state.domainStates.liveChatConfig) === null || _state$domainStates$l === void 0 ? void 0 : _state$domainStates$l.allowedFileExtensions, (_state$domainStates$l2 = state.domainStates.liveChatConfig) === null || _state$domainStates$l2 === void 0 ? void 0 : _state$domainStates$l2.maxUploadFileSize, localizedTexts), createAttachmentSentAnnouncementMiddleware(localizedTexts, ((_props$controlProps = props.controlProps) === null || _props$controlProps === void 0 ? void 0 : _props$controlProps.id) || "oc-lcw"), createCustomEventMiddleware(BroadcastService), createQueueOverflowMiddleware(state, dispatch), channelDataMiddleware(addConversationalSurveyTagsCallback), createConversationEndMiddleware(conversationEndCallback, startConversationalSurveyCallback, endConversationalSurveyCallback), createDataMaskingMiddleware((_state$domainStates$l3 = state.domainStates.liveChatConfig) === null || _state$domainStates$l3 === void 0 ? void 0 : _state$domainStates$l3.DataMaskingInfo), createMessageTimeStampMiddleware, createMessageSequenceIdOverrideMiddleware, createCitationsMiddleware(state, dispatch), gifUploadMiddleware, htmlPlayerMiddleware, htmlTextMiddleware(honorsTargetInHTMLLinks), createMaxMessageSizeValidator(localizedTexts), sanitizationMiddleware, createCallActionMiddleware(),
125
125
  // Pass a callback so middleware can push initials into React context for reactivity
126
126
  localizedStringsBotInitialsMiddleware(initials => {
127
127
  dispatch({
@@ -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);
@@ -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;