Repository: Azure/communication-ui-library
Pull request #5764: Fix listeners for chat events of AzureCommunicationCallWithChatAdapter when the threadId changes
Author: login: mgamis-msft
Created: 2025-04-07T16:40:45Z | Merged: 2025-04-10T17:29:24Z

# What
Add map of listeners for chat events so that old chat adapter listeners can be propagated to newly created chat adapters. This will fix listeners for chat events when:
- the thread Id is resolved late in callInfo when joining a Teams meeting link 
- the thread Id is changed when moving to and from breakout rooms

# Why
Resolves one of the issue from #5755 regarding not being able chat event listeners not working when joining a breakout room

# How Tested
Tested with code from reverted [commit](https://github.com/Azure/communication-ui-library/commit/5e1c04305ce3314027d1636afd6ca17d370f3832) on this branch

# Process & policy checklist
<!--- Review the list and check the boxes that apply. -->

- [ ] I have updated the project documentation to reflect my changes if necessary.
- [ ] I have read the [CONTRIBUTING](https://github.com/Azure/communication-ui-library/blob/main/CONTRIBUTING.md) documentation.

**Is this a breaking change?**

- [ ] This change causes current functionality to break.
<!--- If yes, describe the impact. -->


Diff:
diff --git a/change-beta/@azure-communication-react-8050440c-53e4-4704-8ede-16f4a0a6c0f8.json b/change-beta/@azure-communication-react-8050440c-53e4-4704-8ede-16f4a0a6c0f8.json
new file mode 100644
index 00000000000..bcefd6bc908
--- /dev/null
+++ b/change-beta/@azure-communication-react-8050440c-53e4-4704-8ede-16f4a0a6c0f8.json
@@ -0,0 +1,9 @@
+{
+  "type": "patch",
+  "area": "feature",
+  "workstream": "Breakout rooms",
+  "comment": "Fix listeners subscribed to chat events of AzureCallWithChatAdapter when the thread id is resolved late or is changed when moving to and from breakout rooms",
+  "packageName": "@azure/communication-react",
+  "email": "79475487+mgamis-msft@users.noreply.github.com",
+  "dependentChangeType": "patch"
+}
diff --git a/change/@azure-communication-react-8050440c-53e4-4704-8ede-16f4a0a6c0f8.json b/change/@azure-communication-react-8050440c-53e4-4704-8ede-16f4a0a6c0f8.json
new file mode 100644
index 00000000000..bcefd6bc908
--- /dev/null
+++ b/change/@azure-communication-react-8050440c-53e4-4704-8ede-16f4a0a6c0f8.json
@@ -0,0 +1,9 @@
+{
+  "type": "patch",
+  "area": "feature",
+  "workstream": "Breakout rooms",
+  "comment": "Fix listeners subscribed to chat events of AzureCallWithChatAdapter when the thread id is resolved late or is changed when moving to and from breakout rooms",
+  "packageName": "@azure/communication-react",
+  "email": "79475487+mgamis-msft@users.noreply.github.com",
+  "dependentChangeType": "patch"
+}
diff --git a/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts b/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
index 1d39d813516..1b65b0aa4d9 100644
--- a/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
+++ b/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
@@ -112,6 +112,8 @@ import { busyWait } from '../../common/utils';
 
 type CallWithChatAdapterStateChangedHandler = (newState: CallWithChatAdapterState) => void;
 
+type Listener = (...args: unknown[]) => void;
+
 /**
  * For each time that we use the hook {@link useSelector} in the {@link CallWithChatComposite} we add another listener
  * to the `stateChanged` event on the this adapter. This number is set in relation to the number of
@@ -181,6 +183,27 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
   private originCallChatAdapter: ChatAdapter | undefined;
   private breakoutRoomChatAdapter: ChatAdapter | undefined;
 
+  // This map is used to store the listeners subscribed to chat events so that we can re-subscribe them
+  // to the new chat adapter when the thread id changes
+  private chatEventListeners: Map<string, Set<Listener>> = new Map();
+
+  // This function is used to store a listener to the internal map of chat event listeners and should be
+  // used when a listener subscribes to a chat event
+  private storeChatEventListener(event: string, listener: Listener): void {
+    if (!this.chatEventListeners.has(event)) {
+      this.chatEventListeners.set(event, new Set());
+    }
+    this.chatEventListeners.get(event)?.add(listener);
+  }
+
+  // This function is used to remove a listener from the internal map of chat event listeners and should
+  // be used when a listener unsubscribes from a chat event
+  private removeChatEventListener(event: string, listener: Listener): void {
+    if (this.chatEventListeners.has(event)) {
+      this.chatEventListeners.get(event)?.delete(listener);
+    }
+  }
+
   constructor(callAdapter: CallAdapter, chatAdapter?: ChatAdapter) {
     this.bindPublicMethods();
     this.callAdapter = callAdapter;
@@ -315,12 +338,24 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
 
   private updateChatAdapter(chatAdapter: ChatAdapter): void {
     this.chatAdapter?.offStateChange(this.onChatStateChange);
+    this.updateChatEventListeners(chatAdapter);
     this.chatAdapter = chatAdapter;
     this.chatAdapter.onStateChange(this.onChatStateChange);
     this.context.updateClientStateWithChatState(chatAdapter.getState());
     this.emitter.emit('chatInitialized', this.chatAdapter);
   }
 
+  private updateChatEventListeners(chatAdapter: ChatAdapter): void {
+    for (const [eventName, listeners] of this.chatEventListeners) {
+      for (const listener of listeners) {
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        this.chatAdapter?.off(eventName as any, listener);
+        // eslint-disable-next-line @typescript-eslint/no-explicit-any
+        chatAdapter.on(eventName as any, listener);
+      }
+    }
+  }
+
   private bindPublicMethods(): void {
     this.joinCall.bind(this);
     this.leaveCall.bind(this);
@@ -439,6 +474,8 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
     }
     this.callAdapter.offStateChange(this.onCallStateChange);
     this.callAdapter.dispose();
+    // Clear chat event listeners
+    this.chatEventListeners.clear();
   }
   /** Remove a participant from the Call only. */
   public async removeParticipant(userId: string | CommunicationIdentifier): Promise<void> {
@@ -848,36 +885,43 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
         this.callAdapter.on('isSpokenLanguageChanged', listener);
         break;
       case 'messageReceived':
+        this.storeChatEventListener('messageReceived', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('messageReceived', listener);
         });
         break;
       case 'messageEdited':
+        this.storeChatEventListener('messageEdited', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('messageEdited', listener);
         });
         break;
       case 'messageDeleted':
+        this.storeChatEventListener('messageDeleted', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('messageDeleted', listener);
         });
         break;
       case 'messageSent':
