@hellotext/hellotext
Version:
Hellotext JavaScript Client
567 lines (423 loc) • 16.1 kB
JavaScript
import { flip, offset, shift } from '@floating-ui/dom'
import { Controller } from '@hotwired/stimulus'
import WebchatMessagesAPI from '../api/webchat/messages'
import WebchatChannel from '../channels/webchat_channel'
import Hellotext from '../hellotext'
import { Locale } from '../core'
import { Webchat as WebchatConfiguration, behaviors } from '../core/configuration/webchat'
import { LogoBuilder } from '../builders/logo_builder'
import { usePopover } from './mixins/usePopover'
export default class extends Controller {
static values = {
id: String,
conversationId: String,
media: Object,
fileSizeErrorMessage: String,
placement: { type: String, default: 'bottom-end' },
open: { type: Boolean, default: false },
autoPlacement: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
nextPage: { type: Number, default: undefined },
fullScreenThreshold: { type: Number, default: 1024 },
}
static classes = ['fadeOut']
static targets = [
'trigger',
'popover',
'input',
'attachmentInput',
'attachmentButton',
'errorMessageContainer',
'attachmentTemplate',
'attachmentContainer',
'attachment',
'messageTemplate',
'messagesContainer',
'title',
'onlineStatus',
'attachmentImage',
'footer',
'toolbar',
'message',
'unreadCounter',
]
initialize() {
this.messagesAPI = new WebchatMessagesAPI(this.idValue)
this.webChatChannel = new WebchatChannel(
this.idValue,
Hellotext.session,
this.conversationIdValue,
)
this.files = []
this.onMessageReceived = this.onMessageReceived.bind(this)
this.onConversationAssignment = this.onConversationAssignment.bind(this)
this.onAgentOnline = this.onAgentOnline.bind(this)
this.onMessageReaction = this.onMessageReaction.bind(this)
this.onScroll = this.onScroll.bind(this)
this.onOutboundMessageSent = this.onOutboundMessageSent.bind(this)
this.broadcastChannel = new BroadcastChannel(`hellotext--webchat--${this.idValue}`)
super.initialize()
}
connect() {
usePopover(this)
this.popoverTarget.classList.add(...WebchatConfiguration.classes)
this.triggerTarget.classList.add(...WebchatConfiguration.triggerClasses)
this.setupFloatingUI({ trigger: this.triggerTarget, popover: this.popoverTarget })
this.webChatChannel.onMessage(this.onMessageReceived)
this.webChatChannel.onConversationAssignment(this.onConversationAssignment)
this.webChatChannel.onAgentOnline(this.onAgentOnline)
this.webChatChannel.onReaction(this.onMessageReaction)
this.messagesContainerTarget.addEventListener('scroll', this.onScroll)
if (!Hellotext.business.features.white_label) {
this.toolbarTarget.appendChild(LogoBuilder.build())
}
if (this.shouldOpenOnMount) {
this.openValue = true
}
Hellotext.eventEmitter.dispatch('webchat:mounted')
this.broadcastChannel.addEventListener('message', this.onOutboundMessageSent)
super.connect()
}
disconnect() {
this.broadcastChannel.removeEventListener('message', this.onOutboundMessageSent)
this.messagesContainerTarget.removeEventListener('scroll', this.onScroll)
this.broadcastChannel.close()
this.floatingUICleanup()
super.disconnect()
}
onOutboundMessageSent(event) {
const { data } = event
const callbacks = {
'message:sent': data => {
const element = new DOMParser().parseFromString(data.element, 'text/html').body
.firstElementChild
this.messagesContainerTarget.appendChild(element)
element.scrollIntoView({ behavior: 'instant' })
},
'message:failed': data => {
this.messagesContainerTarget.querySelector(`#${data.id}`)?.classList.add('failed')
},
}
if (callbacks[data.type]) {
callbacks[data.type](data)
} else {
console.log(`Unhandled message event: ${data.type}`)
}
}
async onScroll() {
if (
this.messagesContainerTarget.scrollTop > 300 ||
!this.nextPageValue ||
this.fetchingNextPage
)
return
this.fetchingNextPage = true
const response = await this.messagesAPI.index({
page: this.nextPageValue,
session: Hellotext.session,
})
const { next: nextPage, messages } = await response.json()
this.nextPageValue = nextPage
this.oldScrollHeight = this.messagesContainerTarget.scrollHeight
messages.forEach(message => {
const { body, attachments } = message
const div = document.createElement('div')
div.innerHTML = body
const element = this.messageTemplateTarget.cloneNode(true)
element.setAttribute('data-hellotext--webchat-target', 'message')
element.style.removeProperty('display')
element.querySelector('[data-body]').innerHTML = div.innerHTML
if (message.state === 'received') {
element.classList.add('received')
} else {
element.classList.remove('received')
}
if (attachments) {
attachments.forEach(attachmentUrl => {
const image = this.attachmentImageTarget.cloneNode(true)
image.removeAttribute('data-hellotext--webchat-target')
image.src = attachmentUrl
image.style.display = 'block'
element.querySelector('[data-attachment-container]').appendChild(image)
})
}
element.setAttribute('data-body', body)
this.messagesContainerTarget.prepend(element)
})
this.messagesContainerTarget.scroll({
top: this.messagesContainerTarget.scrollHeight - this.oldScrollHeight,
behavior: 'instant',
})
this.fetchingNextPage = false
}
onClickOutside(event) {
if (
WebchatConfiguration.behaviour === behaviors.POPOVER &&
this.openValue &&
event.target.nodeType &&
this.element.contains(event.target) === false
) {
this.openValue = false
}
}
closePopover() {
this.popoverTarget.classList.add(...this.fadeOutClasses)
setTimeout(() => {
this.openValue = false
}, 250)
}
onPopoverOpened() {
if (!this.onMobile) {
this.inputTarget.focus()
}
if (!this.scrolled) {
requestAnimationFrame(() => {
this.messagesContainerTarget.scroll({
top: this.messagesContainerTarget.scrollHeight,
behavior: 'instant',
})
})
this.scrolled = true
}
Hellotext.eventEmitter.dispatch('webchat:opened')
localStorage.setItem(`hellotext--webchat--${this.idValue}`, 'opened')
if (this.unreadCounterTarget.style.display === 'none') return
this.unreadCounterTarget.style.display = 'none'
this.unreadCounterTarget.innerText = '0'
this.messagesAPI.markAsSeen()
}
onPopoverClosed() {
Hellotext.eventEmitter.dispatch('webchat:closed')
localStorage.setItem(`hellotext--webchat--${this.idValue}`, 'closed')
}
onMessageReaction(message) {
const { message: messageId, reaction, type } = message
const messageElement = this.messageTargets.find(element => element.dataset.id === messageId)
const reactionsContainer = messageElement.querySelector('[data-reactions]')
if (type === 'reaction.destroy') {
const reactionElement = reactionsContainer.querySelector(`[data-id="${reaction.id}"]`)
return reactionElement.remove()
}
if (reactionsContainer.querySelector(`[data-id="${reaction.id}"]`)) {
const reactionElement = reactionsContainer.querySelector(`[data-id="${reaction.id}"]`)
reactionElement.innerText = reaction.emoji
} else {
const reactionElement = document.createElement('span')
reactionElement.innerText = reaction.emoji
reactionElement.setAttribute('data-id', reaction.id)
reactionsContainer.appendChild(reactionElement)
}
}
onMessageReceived(message) {
const { id, body, attachments } = message
const div = document.createElement('div')
div.innerHTML = body
const element = this.messageTemplateTarget.cloneNode(true)
element.style.display = 'flex'
element.querySelector('[data-body]').innerHTML = div.innerHTML
element.setAttribute('data-hellotext--webchat-target', 'message')
if (attachments) {
attachments.forEach(attachmentUrl => {
const image = this.attachmentImageTarget.cloneNode(true)
image.src = attachmentUrl
image.style.display = 'block'
element.querySelector('[data-attachment-container]').appendChild(image)
})
}
this.messagesContainerTarget.appendChild(element)
Hellotext.eventEmitter.dispatch('webchat:message:received', {
...message,
body: element.querySelector('[data-body]').innerText,
})
element.scrollIntoView({ behavior: 'smooth' })
this.setOfflineTimeout()
if (this.openValue) {
this.messagesAPI.markAsSeen(id)
return
}
this.unreadCounterTarget.style.display = 'flex'
const unreadCount = (parseInt(this.unreadCounterTarget.innerText) || 0) + 1
this.unreadCounterTarget.innerText = unreadCount > 99 ? '99+' : unreadCount
}
onConversationAssignment(conversation) {
const { to: user } = conversation
this.titleTarget.innerText = user.name
if (user.online) {
this.onlineStatusTarget.style.display = 'flex'
} else {
this.onlineStatusTarget.style.display = 'none'
}
}
onAgentOnline() {
this.onlineStatusTarget.style.display = 'flex'
this.setOfflineTimeout()
}
async sendMessage() {
const formData = new FormData()
const message = {
body: this.inputTarget.value,
attachments: this.files,
}
if (this.inputTarget.value.trim().length === 0 && this.files.length === 0) {
return
}
if (this.inputTarget.value.trim().length > 0) {
formData.append('message[body]', this.inputTarget.value)
} else {
delete message.body
}
this.files.forEach(file => {
formData.append('message[attachments][]', file)
})
formData.append('session', Hellotext.session)
formData.append('locale', Locale.toString())
const element = this.messageTemplateTarget.cloneNode(true)
element.id = `hellotext--webchat--${this.idValue}--message--${Date.now()}`
element.classList.add('received')
element.style.removeProperty('display')
element.setAttribute('data-hellotext--webchat-target', 'message')
if (this.inputTarget.value.trim().length > 0) {
element.querySelector('[data-body]').innerText = this.inputTarget.value
} else {
element.querySelector('[data-message-bubble]').remove()
}
const attachments = this.attachmentContainerTarget.querySelectorAll('img')
if (attachments.length > 0) {
attachments.forEach(attachment => {
element.querySelector('[data-attachment-container]').appendChild(attachment)
})
}
this.messagesContainerTarget.appendChild(element)
element.scrollIntoView({ behavior: 'smooth' })
this.broadcastChannel.postMessage({
type: 'message:sent',
element: element.outerHTML,
})
this.inputTarget.value = ''
this.files = []
this.attachmentInputTarget.value = ''
this.attachmentContainerTarget.innerHTML = ''
this.attachmentContainerTarget.style.display = 'none'
this.errorMessageContainerTarget.style.display = 'none'
this.inputTarget.focus()
const response = await this.messagesAPI.create(formData)
if (response.failed) {
this.broadcastChannel.postMessage({
type: 'message:failed',
id: element.id,
})
return element.classList.add('failed')
}
const data = await response.json()
element.setAttribute('data-id', data.id)
message.id = data.id
Hellotext.eventEmitter.dispatch('webchat:message:sent', message)
if (data.conversation !== this.conversationIdValue) {
this.conversationIdValue = data.conversation
this.webChatChannel.updateSubscriptionWith(this.conversationIdValue)
}
this.attachmentContainerTarget.style.display = ''
}
openAttachment() {
this.attachmentInputTarget.click()
}
onFileInputChange() {
this.errorMessageContainerTarget.style.display = 'none'
const newFiles = Array.from(this.attachmentInputTarget.files)
this.attachmentInputTarget.value = ''
const fileMaxSizeTooMuch = newFiles.find(file => {
const type = file.type.split('/')[0]
if (['image', 'video', 'audio'].includes(type)) {
return this.mediaValue[type].max_size < file.size
} else {
return this.mediaValue.document.max_size < file.size
}
})
if (fileMaxSizeTooMuch) {
const type = fileMaxSizeTooMuch.type.split('/')[0]
const mediaType = ['image', 'audio', 'video'].includes(type) ? type : 'document'
this.errorMessageContainerTarget.innerText = this.fileSizeErrorMessageValue.replace(
'%{limit}',
this.byteToMegabyte(this.mediaValue[mediaType].max_size),
)
return (this.errorMessageContainerTarget.style.display = 'block')
}
this.files = [...this.files, ...newFiles]
this.errorMessageContainerTarget.innerText = ''
newFiles.forEach(file => this.createAttachmentElement(file))
this.inputTarget.focus()
}
createAttachmentElement(file) {
const element = this.attachmentElement()
this.attachmentContainerTarget.style.display = ''
element.setAttribute('data-name', file.name)
if (file.type.startsWith('image/')) {
const thumbnail = this.attachmentImageTarget.cloneNode(true)
thumbnail.src = URL.createObjectURL(file)
thumbnail.style.display = 'block'
element.appendChild(thumbnail)
this.attachmentContainerTarget.appendChild(element)
this.attachmentContainerTarget.style.display = 'flex'
} else {
const main = element.querySelector('main')
main.style.height = '5rem'
main.style.borderRadius = '0.375rem'
main.style.backgroundColor = '#e5e7eb'
main.style.padding = '0.25rem'
element.querySelector('p[data-attachment-name]').innerText = file.name
this.attachmentContainerTarget.appendChild(element)
this.attachmentContainerTarget.style.display = 'flex'
}
}
removeAttachment({ currentTarget }) {
const attachment = currentTarget.closest("[data-hellotext--webchat-target='attachment']")
this.files = this.files.filter(file => file.name !== attachment.dataset.name)
this.attachmentInputTarget.value = ''
attachment.remove()
this.inputTarget.focus()
}
attachmentTargetDisconnected() {
if (this.attachmentTargets.length === 0) {
this.attachmentContainerTarget.innerHTML = ''
this.attachmentContainerTarget.style.display = 'none'
}
}
attachmentElement() {
const element = this.attachmentTemplateTarget.cloneNode(true)
element.removeAttribute('hidden')
element.style.display = 'flex'
element.setAttribute('data-hellotext--webchat-target', 'attachment')
return element
}
onEmojiSelect({ detail: emoji }) {
const value = this.inputTarget.value
const start = this.inputTarget.selectionStart
const end = this.inputTarget.selectionEnd
this.inputTarget.value = value.slice(0, start) + emoji + value.slice(end)
this.inputTarget.selectionStart = this.inputTarget.selectionEnd = start + emoji.length
this.inputTarget.focus()
}
setOfflineTimeout() {
clearTimeout(this.offlineTimeout)
this.offlineTimeout = setTimeout(() => {
this.onlineStatusTarget.style.display = 'none'
}, this.fiveMinutes)
}
byteToMegabyte(bytes) {
return Math.ceil(bytes / 1024 / 1024)
}
get fiveMinutes() {
return 300000
}
get middlewares() {
return [offset(5), shift({ padding: 24 }), flip()]
}
get shouldOpenOnMount() {
return (
localStorage.getItem(`hellotext--webchat--${this.idValue}`) === 'opened' && !this.onMobile
)
}
get onMobile() {
return window.matchMedia(`(max-width: ${this.fullScreenThresholdValue}px)`).matches
}
}