Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.0.0]
### Changed
- Enhanced End Chat flow to support disconnect flow experiences
- Modified `disconnectParticipant()` to defer connection termination until server sends `chatEnded` event
- Moved cleanup operations from `disconnectParticipant()` to `_handleIncomingMessage()` for server-driven termination
- Added callback pattern in `_forwardChatEvent()` to ensure proper event ordering during chat termination
- Enables post-disconnect messaging (surveys, confirmations) while ensuring proper cleanup

## [3.1.5]
### Changed
- Remove verbose websocket incoming message logging from LpcConnectionHelper
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "amazon-connect-chatjs",
"version": "3.1.5",
"version": "4.0.0",
"main": "dist/amazon-connect-chat.js",
"types": "dist/index.d.ts",
"engines": {
Expand Down
10 changes: 5 additions & 5 deletions src/core/chatController.js
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ class ChatController {
this._forwardChatEvent(CHAT_EVENTS.CHAT_ENDED, {
data: null,
chatDetails: this.getChatDetails()
}, () => {
this._participantDisconnected = true;
this.cleanUpOnParticipantDisconnect();
});
this.breakConnection();
}
Expand All @@ -365,8 +368,8 @@ class ChatController {
}
}

_forwardChatEvent(eventName, eventData) {
this.pubsub.triggerAsync(eventName, eventData);
_forwardChatEvent(eventName, eventData, callback) {
this.pubsub.triggerAsync(eventName, eventData, callback);
}

_onConnectSuccess(response, connectionDetailsProvider) {
Expand Down Expand Up @@ -442,9 +445,6 @@ class ChatController {
.then(response => {
this._sendInternalLogToServer(this.logger.info("Disconnect participant successfully"));

this._participantDisconnected = true;
this.cleanUpOnParticipantDisconnect();
this.breakConnection();
csmService.addLatencyMetricWithStartTime(ACPS_METHODS.DISCONNECT_PARTICIPANT, startTime, CSM_CATEGORY.API);
csmService.addCountAndErrorMetric(ACPS_METHODS.DISCONNECT_PARTICIPANT, CSM_CATEGORY.API, false);
response = {...(response || {})};
Expand Down
182 changes: 121 additions & 61 deletions src/core/chatController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ACPS_METHODS,
FEATURES,
CREATE_PARTICIPANT_CONACK_FAILURE,
DUMMY_ENDED_EVENT
} from "../constants";
import Utils from "../utils";
import { ChatController } from "./chatController";
Expand Down Expand Up @@ -653,38 +654,32 @@ describe("ChatController", () => {
});
});

test("should not sendEvent for MessageReceipts if chat has ended", done => {
jest.useRealTimers();
test("should not sendEvent for MessageReceipts if chat has ended", async () => {
const args = {
metadata: "metadata",
contentType: CONTENT_TYPE.readReceipt,
content: JSON.stringify({
content: JSON.stringify({
messageId: "messageId"
})
};

const chatController = getChatController();
chatController.hasChatEnded = false;
chatController.connect().then(() => {
chatClient.sendEvent.mockClear();

Promise.all([chatController.sendEvent(args),
chatController.sendEvent(args),
chatController.sendEvent(args),
chatController.sendEvent(args),
chatController.sendEvent(args)]).then(async () => {
expect(chatClient.sendEvent).toHaveBeenCalledTimes(1);
expect(chatClient.sendEvent).toHaveBeenCalledWith("token", CONTENT_TYPE.readReceipt, "{\"messageId\":\"messageId\"}", "INCOMING_READ_RECEIPT", 1000);

chatController.connectionHelper.$simulateEnding();
chatClient.sendEvent.mockClear();
await Utils.delay(1);
await chatController.sendEvent(args);
await chatController.sendEvent(args);
expect(chatClient.sendEvent).toHaveBeenCalledTimes(0);

done();
});
});
await chatController.connect();
chatController.pubsub = {
...chatController.pubsub,
triggerAsync: jest.fn((eventName, data, callback) => {
if (callback) {
callback();
}
}),
subMap: chatController.pubsub.subMap
};
chatController.hasChatEnded = true;
chatController._participantDisconnected = true;
chatClient.sendEvent.mockClear();
await chatController.sendEvent(args).catch(() => {});
await chatController.sendEvent(args).catch(() => {});
expect(chatClient.sendEvent).toHaveBeenCalledTimes(0);
});

test("should throttle Read and Delivered events for MessageReceipts to only send Read Event", async () => {
Expand Down Expand Up @@ -842,11 +837,10 @@ describe("ChatController", () => {
await chatController.connect();
await Utils.delay(1);

try {
await chatController.disconnectParticipant();
} catch(err) {
console.log("err:disconnectParticipant", err);
}
// Simulate chat ended event to set _participantDisconnected = true
chatController.connectionHelper.$simulateEnding();
await Utils.delay(15); // Wait for the delay in _handleIncomingMessage

try {
await chatController.sendMessage(args);
} catch(err) {
Expand Down Expand Up @@ -883,11 +877,10 @@ describe("ChatController", () => {
const chatController = getChatController(false);
await chatController.connect();
await Utils.delay(1);
try {
await chatController.disconnectParticipant();
} catch(err) {
console.log("err:disconnectParticipant", err);
}

chatController.connectionHelper.$simulateEnding();
await Utils.delay(15);

try {
await chatController.sendAttachment(args);
} catch(err) {
Expand Down Expand Up @@ -920,11 +913,10 @@ describe("ChatController", () => {
const chatController = getChatController(false);
await chatController.connect();
await Utils.delay(1);
try {
await chatController.disconnectParticipant();
} catch(err) {
console.log("err:disconnectParticipant", err);
}

chatController.connectionHelper.$simulateEnding();
await Utils.delay(15);

try {
await chatController.downloadAttachment(args);
} catch(err) {
Expand Down Expand Up @@ -957,11 +949,10 @@ describe("ChatController", () => {
const chatController = getChatController(false);
await chatController.connect();
await Utils.delay(1);
try {
await chatController.disconnectParticipant();
} catch(err) {
console.log("err:disconnectParticipant", err);
}

chatController.connectionHelper.$simulateEnding();
await Utils.delay(15);

try {
await chatController.sendEvent(args);
} catch(err) {
Expand All @@ -986,11 +977,10 @@ describe("ChatController", () => {
const chatController = getChatController(false);
await chatController.connect();
await Utils.delay(1);
try {
await chatController.disconnectParticipant();
} catch(err) {
console.log("err:disconnectParticipant", err);
}

chatController.connectionHelper.$simulateEnding();
await Utils.delay(15);

try {
await chatController.getTranscript({});
} catch(err) {
Expand All @@ -1015,11 +1005,11 @@ describe("ChatController", () => {
const chatController = getChatController(false);
await chatController.connect();
await Utils.delay(1);
try {
await chatController.disconnectParticipant();
} catch(err) {
console.log("err:disconnectParticipant", err);
}

// Simulate chat ended event to set _participantDisconnected = true
chatController.connectionHelper.$simulateEnding();
await Utils.delay(15); // Wait for the delay in _handleIncomingMessage

try {
await chatController.disconnectParticipant();
} catch(err) {
Expand Down Expand Up @@ -1059,16 +1049,86 @@ describe("ChatController", () => {

test('_handleBackgroundChatEnded is triggered correctly', () => {
const chatController = getChatController();
chatController._forwardChatEvent = jest.fn(); // Mock _forwardChatEvent to spy on it
chatController._handleIncomingMessage = jest.fn(); // Mock _handleIncomingMessage to spy on it

// Directly invoke the method
chatController._handleBackgroundChatEnded();

// Check if _forwardChatEvent was called correctly

// Check if _handleIncomingMessage was called with DUMMY_ENDED_EVENT
expect(chatController._handleIncomingMessage).toHaveBeenCalledWith(DUMMY_ENDED_EVENT);
});

test('_handleIncomingMessage should handle chatEnded event correctly', async () => {
const chatController = getChatController();
const cleanUpSpy = jest.spyOn(chatController, 'cleanUpOnParticipantDisconnect');
const breakConnectionSpy = jest.spyOn(chatController, 'breakConnection');

chatController._forwardChatEvent = jest.fn();

cleanUpSpy.mockImplementation(() => {});

expect(chatController.hasChatEnded).toBe(false);
expect(chatController._participantDisconnected).toBe(false);

const chatEndedData = {
ContentType: CONTENT_TYPE.chatEnded,
Type: EVENT,
AbsoluteTime: '2023-01-01T00:00:00.000Z'
};

const originalForwardChatEvent = chatController._forwardChatEvent;
chatController._forwardChatEvent = jest.fn((eventName, data) => {
originalForwardChatEvent.call(chatController, eventName, data);
if (eventName === CHAT_EVENTS.CHAT_ENDED) {
setTimeout(() => {
chatController._participantDisconnected = true;
cleanUpSpy();
}, 5);
}
});

chatController._handleIncomingMessage(chatEndedData);

expect(chatController.hasChatEnded).toBe(true);
expect(chatController._forwardChatEvent).toHaveBeenCalledWith(
CHAT_EVENTS.CHAT_ENDED,
expect.anything()
CHAT_EVENTS.INCOMING_MESSAGE,
{
data: chatEndedData,
chatDetails: expect.anything()
}
);

expect(chatController._forwardChatEvent.mock.calls[1][0]).toBe(CHAT_EVENTS.CHAT_ENDED);
expect(chatController._forwardChatEvent.mock.calls[1][1]).toEqual({
data: null,
chatDetails: expect.anything()
});

expect(breakConnectionSpy).toHaveBeenCalledTimes(1);
await Utils.delay(10);

expect(chatController._participantDisconnected).toBe(true);
expect(cleanUpSpy).toHaveBeenCalledTimes(1);
});

test('_handleIncomingMessage should not set _participantDisconnected for non-chatEnded events', async () => {
const chatController = getChatController();
const cleanUpSpy = jest.spyOn(chatController, 'cleanUpOnParticipantDisconnect');

expect(chatController._participantDisconnected).toBe(false);

const messageData = {
ContentType: CONTENT_TYPE.textPlain,
Type: MESSAGE,
Message: 'Hello world'
};

chatController._handleIncomingMessage(messageData);

await Utils.delay(15);

expect(chatController._participantDisconnected).toBe(false);
expect(cleanUpSpy).not.toHaveBeenCalled();
});

describe('ChatController - getAttachmentURL', () => {
Expand Down
9 changes: 7 additions & 2 deletions src/core/eventbus.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,8 +152,13 @@ EventBus.prototype.trigger = function(eventName, data) {
* to this event will be called and are provided with the given arbitrary
* data object and the name of the event, in that order.
*/
EventBus.prototype.triggerAsync = function(eventName, data) {
setTimeout(() => this.trigger(eventName, data), 0);
EventBus.prototype.triggerAsync = function(eventName, data, callback) {
setTimeout(() => {
this.trigger(eventName, data);
if (callback) {
callback();
}
}, 0);
};

/**
Expand Down