@gameon/web
Version:
Chat clients for web
1,210 lines (1,044 loc) • 34.5 kB
text/typescript
import {
onChatCardContext,
OnChatCardContext,
} from '@gameon/on-ui-components/chat/card/context';
import {
onChatCarouselContext,
OnChatCarouselContext,
} from '@gameon/on-ui-components/chat/carousel/context';
import {
InputChangeEventDetails,
InputSubmitEventDetails,
} from '@gameon/on-ui-components/chat/input';
import {
onChatInputContext,
OnChatInputContext,
} from '@gameon/on-ui-components/chat/input/context';
import {
onChatMessageContext,
OnChatMessageContext,
} from '@gameon/on-ui-components/chat/message/context';
import { MessageQuickReplyClickEventDetails } from '@gameon/on-ui-components/chat/message';
import '@gameon/on-ui-components/chat/widget';
import { OnChatWidget } from '@gameon/on-ui-components/chat/widget';
import '@gameon/on-ui-components/chat/window';
import { OnChatWindow } from '@gameon/on-ui-components/chat/window';
import '@gameon/on-ui-components/chat/notification';
import { Content, Message, Speaker } from '@gameon/on-ui-components/types';
import { ContextProvider } from '@lit-labs/context';
import { DocumentData } from 'firebase/firestore/lite';
import { css, html, LitElement, nothing, PropertyValues } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { v4 as uuidv4 } from 'uuid';
import { notificationsContext } from '../notifications/notifications.context';
import { Breakpoint, getCurrentBreakpoint } from './breakpoints';
import {
prodConfig as firebaseProdConfig,
stgConfig as firebaseStgConfig,
} from './firebase/firebase-config';
import { FirebaseInstance } from './firebase/firebase-instance';
import '@lottiefiles/lottie-player';
import '../notifications/notifications';
import '@gameon/on-ui-components/base/button';
const USER_STORAGE_KEY = 'ON_CHAT_BOT_CLIENT_USER_ID';
const CONVERSATION_STORAGE_KEY = 'ON_CHAT_BOT_CLIENT_CONVERSATION_ID';
const STG_WEB_CHANNEL_API_URL = 'https://web-channel.services.stg.tuul.com';
const PROD_WEB_CHANNEL_API_URL = 'https://web-channel.services.tuul.com';
const DEFAULT_HISTORY_LIMIT = 100;
const OPENED_DESKTOP_STORAGE_KEY = 'ON_CHAT_BOT_CLIENT_HAS_OPENED_DESKTOP';
const OPENED_MOBILE_STORAGE_KEY = 'ON_CHAT_BOT_CLIENT_HAS_OPENED_MOBILE';
const SURFACE_BACKDROP_Z_INDEX = 999;
const SURFACE_INPUT_Z_INDEX = 2000;
const SURFACE_WINDOW_CLOSE_BUTTON_Z_INDEX = 1001;
const SURFACE_WINDOW_Z_INDEX = 1000;
export interface ClientSendMessageDetail {
message: string;
}
export enum Environment {
PRODUCTION = 'PRODUCTION',
STAGING = 'STAGING',
LOCAL = 'LOCAL',
}
export enum DisplayMode {
FULL_SCREEN = 'FULL_SCREEN',
SURFACE = 'SURFACE',
WIDGET = 'WIDGET',
WIDGET_WITHOUT_BUTTON = 'WIDGET_WITHOUT_BUTTON',
}
export enum OnSearchParams {
INITIAL_PROMPT = 'on_initial_prompt',
SHOULD_AUTO_OPEN = 'on_should_auto_open',
SHOULD_AUTO_OPEN_MOBILE = 'on_should_auto_open_mobile',
}
export enum WidgetVisibility {
HIDDEN = 'hidden',
VISIBLE = 'visible',
}
interface BotConfig {
avatar: string;
colors?: {
primary?: string;
titleFont?: string;
};
title: string;
renderSettings?: {
attachments?: Record<string, string>;
colors?: Record<string, string>;
customStyles?: Record<string, string>;
compatibility: {
'chat-input-enable-voice': OnChatInputContext['enableVoice'];
'chat-input-placeholder': OnChatInputContext['placeholder'];
'chat-input-send-icon': OnChatInputContext['sendIcon'];
'chat-input-voice-icon': OnChatInputContext['voiceIcon'];
'custom-close-button-image': string;
'custom-open-button-image': string;
'custom-stylesheets': string[];
'link-target-mode': OnChatCardContext['linkTarget'];
'render-carousel-buttons': OnChatCarouselContext['renderButtons'];
'stack-quick-replies': OnChatMessageContext['stackQuickReplies'];
};
};
startingContent: Content[];
widgetVisibility?: WidgetVisibility;
}
interface Participant {
userId: string;
type: 'BOT' | 'USER';
name: string;
}
interface InitializeResponse {
timestamp: number;
conversation: {
channel: string;
app: string;
userId: string;
id: string;
isNewChat: boolean;
participants: Participant[];
};
}
interface ChatMessage extends Message {
isOptimistic?: boolean;
}
('on-chat-bot-client')
export class OnChatBotClient extends LitElement {
static override styles = css`
:host {
display: block;
}
:host([display-mode='FULL_SCREEN']) on-chat-window {
height: 100%;
}
:host([display-mode='WIDGET']) on-chat-window,
:host([display-mode='WIDGET_WITHOUT_BUTTON']) on-chat-window {
width: 100%;
}
:host([display-mode='SURFACE']) on-chat-window {
--on-chat-bubble-background-color: transparent;
--on-chat-bubble-border: none;
--on-chat-bubble-max-width: 80%;
--on-chat-conversation-primary-background-color: transparent;
--on-chat-conversation-primary-bubble-border: none;
--on-chat-conversation-primary-color: var(
--on-chat-surface-primary-color,
var(--on-chat-widget-primary-color, black)
);
bottom: 8rem;
left: 50%;
opacity: 0;
pointer-events: 'none';
position: fixed;
top: 0;
transition: 300ms opacity ease-in-out;
transform: translateX(-50%);
width: 80%;
z-index: ${SURFACE_WINDOW_Z_INDEX};
mask-image: linear-gradient(transparent 0, #000 20%);
}
:host([is-surface-chat-open]) .surface-backdrop,
:host([is-surface-chat-open]) on-chat-window,
:host([is-surface-chat-open]) .surface-chat-window-close-button {
opacity: 1;
pointer-events: 'auto';
}
.chat-button-image.open-chat-button {
height: var(
--on-chat-widget-button-open-size,
var(--on-chat-widget-button-size, 4rem)
);
}
.chat-button-image.close-chat-button {
height: var(
--on-chat-widget-button-close-size,
var(--on-chat-widget-button-size, 4rem)
);
}
.surface-backdrop {
background-color: var(
--on-chat-surface-backdrop-color,
rgba(0, 0, 0, 0.75)
);
backdrop-filter: blur(50px);
height: 100%;
left: 0;
opacity: 0;
pointer-events: 'none';
position: fixed;
top: 0;
transition: 300ms opacity ease-in-out;
width: 100%;
z-index: ${SURFACE_BACKDROP_Z_INDEX};
}
.surface-chat-input {
--on-chat-input-background-color: transparent;
--on-chat-input-border-width: 0;
background: linear-gradient(white, white) padding-box,
linear-gradient(
to right,
var(
--on-chat-surface-input-border-color-a,
var(--on-chat-input-border-color, #000)
),
var(
--on-chat-surface-input-border-color-b,
var(--on-chat-input-border-color, #000)
)
)
border-box;
border: 8px solid transparent;
border-radius: var(--on-chat-input-border-radius, 8px);
bottom: 2rem;
left: 50%;
max-width: 420px;
padding: 8px;
position: fixed;
transform: translateX(-50%);
width: 80%;
z-index: ${SURFACE_INPUT_Z_INDEX};
}
.surface-chat-window-close-button {
opacity: 0;
pointer-events: 'none';
position: fixed;
right: 2rem;
top: 2rem;
transition: 300ms opacity ease-in-out;
z-index: ${SURFACE_WINDOW_CLOSE_BUTTON_Z_INDEX};
}
`;
('on-chat-window')
private onChatWindowEl?: OnChatWindow;
('on-chat-widget')
private onChatWidgetEl?: OnChatWidget;
({ attribute: 'chat-close-image-url' })
chatCloseImageUrl?: string;
({ attribute: 'chat-open-image-url' })
chatOpenImageUrl?: string;
({ attribute: 'client-id' })
clientId = '';
({ type: Boolean })
debug = false;
({ attribute: 'message-history-limit', type: Number })
messageHistoryLimit = DEFAULT_HISTORY_LIMIT;
({ attribute: 'display-mode', reflect: true })
displayMode = DisplayMode.FULL_SCREEN;
()
env: Environment = Environment.PRODUCTION;
({ attribute: 'initial-prompt' })
initialPrompt?: string;
({ attribute: 'is-surface-chat-open', reflect: true, type: Boolean })
isSurfaceChatOpen = false;
({ attribute: 'persist-messages-across-sessions', type: Boolean })
persistMessagesAcrossSessions: boolean = false;
({ attribute: 'should-auto-open', type: Boolean })
shouldAutoOpen: boolean = false;
({ attribute: 'should-auto-open-mobile', type: Boolean })
shouldAutoOpenMobile: boolean = false;
()
private botAvatarUrl?: string;
()
private botId?: string;
()
private botName?: string;
()
private customStyles = new Map<string, string>();
()
private initialMessage?: Message;
()
private messages: ChatMessage[] = [];
()
private speakers: Speaker[] = [];
()
private startingContent?: Content[];
()
private surfaceChatInputValue = '';
()
private userId = '';
()
private usersTyping: Set<string> = new Set();
()
private webChannelParticipants: Participant[] = [];
()
private webChannelUserId?: string;
()
private widgetVisibility?: WidgetVisibility;
private lastSeenMessageProvider = new ContextProvider(
this,
notificationsContext,
{ lastSeenMessageTimestamp: undefined, unreadMessages: [] }
);
private onChatCardContextProvider = new ContextProvider(
this,
onChatCardContext,
{ linkTarget: '_blank' }
);
private onChatCarouselContextProvider = new ContextProvider(
this,
onChatCarouselContext,
{ renderButtons: true }
);
private onChatInputContextProvider = new ContextProvider(
this,
onChatInputContext,
{
enableVoice: false,
placeholder: 'Aa',
sendIcon: 'send',
voiceIcon: 'microphone-01',
}
);
private onChatMessageContextProvider = new ContextProvider(
this,
onChatMessageContext,
{ stackQuickReplies: true }
);
private get webChannelApiBaseUrl(): string {
switch (this.env) {
case Environment.LOCAL:
return '';
case Environment.STAGING:
return STG_WEB_CHANNEL_API_URL;
}
return PROD_WEB_CHANNEL_API_URL;
}
private get firebaseConfig() {
switch (this.env) {
case Environment.LOCAL:
case Environment.STAGING:
return firebaseStgConfig;
}
return firebaseProdConfig;
}
open() {
if (this.onChatWidgetEl) {
this.onChatWidgetEl.isOpen = true;
this.connectToFirestore();
this.resetLastSeenMessage();
}
}
close() {
if (this.onChatWidgetEl) {
this.onChatWidgetEl.isOpen = false;
}
}
toggle() {
if (this.onChatWidgetEl && this.onChatWidgetEl.isOpen) {
this.close();
} else {
this.open();
}
}
private getConversationId(clientId: string, userId: string): string {
const store = this.persistMessagesAcrossSessions
? window.localStorage
: window.sessionStorage;
let conversationId = store.getItem(
`${CONVERSATION_STORAGE_KEY}_${clientId}_${userId}`
);
// if there is no conversation id in storage, generate a new one and store it
if (!conversationId) {
conversationId = uuidv4();
this.setConversationId(conversationId, clientId, userId);
}
return conversationId;
}
private setConversationId(
conversationId: string,
clientId: string,
userId: string
): void {
const store = this.persistMessagesAcrossSessions
? window.localStorage
: window.sessionStorage;
store.setItem(
`${CONVERSATION_STORAGE_KEY}_${clientId}_${userId}`,
conversationId
);
}
private getWebChannelBotId(): string {
return (
this.webChannelParticipants.find((p) => p.type === 'BOT')?.userId ?? ''
);
}
protected override willUpdate(changedProperties: PropertyValues) {
// Compute speakers
if (
changedProperties.has('botAvatarUrl') ||
changedProperties.has('webChannelParticipants')
) {
this.speakers = [];
// for each participant, add a speaker
this.webChannelParticipants.forEach((participant) => {
switch (participant.type) {
case 'BOT':
this.speakers.push({
avatarUrl: this.botAvatarUrl,
id: participant.userId,
});
break;
case 'USER':
this.speakers.push({ id: participant.userId });
break;
}
});
}
// Load bot details
if (changedProperties.has('clientId') && this.clientId) {
this.loadBotConfig(this.clientId).then((config) => {
this.configureClient(config);
if (
this.displayMode === DisplayMode.FULL_SCREEN ||
this.displayMode === DisplayMode.SURFACE ||
this.shouldAutoOpen ||
this.shouldAutoOpenMobile
) {
this.connectToFirestore();
}
});
}
}
private getBotMessages() {
return this.messages.filter(
(message) => message.speakerId === this.getWebChannelBotId()
);
}
private resetLastSeenMessage() {
const lastBotMessage = this.getBotMessages().at(-1);
this.lastSeenMessageProvider.setValue({
lastSeenMessageTimestamp: lastBotMessage?.timestamp,
unreadMessages: [],
});
}
protected override firstUpdated() {
const storedUserId = this.fetchStoredUserId();
this.userId = storedUserId || uuidv4();
this.storeUserId(this.userId);
this.addEventListener(
'on-chat-widget-open',
this.connectToFirestore.bind(this),
{ once: true }
);
const params = new URLSearchParams(window.location.search);
if (!this.initialPrompt) {
const initialPromptParam = params.get(OnSearchParams.INITIAL_PROMPT);
this.initialPrompt = initialPromptParam ?? undefined;
}
window.addEventListener('on-chat-widget-open', () => {
this.resetLastSeenMessage();
});
}
private handleLottieLoad(event: Event) {
// Update the lottie-player aspect-ratio style property dynamically; Needed to tell Safari how to handle resizing the player based on height.
const player = event.currentTarget as HTMLElement;
const svgElement = player.shadowRoot!.querySelector('svg');
if (svgElement) {
const aspectRatio =
svgElement.viewBox.baseVal.width / svgElement.viewBox.baseVal.height;
player.style.aspectRatio = `${aspectRatio}`;
}
}
private async configureClient(config: BotConfig): Promise<void> {
this.botAvatarUrl = config.avatar;
this.botId = this.clientId;
this.botName = config.title;
this.startingContent = config.startingContent;
this.widgetVisibility = config.widgetVisibility;
this.customStyles = this.computeCustomStyles(config);
if (config.renderSettings?.compatibility?.['link-target-mode']) {
this.onChatCardContextProvider.setValue({
linkTarget: config.renderSettings.compatibility['link-target-mode'],
});
}
if (
config.renderSettings?.compatibility?.['render-carousel-buttons'] !==
undefined
) {
this.onChatCarouselContextProvider.setValue({
renderButtons:
config.renderSettings.compatibility['render-carousel-buttons'],
});
}
if (
config.renderSettings?.compatibility['chat-input-enable-voice'] ||
config.renderSettings?.compatibility?.['chat-input-placeholder'] ||
config.renderSettings?.compatibility['chat-input-send-icon'] ||
config.renderSettings?.compatibility['chat-input-voice-icon']
) {
this.onChatInputContextProvider.setValue({
enableVoice:
config.renderSettings.compatibility['chat-input-enable-voice'] ??
false,
placeholder:
config.renderSettings.compatibility['chat-input-placeholder'] ?? 'Aa',
sendIcon:
config.renderSettings.compatibility['chat-input-send-icon'] ?? 'send',
voiceIcon:
config.renderSettings.compatibility['chat-input-voice-icon'] ??
'microphone-01',
});
}
if (
config.renderSettings?.compatibility?.['stack-quick-replies'] !==
undefined
) {
this.onChatMessageContextProvider.setValue({
stackQuickReplies:
config.renderSettings?.compatibility['stack-quick-replies'],
});
}
if (config.renderSettings?.compatibility?.['custom-close-button-image']) {
this.chatCloseImageUrl =
config.renderSettings.compatibility['custom-close-button-image'];
}
if (config.renderSettings?.compatibility?.['custom-open-button-image']) {
this.chatOpenImageUrl =
config.renderSettings.compatibility['custom-open-button-image'];
}
if (config.renderSettings?.compatibility?.['custom-stylesheets']) {
const customStylesheetUrls =
config.renderSettings?.compatibility?.['custom-stylesheets'];
customStylesheetUrls.forEach((stylesheetUrl) => {
const link = document.createElement('link');
link.type = 'text/css';
link.rel = 'stylesheet';
link.href = stylesheetUrl;
document.head.appendChild(link);
});
}
// Auto open the widget
this.updateComplete.then(() => {
const params = new URLSearchParams(window.location.search);
const shouldAutoOpenParam = params.has(OnSearchParams.SHOULD_AUTO_OPEN);
const shouldAutoOpenMobileParam = params.has(
OnSearchParams.SHOULD_AUTO_OPEN_MOBILE
);
if (this.onChatWidgetEl) {
if (getCurrentBreakpoint() > Breakpoint.SMALL) {
this.onChatWidgetEl.isOpen =
(shouldAutoOpenParam || this.shouldAutoOpen) &&
!this.hasAutoOpenedThisSession(OPENED_DESKTOP_STORAGE_KEY);
} else {
this.onChatWidgetEl.isOpen =
(shouldAutoOpenMobileParam || this.shouldAutoOpenMobile) &&
!this.hasAutoOpenedThisSession(OPENED_MOBILE_STORAGE_KEY);
}
if (this.onChatWidgetEl.isOpen === true) {
this.connectToFirestore();
}
}
});
}
private updateMessages(documents: DocumentData[]): void {
if (this.debug) {
console.log(' updateMessages() start');
}
// Gather all message events, with a valid content.
const messages = documents.filter(
(doc) =>
doc.eventData &&
doc.eventData.eventType === 'message' &&
doc.channelData &&
doc.channelData.content.length > 0
);
// Gather all typing events.
const typingEvents = documents.filter(
(doc) =>
doc.eventData &&
(doc.eventData.eventType === 'typing_start' ||
doc.eventData.eventType === 'typing_end')
);
// Compute the users that are currently typing.
this.usersTyping = typingEvents.reduce<Set<string>>(
(usersTyping, currentEvent) => {
if (currentEvent.eventData.eventType === 'typing_start') {
usersTyping.add(currentEvent.channelData.user.id);
}
if (currentEvent.eventData.eventType === 'typing_end') {
usersTyping.delete(currentEvent.channelData.user.id);
}
return usersTyping;
},
new Set<string>()
);
if (this.debug) {
console.log(' Messages filtered: ', messages);
console.log(' Typing events: ', typingEvents);
console.log(' Users typing: ', this.usersTyping);
}
// Find optimistic message.
const optimisticMessages = this.messages.filter(
(message) => message.isOptimistic
);
if (this.debug) {
console.log(' Optimistic messages: ', optimisticMessages);
}
this.messages = messages.map((message) => ({
contents: message.channelData.content,
speakerId: message.channelData.user.id,
timestamp: message.timestamp,
}));
if (this.debug) {
console.log(' this.messages set: ', this.messages);
}
// If optimistic messages were found, add them back unless there's a
// message in the conversation with a later timestamp.
if (optimisticMessages.length > 0) {
const lastMessage = this.messages[this.messages.length - 1];
optimisticMessages.forEach((optimisticMessage) => {
if (
!lastMessage ||
lastMessage.timestamp < optimisticMessage.timestamp
) {
if (this.debug) {
console.log(
' adding back optimistic message: ',
optimisticMessage
);
}
this.messages.push(optimisticMessage);
}
});
}
// Set the initial message timestamp to be the earliest message.
if (this.initialMessage) {
const firstMessage = messages[0];
if (firstMessage) {
this.initialMessage.timestamp = firstMessage.timestamp - 1;
}
}
// Insert the initial message again, and add user typing messages.
this.messages = [
...(this.initialMessage ? [this.initialMessage] : []),
...this.messages,
];
if (this.debug) {
console.log(' final this.messages state:', this.messages);
console.log(' updateMessages() end');
}
}
private computeCustomStyles(config: BotConfig): Map<string, string> {
const customStyles = new Map<string, string>();
if (!config.renderSettings) {
return customStyles;
}
// For backwards compaitibility, search for specific customizations by name
const colorPrimary = config.colors?.primary;
const colorTitleFont = config.colors?.titleFont;
const attachmentFit =
config.renderSettings.attachments?.['--attachment-fit'];
const primaryButtonText =
config.renderSettings.colors?.['--primary-button-text'];
const primaryMessageBg =
config.renderSettings.colors?.['--primary-message-bg'];
if (colorPrimary) {
customStyles.set('--on-chat-widget-primary-color', colorPrimary);
customStyles.set(
'--on-linear-progress-active-indicator-color',
colorPrimary
);
}
if (colorTitleFont) {
customStyles.set(
'--on-chat-widget-text-on-primary-color',
colorTitleFont
);
}
if (attachmentFit) {
customStyles.set('--on-chat-card-image-fit', attachmentFit);
}
if (primaryButtonText) {
customStyles.set('--on-chat-input-button-color', primaryButtonText);
}
if (primaryMessageBg) {
customStyles.set(
'--on-chat-conversation-primary-background-color',
primaryMessageBg
);
}
// Add arbitrary customizations in the config
if (config.renderSettings.customStyles) {
Object.entries(config.renderSettings.customStyles).forEach(
(keyValuePair) => customStyles.set(keyValuePair[0], keyValuePair[1])
);
}
return customStyles;
}
private fetchStoredUserId(): string | null {
return window.localStorage.getItem(USER_STORAGE_KEY);
}
private storeUserId(userId: string): void {
window.localStorage.setItem(USER_STORAGE_KEY, userId);
}
private async loadBotConfig(clientId: string): Promise<BotConfig> {
const response = await fetch(
`${this.webChannelApiBaseUrl}/v1/config/${clientId}`
);
if (response.ok) {
return await response.json();
} else {
throw new Error(`A bot config failed to load for client ID: ${clientId}`);
}
}
private pushNotifications() {
if (this.debug) {
console.log('pushNotifications() start');
}
if (this.onChatWidgetEl?.isOpen) {
this.resetLastSeenMessage();
} else if (this.lastSeenMessageProvider.value.lastSeenMessageTimestamp) {
const botMessages = this.getBotMessages();
const lastBotMessage = botMessages.at(-1);
if (
lastBotMessage &&
lastBotMessage.timestamp >
this.lastSeenMessageProvider.value.lastSeenMessageTimestamp
) {
const unreadMessages = botMessages.filter(
(message) =>
message.timestamp >
(this.lastSeenMessageProvider.value.lastSeenMessageTimestamp ??
0) &&
!message.contents.find(
(content) =>
content.text ===
'<on-chat-typing-indicator></on-chat-typing-indicator>'
)
);
this.lastSeenMessageProvider.setValue({
lastSeenMessageTimestamp:
this.lastSeenMessageProvider.value.lastSeenMessageTimestamp,
unreadMessages: [...unreadMessages],
});
}
}
if (this.debug) {
console.log(
' lastSeenMessageProvider.value: ',
this.lastSeenMessageProvider.value
);
console.log('pushNotifications() end');
}
}
private async initializeConversation(
userId: string,
clientId: string,
conversationId: string,
baseURL: string
): Promise<InitializeResponse> {
const response = await fetch(`${baseURL}/v1/initialize`, {
body: JSON.stringify({
userId: userId,
clientId: clientId,
conversationId: conversationId,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
});
const result = await response.json();
this.setConversationId(result.conversation.id, clientId, userId);
return result;
}
private async initializeFirestoreConversation(
userId: string,
clientId: string,
conversationId: string,
baseURL: string,
startingContent: Content[]
) {
if (this.debug) {
console.log('initializeFirestoreConversation() start');
}
const resp = await this.initializeConversation(
userId,
clientId,
conversationId,
baseURL
);
this.webChannelUserId = resp.conversation.userId;
this.webChannelParticipants = resp.conversation.participants;
this.initialMessage = {
contents: startingContent,
speakerId: this.getWebChannelBotId(),
timestamp: Date.now(),
};
const messageInitData = {
app: resp.conversation.app,
channel: resp.conversation.channel,
conversationId: resp.conversation.id,
};
let fb: FirebaseInstance;
fb = new FirebaseInstance(this.firebaseConfig);
if (this.debug) {
console.log(' FirebaseInstance initialized: ', fb);
}
fb.listenToConvo(
messageInitData,
this.messageHistoryLimit,
(snapshot) => {
if (this.debug) {
console.log('FirebaseInstance.listenToConvo() onSuccess() start');
console.log(' Got a new snapshot: ', snapshot);
}
const messagesData = snapshot.docs.map((doc) => doc.data()).reverse();
if (this.debug) {
console.log(' Snapshot docs processed: ', messagesData);
}
this.updateMessages(messagesData);
this.pushNotifications();
if (this.debug) {
console.log('FirebaseInstance.listenToConvo() onSuccess() end');
}
},
(error) => {
console.error('Firestore error', error);
}
);
if (this.initialPrompt) {
this.sendMessage(this.initialPrompt);
}
if (this.debug) {
console.log('initializeFirestoreConversation() end');
}
}
private async sendMessage(text: string): Promise<void> {
if (!this.clientId || !text.length) {
return;
}
const payload = JSON.stringify({
message: { text },
clientId: this.clientId,
userId: this.userId,
conversationId: this.getConversationId(this.clientId, this.userId),
});
const sentMessageResponse = await fetch(
`${this.webChannelApiBaseUrl}/v1/message`,
{
body: payload,
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
}
);
const sentMessageMeta = await sentMessageResponse.json();
const message: ChatMessage = {
contents: [{ text }],
speakerId: this.webChannelUserId ?? '',
timestamp: sentMessageMeta.timestamp,
isOptimistic: true,
};
this.messages = [...this.messages, message];
// Fix the initial message timestamp if the sent message is behind the
// initial message.
if (
this.initialMessage &&
this.initialMessage.timestamp > sentMessageMeta.timestamp
) {
this.initialMessage.timestamp = sentMessageMeta.timestamp - 1;
}
this.dispatchEvent(
new CustomEvent<ClientSendMessageDetail>('on-client-send-message', {
bubbles: true,
composed: true,
detail: {
message: text,
},
})
);
}
private handleSurfaceChatCloseClick() {
this.isSurfaceChatOpen = false;
document.body.style.overflow = 'visible';
}
private handleChatInputSubmit(
e:
| CustomEvent<InputSubmitEventDetails>
| CustomEvent<MessageQuickReplyClickEventDetails>
) {
if (!this.userId) {
return;
}
this.sendMessage(e.detail.value);
// Reset the input box and scroll to bottom
if (this.onChatWindowEl) {
this.onChatWindowEl.inputValue = '';
this.onChatWindowEl.scrollToBottom();
}
// Reset the input value
if (this.displayMode === DisplayMode.SURFACE) {
this.surfaceChatInputValue = '';
this.isSurfaceChatOpen = true;
document.body.style.overflow = 'hidden';
}
}
private hasAutoOpenedThisSession(key: string) {
if (!window.sessionStorage.getItem(key)) {
// Set the session storage
window.sessionStorage.setItem(key, 'true');
return false;
}
// It has been opened this seesion so don't open it again
return true;
}
private async connectToFirestore() {
if (!this.webChannelUserId) {
if (this.debug) {
console.log('Connecting to Firestore');
}
this.initializeFirestoreConversation(
this.userId,
this.clientId,
this.getConversationId(this.clientId, this.userId),
this.webChannelApiBaseUrl,
this.startingContent ?? []
);
}
}
private renderChatWindow(shouldHideDisplayInputBar: boolean = false) {
return html`
<on-chat-window
.conversationMessages="${this.messages}"
.conversationPrimarySpeakerId="${this.webChannelUserId ?? ''}"
.conversationSpeakers="${this.speakers}"
?shouldDisplayLoadingIndicator="${this.usersTyping.size > 0}"
?shouldHideDisplayInputBar="${shouldHideDisplayInputBar}"
@on-chat-input-submit="${this.handleChatInputSubmit}"
@on-chat-message-quick-reply-click="${this.handleChatInputSubmit}"
></on-chat-window>
<style>
:host {
${Array.from(this.customStyles.entries()).map(
(keyValuePair) => `${keyValuePair[0]}: ${keyValuePair[1]};`
)};
}
</style>
<on-button
class="surface-chat-window-close-button"
.icon="${'x'}"
.iconPlacement="${'only'}"
@click="${this.handleSurfaceChatCloseClick}"
></on-button>
`;
}
private renderSurface() {
return html`
<div class="surface-backdrop"></div>
${this.renderChatWindow(true)}
<on-chat-input
class="surface-chat-input"
.value="${this.surfaceChatInputValue}"
@on-chat-input-change="${({
detail,
}: CustomEvent<InputChangeEventDetails>) =>
(this.surfaceChatInputValue = detail.value)}"
@on-chat-input-submit="${this.handleChatInputSubmit}"
></on-chat-input>
`;
}
private renderChatNotifications() {
if (this.onChatWidgetEl?.isOpen || !this.lastSeenMessageProvider.value)
return nothing;
const handleNotificationOpenWidget = () => {
if (this.onChatWidgetEl) {
this.onChatWidgetEl.isOpen = true;
this.resetLastSeenMessage();
}
};
const primarySpeaker = this.speakers.find(
(speaker) => speaker.id === this.getWebChannelBotId()
);
return html`
<on-chat-bot-notifications
.speaker=${primarySpeaker}
@on-notification-reset=${this.resetLastSeenMessage}
@on-notification-open-message=${handleNotificationOpenWidget}
></on-chat-bot-notifications>
`;
}
private renderCustomButton(type: 'OPEN' | 'CLOSE', src: string) {
const slot = type === 'OPEN' ? 'open-chat-button' : 'close-chat-button';
const classes = classMap({
'chat-button-image': true,
'open-chat-button': type === 'OPEN',
'close-chat-button': type === 'CLOSE',
});
const isLottieAnimation = src.endsWith('.json');
return isLottieAnimation
? html`
<lottie-player
slot="${slot}"
class="${classes}"
autoplay
loop
mode="normal"
src="${src}"
@ready="${this.handleLottieLoad}"
>
</lottie-player>
`
: html` <img slot="${slot}" class="${classes}" src="${src}" /> `;
}
override render() {
if (!this.botId) {
return;
}
switch (this.displayMode) {
case DisplayMode.WIDGET:
case DisplayMode.WIDGET_WITHOUT_BUTTON:
return this.widgetVisibility !== WidgetVisibility.HIDDEN
? html`
${this.renderChatNotifications()}
<on-chat-widget
.avatarUrl="${this.botAvatarUrl}"
.shouldHideWidgetButton="${this.displayMode ===
DisplayMode.WIDGET_WITHOUT_BUTTON}"
.windowTitle="${this.botName}"
>
${this.renderChatWindow()}
${this.chatOpenImageUrl
? this.renderCustomButton('OPEN', this.chatOpenImageUrl)
: nothing}
${this.chatCloseImageUrl
? this.renderCustomButton('CLOSE', this.chatCloseImageUrl)
: nothing}
</on-chat-widget>
`
: nothing;
case DisplayMode.SURFACE:
return this.renderSurface();
default:
return this.renderChatWindow();
}
}
}
declare global {
interface HTMLElementTagNameMap {
'on-chat-bot-client': OnChatBotClient;
}
interface HTMLElementEventMap {
'on-client-send-message': CustomEvent<ClientSendMessageDetail>;
}
}