UNPKG

@gameon/web

Version:
1,210 lines (1,044 loc) 34.5 kB
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; } @customElement('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}; } `; @query('on-chat-window') private onChatWindowEl?: OnChatWindow; @query('on-chat-widget') private onChatWidgetEl?: OnChatWidget; @property({ attribute: 'chat-close-image-url' }) chatCloseImageUrl?: string; @property({ attribute: 'chat-open-image-url' }) chatOpenImageUrl?: string; @property({ attribute: 'client-id' }) clientId = ''; @property({ type: Boolean }) debug = false; @property({ attribute: 'message-history-limit', type: Number }) messageHistoryLimit = DEFAULT_HISTORY_LIMIT; @property({ attribute: 'display-mode', reflect: true }) displayMode = DisplayMode.FULL_SCREEN; @property() env: Environment = Environment.PRODUCTION; @property({ attribute: 'initial-prompt' }) initialPrompt?: string; @property({ attribute: 'is-surface-chat-open', reflect: true, type: Boolean }) isSurfaceChatOpen = false; @property({ attribute: 'persist-messages-across-sessions', type: Boolean }) persistMessagesAcrossSessions: boolean = false; @property({ attribute: 'should-auto-open', type: Boolean }) shouldAutoOpen: boolean = false; @property({ attribute: 'should-auto-open-mobile', type: Boolean }) shouldAutoOpenMobile: boolean = false; @state() private botAvatarUrl?: string; @state() private botId?: string; @state() private botName?: string; @state() private customStyles = new Map<string, string>(); @state() private initialMessage?: Message; @state() private messages: ChatMessage[] = []; @state() private speakers: Speaker[] = []; @state() private startingContent?: Content[]; @state() private surfaceChatInputValue = ''; @state() private userId = ''; @state() private usersTyping: Set<string> = new Set(); @state() private webChannelParticipants: Participant[] = []; @state() private webChannelUserId?: string; @state() 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>; } }