Skip to content

Commit

Permalink
Share messages between tabs (#75)
Browse files Browse the repository at this point in the history
* Share messages between tabs

* on tab focus fix

* Unit tests
  • Loading branch information
petar-basic authored Aug 21, 2024
1 parent 88cef78 commit b586891
Show file tree
Hide file tree
Showing 8 changed files with 188 additions and 14 deletions.
9 changes: 9 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
22 changes: 15 additions & 7 deletions packages/sdk/src/services/storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion packages/ui/src/rasa-chatbot-widget/constants.ts
Original file line number Diff line number Diff line change
@@ -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: '',
Expand Down
36 changes: 30 additions & 6 deletions packages/ui/src/rasa-chatbot-widget/rasa-chatbot-widget.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -19,6 +22,7 @@ export class RasaChatbotWidget {
private client: Rasa;
private messageDelayQueue: Promise<void> = Promise.resolve();
private disconnectTimeout: NodeJS.Timeout | null = null;
private sentMessage = false;

@Element() el: HTMLRasaChatbotWidgetElement;
@State() isOpen: boolean = false;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;

Expand All @@ -227,14 +242,21 @@ 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);
});
});
};

private loadHistory = (data: Message[]): void => {
this.messageHistory = data;
this.messages = data;
};

private connect(): void {
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/utils/debounce.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions packages/ui/src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const debounce = (fn: Function, ms = 300) => {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: any[]) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), ms);
};
};
59 changes: 59 additions & 0 deletions packages/ui/src/utils/eventChannel.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions packages/ui/src/utils/eventChannel.ts
Original file line number Diff line number Diff line change
@@ -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);
};

0 comments on commit b586891

Please sign in to comment.