From d7b77b1fe5213a78105dbc04c79f0e6b02099724 Mon Sep 17 00:00:00 2001 From: petar-basic Date: Fri, 16 Aug 2024 11:39:16 +0200 Subject: [PATCH 1/3] Share messages between tabs --- packages/sdk/src/index.ts | 9 ++++++++ packages/sdk/src/services/storage.service.ts | 22 +++++++++++++------ packages/ui/src/index.html | 2 +- .../rasa-chatbot-widget.tsx | 19 +++++++++++++++- packages/ui/src/utils/debounce.ts | 7 ++++++ packages/ui/src/utils/eventChannel.ts | 13 +++++++++++ 6 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/utils/debounce.ts create mode 100644 packages/ui/src/utils/eventChannel.ts 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/index.html b/packages/ui/src/index.html index a84c85f..8011609 100644 --- a/packages/ui/src/index.html +++ b/packages/ui/src/index.html @@ -11,6 +11,6 @@ - + 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..d176661 100644 --- a/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx +++ b/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx @@ -9,6 +9,8 @@ import { isValidURL } from '../utils/validate-url'; import { messageQueueService } from '../store/message-queue'; import { v4 as uuidv4 } from 'uuid'; import { widgetState } from '../store/widget-state-store'; +import { broadcastChatHistoryEvent, receiveChatHistoryEvent } from '../utils/eventChannel'; +import { debounce } from '../utils/debounce'; @Component({ tag: 'rasa-chatbot-widget', @@ -19,6 +21,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 +205,12 @@ export class RasaChatbotWidget { if (this.autoOpen) { this.toggleOpenState(); } + + if (this.senderId) { + window.onstorage = ev => { + receiveChatHistoryEvent(ev, this.client.overrideChatHistory, this.senderId); + }; + } } private scrollToBottom(): void { @@ -227,6 +236,12 @@ export class RasaChatbotWidget { setTimeout(() => { messageQueueService.enqueueMessage(data); this.typingIndicator = false; + if (this.senderId && this.sentMessage) { + debounce(() => { + broadcastChatHistoryEvent(this.client.getChatHistory(), this.senderId); + this.sentMessage = false; + }, 1000)(); + } resolve(); }, delay); }); @@ -234,7 +249,7 @@ export class RasaChatbotWidget { }; private loadHistory = (data: Message[]): void => { - this.messageHistory = data; + this.messages = data; }; private connect(): void { @@ -281,6 +296,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 +309,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.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.ts b/packages/ui/src/utils/eventChannel.ts new file mode 100644 index 0000000..ace13f9 --- /dev/null +++ b/packages/ui/src/utils/eventChannel.ts @@ -0,0 +1,13 @@ +export const broadcastChatHistoryEvent = (chatHistory: string, senderID) => { + if (!senderID) return; + localStorage.setItem(`rasaChatHistory-${senderID}`, chatHistory); + localStorage.removeItem(`rasaChatHistory-${senderID}`); +}; + +export const receiveChatHistoryEvent = (ev, callback, senderID) => { + if (ev.key != `rasaChatHistory-${senderID}`) return; + var message = ev.newValue; + console.log(ev.newValue); + if (!message) return; + callback(ev.newValue); +}; From 1b2c0004ba130ac40a95b14507c02040cde72fce Mon Sep 17 00:00:00 2001 From: petar-basic Date: Fri, 16 Aug 2024 13:38:31 +0200 Subject: [PATCH 2/3] on tab focus fix --- packages/ui/src/rasa-chatbot-widget/constants.ts | 3 ++- .../rasa-chatbot-widget/rasa-chatbot-widget.tsx | 16 ++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) 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 d176661..1f5265d 100644 --- a/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx +++ b/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx @@ -1,16 +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', @@ -226,6 +227,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; @@ -240,7 +244,7 @@ export class RasaChatbotWidget { debounce(() => { broadcastChatHistoryEvent(this.client.getChatHistory(), this.senderId); this.sentMessage = false; - }, 1000)(); + }, DEBOUNCE_THRESHOLD)(); } resolve(); }, delay); From feb825293672921598ffed902d45693edc3a1019 Mon Sep 17 00:00:00 2001 From: petar-basic Date: Fri, 16 Aug 2024 15:57:10 +0200 Subject: [PATCH 3/3] Unit tests --- packages/ui/src/index.html | 2 +- .../rasa-chatbot-widget.tsx | 3 + packages/ui/src/utils/debounce.test.ts | 54 +++++++++++++++++ packages/ui/src/utils/eventChannel.test.ts | 59 +++++++++++++++++++ packages/ui/src/utils/eventChannel.ts | 9 ++- 5 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/utils/debounce.test.ts create mode 100644 packages/ui/src/utils/eventChannel.test.ts diff --git a/packages/ui/src/index.html b/packages/ui/src/index.html index 8011609..a84c85f 100644 --- a/packages/ui/src/index.html +++ b/packages/ui/src/index.html @@ -11,6 +11,6 @@ - + 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 1f5265d..5964cc7 100644 --- a/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx +++ b/packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx @@ -207,6 +207,8 @@ export class RasaChatbotWidget { 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); @@ -240,6 +242,7 @@ 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); 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/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 index ace13f9..620a491 100644 --- a/packages/ui/src/utils/eventChannel.ts +++ b/packages/ui/src/utils/eventChannel.ts @@ -5,9 +5,8 @@ export const broadcastChatHistoryEvent = (chatHistory: string, senderID) => { }; export const receiveChatHistoryEvent = (ev, callback, senderID) => { - if (ev.key != `rasaChatHistory-${senderID}`) return; - var message = ev.newValue; - console.log(ev.newValue); - if (!message) return; - callback(ev.newValue); + const newChatHistory = ev.newValue; + + if (ev.key != `rasaChatHistory-${senderID}` || !newChatHistory) return; + callback(newChatHistory); };