// chat-client.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; const DTP_COMPONENT_NAME = 'DtpChatApp'; const dtp = window.dtp = window.dtp || { }; import DtpApp from 'lib/dtp-app.js'; import ChatAudio from './chat-audio.js'; import QRCode from 'qrcode'; import Cropper from 'cropperjs'; import dayjs from 'dayjs'; import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; dayjs.extend(dayjsRelativeTime); import hljs from 'highlight.js'; export class ChatApp extends DtpApp { static get SFX_CHAT_MESSAGE ( ) { return 'chat-message'; } constructor (user) { super(DTP_COMPONENT_NAME, user); this.loadSettings(); this.log.info('DTP app client online'); this.chat = { form: document.querySelector('#chat-input-form'), messageList: document.querySelector('#chat-message-list'), messages: [ ], messageMenu: document.querySelector('.chat-message-menu'), input: document.querySelector('#chat-input-text'), sendButton: document.querySelector('#chat-send-btn'), isAtBottom: true, }; if (this.chat.input) { this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); this.observer = new MutationObserver(this.onChatMessageListChanged.bind(this)); this.observer.observe(this.chat.messageList, { childList: true }); hljs.highlightAll(); } window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); window.addEventListener('unload', this.onDtpUnload.bind(this)); this.updateTimestamps(); this.emojiPickerDisplay = document.querySelector('.emoji-picker-display'); if (this.emojiPickerDisplay) { this.emojiPickerDisplay.addEventListener('click', this.onEmojiPickerClose.bind(this)); } this.emojiPicker = document.querySelector('emoji-picker'); if (this.emojiPicker) { this.emojiPicker.addEventListener('emoji-click', this.onEmojiPicked.bind(this)); } } async startAudio ( ) { this.log.info('startAudio', 'starting ChtaAudio'); this.audio = new ChatAudio(); this.audio.start(); try { await Promise.all([ this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE, '/static/sfx/chat-message.mp3'), ]); } catch (error) { this.log.error('startAudio', 'failed to load sound', { error }); // fall through } } async onChatInputKeyDown (event) { if (event.key === 'Enter' && !event.shiftKey) { if (dtp.room) { return this.sendChatRoomMessage(event); } if (dtp.thread) { return this.sendPrivateMessage(event); } return this.sendUserChat(event); } } async onChatMessageListChanged (mutationList) { this.log.info('onMutation', 'DOM mutation received', { mutationList }); if (!Array.isArray(mutationList) || (mutationList.length === 0)) { return; } for (const mutation of mutationList) { for (const node of mutation.addedNodes) { if (typeof node.querySelectorAll !== 'function') { continue; } const timestamps = node.querySelectorAll("[data-dtp-timestamp]"); this.updateTimestamps(timestamps); } } hljs.highlightAll(); } updateTimestamps ( ) { const nodeList = document.querySelectorAll("[data-dtp-timestamp]"); this.log.debug('updateTimestamps', 'updating timestamps', { count: nodeList.length }); for (const ts of nodeList) { const date = ts.getAttribute('data-dtp-timestamp'); const format = ts.getAttribute('data-dtp-timestamp-format'); if (!date) { continue; } switch (format) { case 'date': ts.textContent = dayjs(date).format('MMM DD, YYYY'); break; case 'time': ts.textContent = dayjs(date).format('h:mm a'); break; case 'datetime': ts.textContent = dayjs(date).format('MMM D [at] h:mm a'); break; case 'fuzzy': ts.textContent = dayjs(date).fromNow(); break; case 'timestamp': default: ts.textContent = dayjs(date).format('hh:mm:ss a'); break; } } } async sendUserChat (event) { event.preventDefault(); if (!dtp.user) { UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); return; } if (this.chatTimeout) { return; } const channelId = dtp.user._id; const content = this.chat.input.value; this.chat.input.value = ''; if (content.length === 0) { return true; } this.log.debug('sendUserChat', 'sending chat message', { channel: this.user._id, content }); this.socket.sendUserChat(channelId, content); // set focus back to chat input this.chat.input.focus(); const isFreeMember = false; this.chat.sendButton.setAttribute('disabled', ''); this.chat.sendButton.setAttribute('uk-tooltip', isFreeMember ? 'Waiting 30 seconds' : 'Waiting 5 seconds'); this.chatTimeout = setTimeout(( ) => { delete this.chatTimeout; this.chat.sendButton.removeAttribute('disabled'); this.chat.sendButton.setAttribute('uk-tooltip', 'Send message'); }, isFreeMember ? 30000 : 5000); return true; } async sendChatRoomMessage (event) { if (event) { event.preventDefault(); event.stopPropagation(); } if (this.chatTimeout) { return; } const form = new FormData(this.chat.form); const roomId = this.chat.form.getAttribute('data-room-id'); const content = this.chat.input.value; this.chat.input.value = ''; if (content.length === 0) { return true; } try { this.log.info('sendChatRoomMessage', 'sending chat message', { room: roomId, content }); const response = await fetch(this.chat.form.action, { method: this.chat.form.method, body: form, }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to send chat message: ${error.message}`); } // set focus back to chat input this.chat.input.focus(); this.chat.sendButton.setAttribute('disabled', ''); this.chatTimeout = setTimeout(( ) => { delete this.chatTimeout; this.chat.sendButton.removeAttribute('disabled'); }, 5000); return true; } async onDtpLoad ( ) { this.log.info('dtp-load event received. Connecting to platform.'); await this.connect({ mode: 'User', onSocketConnect: this.onChatSocketConnect.bind(this), onSocketDisconnect: this.onChatSocketDisconnect.bind(this), }); } async onDtpUnload ( ) { await this.socket.disconnect(); } async onChatSocketConnect (socket) { this.log.debug('onSocketConnect', 'attaching socket events'); socket.on('chat-message', this.onChatMessage.bind(this)); socket.on('chat-control', this.onChatControl.bind(this)); socket.on('system-message', this.onSystemMessage.bind(this)); if (dtp.room) { await this.joinChatChannel(dtp.room); } } async onChatSocketDisconnect (socket) { this.log.debug('onSocketDisconnect', 'detaching socket events'); socket.off('chat-message', this.onChatMessage.bind(this)); socket.off('chat-control', this.onChatControl.bind(this)); socket.off('system-message', this.onSystemMessage.bind(this)); } async onChatMessage (message) { const isAtBottom = this.chat.isAtBottom; this.log.info('onChatMessage', 'chat message received', { message }); this.chat.messageList.insertAdjacentHTML('beforeend', message.html); this.audio.playSound(ChatApp.SFX_CHAT_MESSAGE); this.scrollChatToBottom(isAtBottom); } async onChatControl (message) { const isAtBottom = this.chat.isAtBottom; if (message.displayList) { this.displayEngine.executeDisplayList(message.displayList); } if (Array.isArray(message.systemMessages) && (message.systemMessages.length > 0)) { for await (const sm of message.systemMessages) { await this.onSystemMessage(sm); } } if (message.cmd) { switch (message.cmd) { case 'call-start': if (message.mediaServer && !this.call) { dtp.mediaServer = message.mediaServer; setTimeout(this.joinWebCall.bind(this), Math.floor(Math.random() * 3000)); } break; case 'call-end': if (this.chat) { this.chat.closeCall(); } break; } } this.scrollChatToBottom(isAtBottom); } async onSystemMessage (message) { if (message.displayList) { this.displayEngine.executeDisplayList(message.displayList); } if (!message.created || !message.content) { return; } if (!this.chat || !this.chat.messageList) { return; } const systemMessage = document.createElement('div'); systemMessage.classList.add('chat-message'); systemMessage.classList.add('system-message'); systemMessage.classList.add('no-select'); const grid = document.createElement('div'); grid.toggleAttribute('uk-grid', true); grid.classList.add('uk-grid-small'); systemMessage.appendChild(grid); let column = document.createElement('div'); column.classList.add('uk-width-expand'); grid.appendChild(column); const chatContent = document.createElement('div'); chatContent.classList.add('message-content'); chatContent.classList.add('uk-text-break'); chatContent.innerHTML = message.content; column.appendChild(chatContent); column = document.createElement('div'); column.classList.add('uk-width-expand'); grid.appendChild(column); const chatTimestamp = document.createElement('div'); chatTimestamp.classList.add('message-timestamp'); chatTimestamp.classList.add('uk-text-small'); chatTimestamp.classList.add('uk-text-right'); chatTimestamp.setAttribute('data-dtp-timestamp', message.created); chatTimestamp.setAttribute('data-dtp-timestamp-format', 'time'); chatTimestamp.innerHTML = dayjs(message.created).format('h:mm:ss a'); column.appendChild(chatTimestamp); this.chat.messageList.appendChild(systemMessage); this.chat.messages.push(systemMessage); while (this.chat.messages.length > 50) { const message = this.chat.messages.shift(); this.chat.messageList.removeChild(message); } if (this.chat.isAtBottom) { this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); } } async joinChatChannel (room) { try { const response = await fetch(`/chat/room/${dtp.room._id}/join`); await this.processResponse(response); await this.socket.joinChannel(dtp.room._id, 'ChatRoom'); } catch (error) { this.log.error('failed to join chat room', { room, error }); UIkit.modal.alert(`Failed to join chat room: ${error.message}`); } } async confirmNavigation (event) { const target = event.currentTarget || event.target; event.preventDefault(); event.stopPropagation(); const href = target.getAttribute('href'); const hrefTarget = target.getAttribute('target'); const text = target.textContent; const whitelist = [ 'digitaltelepresence.com', 'www.digitaltelepresence.com', 'chat.digitaltelepresence.com', 'sites.digitaltelepresence.com', ]; try { const url = new URL(href); if (!whitelist.includes(url.hostname)) { await UIkit.modal.confirm(`

