diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f86729d..f5f9c5d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -132,6 +132,15 @@ export class Rasa extends EventEmitter { public reconnection(value: boolean): void { this.connection.reconnection(value); } + + public overrideChatHistory = (chatHistoryString: string): void => { + this.storageService.overrideChatHistory(chatHistoryString); + this.loadChatHistory(); + } + + public getChatHistory(): string { + return JSON.stringify(this.storageService.getChatHistory()); + } //#endregion } diff --git a/packages/sdk/src/services/storage.service.ts b/packages/sdk/src/services/storage.service.ts index e57e897..d45b00f 100644 --- a/packages/sdk/src/services/storage.service.ts +++ b/packages/sdk/src/services/storage.service.ts @@ -2,6 +2,18 @@ import { SESSION_STORAGE_KEYS } from '../constants'; import { CustomErrorClass, ErrorSeverity } from '../errors'; export class StorageService { + //#region Private Methods + private parseSessionStorageValue(value: string | null) { + if (!value) return null; + try { + return JSON.parse(value); + } catch (e) { + return null; + } + } + //#endregion + + //#region Public Methods public setSession(sessionId: string, sessionStart: Date): boolean { const preservedHistory = this.getChatHistory() || {}; if (!preservedHistory[sessionId]) { @@ -57,12 +69,8 @@ export class StorageService { } } - private parseSessionStorageValue(value: string | null) { - if (!value) return null; - try { - return JSON.parse(value); - } catch (e) { - return null; - } + public overrideChatHistory(chatHistory: string) { + sessionStorage.setItem(SESSION_STORAGE_KEYS.CHAT_HISTORY, chatHistory); } + //#endregion } diff --git a/packages/ui/src/rasa-chatbot-widget/constants.ts b/packages/ui/src/rasa-chatbot-widget/constants.ts index b1c963f..c750f78 100644 --- a/packages/ui/src/rasa-chatbot-widget/constants.ts +++ b/packages/ui/src/rasa-chatbot-widget/constants.ts @@ -1,4 +1,5 @@ -export const DISCONNECT_TIMEOUT = 5000; +export const DISCONNECT_TIMEOUT = 5_000; +export const DEBOUNCE_THRESHOLD = 1_000; export const WIDGET_DEFAULT_CONFIGURATION = { AUTHENTICATION_TOKEN: '', diff --git a/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx b/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx index a9966fb..5964cc7 100644 --- a/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx +++ b/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx @@ -1,14 +1,17 @@ import { Component, Element, Event, EventEmitter, Listen, Prop, State, Watch, h } from '@stencil/core/internal'; +import { v4 as uuidv4 } from 'uuid'; + import { MESSAGE_TYPES, Message, QuickReply, QuickReplyMessage, Rasa, SENDER } from '@vortexwest/chat-widget-sdk'; -import { configStore, setConfigStore } from '../store/config-store'; -import { DISCONNECT_TIMEOUT } from './constants'; import { Messenger } from '../components/messenger'; -import { isMobile } from '../utils/isMobile'; -import { isValidURL } from '../utils/validate-url'; +import { configStore, setConfigStore } from '../store/config-store'; import { messageQueueService } from '../store/message-queue'; -import { v4 as uuidv4 } from 'uuid'; import { widgetState } from '../store/widget-state-store'; +import { isValidURL } from '../utils/validate-url'; +import { broadcastChatHistoryEvent, receiveChatHistoryEvent } from '../utils/eventChannel'; +import { isMobile } from '../utils/isMobile'; +import { debounce } from '../utils/debounce'; +import { DEBOUNCE_THRESHOLD, DISCONNECT_TIMEOUT } from './constants'; @Component({ tag: 'rasa-chatbot-widget', @@ -19,6 +22,7 @@ export class RasaChatbotWidget { private client: Rasa; private messageDelayQueue: Promise = Promise.resolve(); private disconnectTimeout: NodeJS.Timeout | null = null; + private sentMessage = false; @Element() el: HTMLRasaChatbotWidgetElement; @State() isOpen: boolean = false; @@ -202,6 +206,14 @@ export class RasaChatbotWidget { if (this.autoOpen) { this.toggleOpenState(); } + + // If senderID is configured watch for storage change event (localStorage) and override chat history (sessionStorage) + // This happens on tabs that are not in focus nor message was sent from that tab + if (this.senderId) { + window.onstorage = ev => { + receiveChatHistoryEvent(ev, this.client.overrideChatHistory, this.senderId); + }; + } } private scrollToBottom(): void { @@ -217,6 +229,9 @@ export class RasaChatbotWidget { }; private onNewMessage = (data: Message) => { + // If senderID is configured (continuous session), tab is not in focus and user message was not sent from this tab do not render new server message + if (this.senderId && !document.hasFocus() && !this.sentMessage) return; + this.chatWidgetReceivedMessage.emit(data); const delay = data.type === MESSAGE_TYPES.SESSION_DIVIDER || data.sender === SENDER.USER ? 0 : configStore().messageDelay; @@ -227,6 +242,13 @@ export class RasaChatbotWidget { setTimeout(() => { messageQueueService.enqueueMessage(data); this.typingIndicator = false; + // If senderID is configured and message was sent from this tab, broadcast event to share chat history with other tabs with same senderID + if (this.senderId && this.sentMessage) { + debounce(() => { + broadcastChatHistoryEvent(this.client.getChatHistory(), this.senderId); + this.sentMessage = false; + }, DEBOUNCE_THRESHOLD)(); + } resolve(); }, delay); }); @@ -234,7 +256,7 @@ export class RasaChatbotWidget { }; private loadHistory = (data: Message[]): void => { - this.messageHistory = data; + this.messages = data; }; private connect(): void { @@ -281,6 +303,7 @@ export class RasaChatbotWidget { this.chatWidgetSentMessage.emit(event.detail); this.messages = [...this.messages, { type: 'text', text: event.detail, sender: 'user', timestamp }]; this.scrollToBottom(); + this.sentMessage = true; } @Listen('quickReplySelected') @@ -293,6 +316,7 @@ export class RasaChatbotWidget { this.messages[key] = updatedMessage; this.client.sendMessage({ text: quickReply.text, reply: quickReply.reply, timestamp }, true, key - 1); this.chatWidgetQuickReply.emit(quickReply.reply); + this.sentMessage = true; } @Listen('linkClicked') diff --git a/packages/ui/src/utils/debounce.test.ts b/packages/ui/src/utils/debounce.test.ts new file mode 100644 index 0000000..9bfd8aa --- /dev/null +++ b/packages/ui/src/utils/debounce.test.ts @@ -0,0 +1,54 @@ +import { debounce } from './debounce'; + +describe('debounce', () => { + + beforeAll(() => { + jest.useFakeTimers(); + }); + + it('calls the function only once after the specified time', () => { + const fn = jest.fn(); + const debouncedFn = debounce(fn, 500); + + debouncedFn(); + debouncedFn(); + debouncedFn(); + jest.runAllTimers(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('calls the function with the correct arguments', () => { + const fn = jest.fn(); + const debouncedFn = debounce(fn, 500); + + debouncedFn('test', 123); + jest.runAllTimers(); + + expect(fn).toHaveBeenCalledWith('test', 123); + }); + + it('uses the correct "this" context', () => { + const fn = jest.fn(); + const context = { value: 'context' }; + const debouncedFn = debounce(fn, 500); + + debouncedFn.call(context); + jest.runAllTimers(); + + expect(fn.mock.instances[0]).toBe(context); + }); + + it('uses the default delay if none is provided', () => { + const fn = jest.fn(); + const debouncedFn = debounce(fn); + + debouncedFn(); + + jest.advanceTimersByTime(299); + expect(fn).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/utils/debounce.ts b/packages/ui/src/utils/debounce.ts new file mode 100644 index 0000000..3b8c6f3 --- /dev/null +++ b/packages/ui/src/utils/debounce.ts @@ -0,0 +1,7 @@ +export const debounce = (fn: Function, ms = 300) => { + let timeoutId: ReturnType; + return function (this: any, ...args: any[]) { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn.apply(this, args), ms); + }; +}; diff --git a/packages/ui/src/utils/eventChannel.test.ts b/packages/ui/src/utils/eventChannel.test.ts new file mode 100644 index 0000000..9a1784a --- /dev/null +++ b/packages/ui/src/utils/eventChannel.test.ts @@ -0,0 +1,59 @@ +import { broadcastChatHistoryEvent, receiveChatHistoryEvent } from './eventChannel'; + +describe('broadcastChatHistoryEvent', () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + it('should not set or remove from localStorage if senderID is not set', () => { + const setItemSpy = jest.spyOn(localStorage, 'setItem'); + const removeItemSpy = jest.spyOn(localStorage, 'removeItem'); + + broadcastChatHistoryEvent('some chat history', ''); + + expect(setItemSpy).not.toHaveBeenCalled(); + expect(removeItemSpy).not.toHaveBeenCalled(); + }); + + it('should set and then remove the chat history in localStorage for the given senderID', () => { + const setItemSpy = jest.spyOn(localStorage, 'setItem'); + const removeItemSpy = jest.spyOn(localStorage, 'removeItem'); + + const senderID = '123'; + broadcastChatHistoryEvent('some chat history', senderID); + + expect(setItemSpy).toHaveBeenCalledWith(`rasaChatHistory-${senderID}`, 'some chat history'); + expect(removeItemSpy).toHaveBeenCalledWith(`rasaChatHistory-${senderID}`); + }); +}); + +describe('receiveChatHistoryEvent', () => { + it('should not call callback if the event key does not match the senderID', () => { + const callback = jest.fn(); + const ev = { key: '321', newValue: 'new chat history' }; + + receiveChatHistoryEvent(ev, callback, '123'); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should not call callback if newValue is falsy', () => { + const callback = jest.fn(); + const ev = { key: 'rasaChatHistory-123', newValue: null }; + + receiveChatHistoryEvent(ev, callback, '123'); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should call callback with new chat history if the event matches the senderID', () => { + const callback = jest.fn(); + const newChatHistory = 'new chat history'; + const ev = { key: 'rasaChatHistory-123', newValue: newChatHistory }; + + receiveChatHistoryEvent(ev, callback, '123'); + + expect(callback).toHaveBeenCalledWith(newChatHistory); + }); +}); diff --git a/packages/ui/src/utils/eventChannel.ts b/packages/ui/src/utils/eventChannel.ts new file mode 100644 index 0000000..620a491 --- /dev/null +++ b/packages/ui/src/utils/eventChannel.ts @@ -0,0 +1,12 @@ +export const broadcastChatHistoryEvent = (chatHistory: string, senderID) => { + if (!senderID) return; + localStorage.setItem(`rasaChatHistory-${senderID}`, chatHistory); + localStorage.removeItem(`rasaChatHistory-${senderID}`); +}; + +export const receiveChatHistoryEvent = (ev, callback, senderID) => { + const newChatHistory = ev.newValue; + + if (ev.key != `rasaChatHistory-${senderID}` || !newChatHistory) return; + callback(newChatHistory); +};