+        this.storeChatEventListener('messageSent', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('messageSent', listener);
         });
         break;
       case 'messageRead':
+        this.storeChatEventListener('messageRead', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('messageRead', listener);
         });
         break;
       case 'chatParticipantsAdded':
+        this.storeChatEventListener('chatParticipantsAdded', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('participantsAdded', listener);
         });
         break;
       case 'chatParticipantsRemoved':
+        this.storeChatEventListener('chatParticipantsRemoved', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('participantsRemoved', listener);
         });
@@ -886,6 +930,7 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
         this.callAdapter.on('error', listener);
         break;
       case 'chatError':
+        this.storeChatEventListener('chatError', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.on('error', listener);
         });
@@ -986,36 +1031,43 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
         this.callAdapter.off('isSpokenLanguageChanged', listener);
         break;
       case 'messageReceived':
+        this.removeChatEventListener('messageReceived', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('messageReceived', listener);
         });
         break;
       case 'messageEdited':
+        this.removeChatEventListener('messageEdited', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('messageEdited', listener);
         });
         break;
       case 'messageDeleted':
+        this.removeChatEventListener('messageDeleted', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('messageDeleted', listener);
         });
         break;
       case 'messageSent':
+        this.removeChatEventListener('messageSent', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('messageSent', listener);
         });
         break;
       case 'messageRead':
+        this.removeChatEventListener('messageRead', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('messageRead', listener);
         });
         break;
       case 'chatParticipantsAdded':
+        this.removeChatEventListener('chatParticipantsAdded', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('participantsAdded', listener);
         });
         break;
       case 'chatParticipantsRemoved':
+        this.removeChatEventListener('chatParticipantsRemoved', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('participantsRemoved', listener);
         });
@@ -1024,6 +1076,7 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
         this.callAdapter.off('error', listener);
         break;
       case 'chatError':
+        this.removeChatEventListener('chatError', listener);
         this.executeWithResolvedChatAdapter((adapter) => {
           adapter.off('error', listener);
         });


Reviews:
user: login: JamesBurnside
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:19:38Z
user: login: JamesBurnside
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:23:32Z
user: login: JamesBurnside
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:24:40Z
user: login: JamesBurnside
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:30:34Z
user: login: JamesBurnside
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T15:32:15Z
user: login: JamesBurnside
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-09T22:49:52Z
user: login: edwardlee-msft
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T16:06:27Z

Review comments:
diff_hunk: @@ -179,6 +181,24 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
   private originCallChatAdapter: ChatAdapter | undefined;
   private breakoutRoomChatAdapter: ChatAdapter | undefined;
 
+  private chatEventListeners: Map<string, Listener[]> = new Map();
path: packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
user: login: JamesBurnside
body: Add a detailed comment here why the need to track and store all chat event listeners manually
created_at: 2025-04-08T15:19:38Z
author_association: MEMBER
diff_hunk: @@ -179,6 +181,24 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
   private originCallChatAdapter: ChatAdapter | undefined;
   private breakoutRoomChatAdapter: ChatAdapter | undefined;
 