You are navigating to ${href}, a link or button that was displayed as:

${text}

Please only open links to destinations you trust and want to visit.
`); } window.open(href, hrefTarget); } catch (error) { this.log.info('confirmNavigation', 'navigation canceled', { error }); } return true; } async confirmRoomDelete (event) { const target = event.currentTarget || event.target; const roomId = target.getAttribute('data-room-id'); const roomName = target.getAttribute('data-room-name'); try { await UIkit.modal.confirm(`Are you sure you want to delete "${roomName}"?`); } catch (error) { return; } try { const response = await fetch(`/chat/room/${roomId}`, { method: 'DELETE' }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(error.message); } } async generateOtpQR (canvas, keyURI) { QRCode.toCanvas(canvas, keyURI); } async generateQRCanvas (canvas, uri) { this.log.info('generateQRCanvas', 'creating QR code canvas', { uri }); QRCode.toCanvas(canvas, uri, { width: 256 }); } async closeAllDropdowns ( ) { const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open'); for (const dropdown of dropdowns) { this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown }); UIkit.dropdown(dropdown).hide(false); } } async muteChatUser (event) { const target = (event.currentTarget || event.target); event.preventDefault(); event.stopPropagation(); this.closeAllDropdowns(); const messageId = target.getAttribute('data-message-id'); const userId = target.getAttribute('data-user-id'); const username = target.getAttribute('data-username'); try { await UIkit.modal.confirm(`Are you sure you want to mute ${username}?`); } catch (error) { // canceled or error return; } this.log.info('muteChatUser', 'muting chat user', { messageId, userId, username }); this.mutedUsers.push({ userId, username }); window.localStorage.mutedUsers = JSON.stringify(this.mutedUsers); document.querySelectorAll(`.chat-message[data-author-id="${userId}"]`).forEach((message) => { message.parentElement.removeChild(message); }); } async unmuteChatUser (event) { const target = (event.currentTarget || event.target); event.preventDefault(); event.stopPropagation(); const userId = target.getAttribute('data-user-id'); const username = target.getAttribute('data-username'); this.log.info('muteChatUser', 'muting chat user', { userId, username }); this.mutedUsers = this.mutedUsers.filter((block) => block.userId !== userId); window.localStorage.mutedUsers = JSON.stringify(this.mutedUsers); const entry = document.querySelector(`.chat-muted-user[data-user-id="${userId}"]`); if (!entry) { return; } entry.parentElement.removeChild(entry); } async filterChatView ( ) { if (!this.mutedUsers || (this.mutedUsers.length === 0)) { return; } this.mutedUsers.forEach((block) => { document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => { message.parentElement.removeChild(message); }); }); } async initSettingsView ( ) { this.log.info('initSettingsView', 'settings', { settings: this.settings }); const mutedUserList = document.querySelector('ul#muted-user-list'); for (const block of this.mutedUsers) { const li = document.createElement(`li`); li.setAttribute('data-user-id', block.userId); li.classList.add('chat-muted-user'); mutedUserList.appendChild(li); const grid = document.createElement('div'); grid.setAttribute('uk-grid', ''); grid.classList.add('uk-grid-small'); grid.classList.add('uk-flex-middle'); li.appendChild(grid); let column = document.createElement('div'); column.classList.add('uk-width-expand'); column.textContent = block.username; grid.appendChild(column); column = document.createElement('div'); column.classList.add('uk-width-auto'); grid.appendChild(column); const button = document.createElement('button'); button.setAttribute('type', 'button'); button.setAttribute('title', `Remove ${block.username} from your mute list`); button.setAttribute('data-user-id', block.userId); button.setAttribute('data-username', block.username); button.setAttribute('onclick', "return dtp.app.unmuteChatUser(event);"); button.classList.add('uk-button'); button.classList.add('uk-button-default'); button.classList.add('uk-button-small'); button.classList.add('uk-border-rounded'); button.textContent = 'Unmute'; column.appendChild(button); } } loadSettings ( ) { this.settings = { tutorials: { } }; if (window.localStorage) { if (window.localStorage.settings) { this.settings = JSON.parse(window.localStorage.settings); } else { this.saveSettings(); } this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ]; this.filterChatView(); } this.settings.tutorials = this.settings.tutorials || { }; } saveSettings ( ) { if (!window.localStorage) { return; } window.localStorage.settings = JSON.stringify(this.settings); } async submitImageForm (event) { event.preventDefault(); event.stopPropagation(); const formElement = event.currentTarget || event.target; const form = new FormData(formElement); this.cropper.getCroppedCanvas().toBlob(async (imageData) => { try { const imageId = formElement.getAttribute('data-image-id'); form.append('imageFile', imageData, imageId); this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); const response = await fetch(formElement.action, { method: formElement.method, body: form, }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to upload image: ${error.message}`); } }); return; } async selectImageFile (event) { event.preventDefault(); const imageId = event.target.getAttribute('data-image-id'); //z read the cropper options from the element on the page let cropperOptions = event.target.getAttribute('data-cropper-options'); if (cropperOptions) { cropperOptions = JSON.parse(cropperOptions); } this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); if (!fileSelectContainerId) { UIkit.modal.alert('Missing file select container element ID information'); return; } const fileSelectContainer = document.getElementById(fileSelectContainerId); if (!fileSelectContainer) { UIkit.modal.alert('Missing file select element'); return; } const fileSelect = fileSelectContainer.querySelector('input[type="file"]'); if (!fileSelect.files || (fileSelect.files.length === 0)) { return; } const selectedFile = fileSelect.files[0]; if (!selectedFile) { return; } this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile }); const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i; if (!filter.test(selectedFile.type)) { UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`); return; } const fileSizeId = event.target.getAttribute('data-file-size-element'); const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10); const fileSize = document.getElementById(fileSizeId); fileSize.textContent = numeral(selectedFile.size).format('0,0.0b'); if (selectedFile.size > (FILE_MAX_SIZE)) { UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`); return; } // const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w')); // const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h')); const reader = new FileReader(); reader.onload = (e) => { const img = document.getElementById(imageId); img.onload = (e) => { console.log('image loaded', e, img.naturalWidth, img.naturalHeight); // if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) { // UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`); // img.setAttribute('hidden', ''); // img.src = ''; // return; // } fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow(); fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString(); fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString(); fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true); fileSelectContainer.querySelector('#file-info').removeAttribute('hidden'); fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden'); }; // set the image as the "src" of the in the DOM. img.src = e.target.result; //z create cropper and set options here this.createImageCropper(img, cropperOptions); }; // read in the file, which will trigger everything else in the event handler above. reader.readAsDataURL(selectedFile); } async createImageCropper (img, options) { // https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options options = Object.assign({ aspectRatio: 1, viewMode: 1, // restrict the crop box not to exceed the size of the canvas dragMode: 'move', autoCropArea: 0.85, restore: false, guides: false, center: false, highlight: false, cropBoxMovable: true, cropBoxResizable: true, toggleDragModeOnDblclick: false, modal: true, }, options); this.log.info("createImageCropper", "Creating image cropper", { img }); this.cropper = new Cropper(img, options); } scrollChatToBottom (isAtBottom = true) { if (this.chat && this.chat.messageList && isAtBottom) { this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); setTimeout(( ) => { this.chat.isAtBottom = true; this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); this.chat.isModifying = false; }, 25); } } async onChatMessageListScroll (event) { const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight; if (!this.chat.isModifying) { this.chat.isAtBottom = (scrollPos >= (this.chat.messageList.scrollHeight - 10)); this.chat.isAtTop = (scrollPos <= 0); } if (event && (this.chat.isAtBottom || this.chat.isAtTop)) { event.preventDefault(); event.stopPropagation(); } if (this.chat.isAtBottom) { this.chat.messageMenu.classList.remove('chat-menu-visible'); } else { this.chat.messageMenu.classList.add('chat-menu-visible'); } } async resumeChatScroll ( ) { this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); this.chat.isAtBottom = true; this.chat.messageMenu.classList.remove('chat-menu-visible'); } async onWindowResize ( ) { if (this.chat.messageList && this.chat.isAtBottom) { this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); } } async showEmojiPicker (event) { const target = event.currentTarget || event.target; const emojiTargetSelector = target.getAttribute('data-target'); this.emojiPickerTarget = document.querySelector(emojiTargetSelector); if (!this.emojiPickerTarget) { UIkit.modal.alert('Invalid emoji picker target'); return; } this.emojiPickerDisplay.classList.add('picker-active'); } async onEmojiPickerClose (event) { if (!this.emojiPickerDisplay) { return; } if (!event.target.classList.contains('emoji-picker-display')) { return; } this.emojiPickerDisplay.classList.remove('picker-active'); } async onEmojiPicked (event) { event = event.detail; this.log.info('onEmojiPicked', 'An emoji has been selected', { event }); this.emojiPickerTarget.value += event.unicode; } }