+  private chatEventListeners: Map<string, Listener[]> = new Map();
path: packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
user: login: JamesBurnside
body: You can use a `Set` instead of array to ensure no duplicates and syntactically inform the reader that it is designed for uniqueness
created_at: 2025-04-08T15:23:32Z
author_association: MEMBER
diff_hunk: @@ -179,6 +181,24 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
   private originCallChatAdapter: ChatAdapter | undefined;
   private breakoutRoomChatAdapter: ChatAdapter | undefined;
 
+  private chatEventListeners: Map<string, Listener[]> = new Map();
path: packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
user: login: JamesBurnside
body: Your add/remove functions won't then need to recreate the array, but can just use `set.add` and `set.remove`
created_at: 2025-04-08T15:24:39Z
author_association: MEMBER
diff_hunk: @@ -313,12 +333,63 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
 
   private updateChatAdapter(chatAdapter: ChatAdapter): void {
     this.chatAdapter?.offStateChange(this.onChatStateChange);
+    this.updateChatEventListeners(chatAdapter);
     this.chatAdapter = chatAdapter;
     this.chatAdapter.onStateChange(this.onChatStateChange);
     this.context.updateClientStateWithChatState(chatAdapter.getState());
     this.emitter.emit('chatInitialized', this.chatAdapter);
   }
 
+  private updateChatEventListeners(chatAdapter: ChatAdapter): void {
+    const messageReceivedListeners = this.getListeners('messageReceived');
path: packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
user: login: JamesBurnside
body: if someone adds a new event it will be easy to miss adding it here, can you instead more blindly iterate over the listeners?
e.g. (untested)
```
for (const [eventName, listeners] of this.chatEventListeners) {
  for (const listener of listeners) {
    this.chatAdapter?.off('eventName', listener);
    chatAdapter.on('eventName', listener);
  }
}
```
(might have to use `as any` to satisfy typescript
created_at: 2025-04-08T15:30:33Z
author_association: MEMBER
diff_hunk: @@ -179,6 +181,24 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte
   private originCallChatAdapter: ChatAdapter | undefined;
   private breakoutRoomChatAdapter: ChatAdapter | undefined;
 
+  private chatEventListeners: Map<string, Listener[]> = new Map();
path: packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts
user: login: JamesBurnside
body: Cleanup (empty) this variable in the dispose function
created_at: 2025-04-08T15:32:14Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-07T16:49:04Z
body: Storybook 8 URL https://60c7ae6891f0e90039d7cd54-bpklywqfle.chromatic.com/
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-07T16:52:07Z
body: ## @azure/communication-react jest test coverage for ***stable***.

|   | Lines | Statements | Functions | Branches |
| --- | :-------: | :-------: | :-------: | :-------: |
| Base |28162 / 44748 <br /> 62.93%|28162 / 44748 <br /> 62.93%|788 / 1439 <br /> 54.76%|2347 / 3725 <br /> 63%|
| Current |28102 / 44748 <br /> 62.8%|28102 / 44748 <br /> 62.8%|788 / 1439 <br /> 54.76%|2344 / 3720 <br /> 63.01%|
| Diff |-60 / 0 <br /> -0.13%|-60 / 0 <br /> -0.13%|0 / 0 <br /> 0%|-3 / -5 <br /> 0.01%|
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-07T16:52:28Z
body: ## @azure/communication-react jest test coverage for ***beta***.

|   | Lines | Statements | Functions | Branches |
| --- | :-------: | :-------: | :-------: | :-------: |
| Base |58502 / 94727 <br /> 61.75%|58502 / 94727 <br /> 61.75%|1181 / 2703 <br /> 43.69%|3542 / 5848 <br /> 60.56%|
| Current |58553 / 94780 <br /> 61.77%|58553 / 94780 <br /> 61.77%|1182 / 2706 <br /> 43.68%|3530 / 5848 <br /> 60.36%|
| Diff |51 / 53 <br /> 0.02%|51 / 53 <br /> 0.02%|1 / 3 <br /> -0.01%|-12 / 0 <br /> -0.2%|
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-07T21:03:18Z
body: Storybook 8 URL https://60c7ae6891f0e90039d7cd54-mguzcdmxpf.chromatic.com/
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-09T06:58:45Z
body: Storybook 8 URL https://60c7ae6891f0e90039d7cd54-lfekxquate.chromatic.com/
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-09T07:48:15Z
body: Storybook 8 URL https://60c7ae6891f0e90039d7cd54-jiksvluaqw.chromatic.com/
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-10T17:19:48Z
body: ## CallWithChat bundle size is ***not changed***.
  - Current size: 12413510
  - Base size: 12413510
  - Diff size: 0
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-10T17:19:52Z
body: ## Calling bundle size is ***not changed***.
  - Current size: 12413497
  - Base size: 12413497
  - Diff size: 0
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-10T17:19:54Z
body: ## Chat bundle size is ***not changed***.
  - Current size: 1780243
  - Base size: 1780243
  - Diff size: 0
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-10T17:22:55Z
body: Storybook 8 URL https://60c7ae6891f0e90039d7cd54-xwdzuahnct.chromatic.com/
author_association: CONTRIBUTOR