// 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_ROOM_CONNECT ( ) { return 'chat-room-connect'; } static get SFX_CHAT_MESSAGE ( ) { return 'chat-message'; } static get SFX_CHAT_REACTION ( ) { return 'reaction'; } static get SFX_CHAT_REACTION_REMOVE ( ) { return 'reaction-remove'; } static get SFX_CHAT_MESSAGE_REMOVE ( ) { return 'message-remove'; } constructor (user) { super(DTP_COMPONENT_NAME, user); this.loadSettings(); this.log.info('constructor', 'DTP app client online'); this.notificationPermission = 'default'; this.haveFocus = true; // hard to load the app w/o also being the focused app 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'), imageFiles: document.querySelector('#image-files'), videoFile: document.querySelector('#video-file'), 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(); } 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)); } this.dragFeedback = document.querySelector('.dtp-drop-feedback'); window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); window.addEventListener('unload', this.onDtpUnload.bind(this)); window.addEventListener('focus', this.onWindowFocus.bind(this)); window.addEventListener('blur', this.onWindowBlur.bind(this)); this.updateTimestamps(); } async onWindowFocus (event) { this.log.debug('onWindowFocus', 'window has received focus', { event }); this.haveFocus = true; } async onWindowBlur (event) { this.log.debug('onWindowBlur', 'window has lost focus', { event }); this.haveFocus = false; } async startAudio ( ) { this.log.info('startAudio', 'starting ChatAudio'); this.audio = new ChatAudio(); this.audio.start(); try { await Promise.all([ this.audio.loadSound(ChatApp.SFX_CHAT_ROOM_CONNECT, '/static/sfx/room-connect.mp3'), this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE, '/static/sfx/chat-message.mp3'), this.audio.loadSound(ChatApp.SFX_CHAT_REACTION, '/static/sfx/reaction.mp3'), this.audio.loadSound(ChatApp.SFX_CHAT_REACTION_REMOVE, '/static/sfx/reaction-remove.mp3'), this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE_REMOVE, '/static/sfx/message-deleted.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 onDragEnter (event) { event.preventDefault(); event.stopPropagation(); this.log.info('onDragEnter', 'something being dragged has entered the stage', { event }); this.dragFeedback.classList.add('feedback-active'); } async onDragLeave (event) { event.preventDefault(); event.stopPropagation(); this.log.info('onDragLeave', 'something being dragged has left the stage', { event }); this.dragFeedback.classList.remove('feedback-active'); } async onDragOver (event) { /* * Inform that we want "copy" as a drop effect and prevent all default * processing so we'll actually get the files in the drop event. If this * isn't done, you simply won't get the files in the drop. */ event.preventDefault(); event.stopPropagation(); // this ends now! event.dataTransfer.dropEffect = 'copy'; // this.log.info('onDragOver', 'something was dragged over the stage', { event }); this.dragFeedback.classList.add('feedback-active'); } async onDrop (event) { event.preventDefault(); event.stopPropagation(); for (const file of event.dataTransfer.files) { this.log.info('onDrop', 'a file has been dropped', { file }); } this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files }); this.dragFeedback.classList.remove('feedback-active'); } 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) && (!this.chat.imageFiles.value) && (!this.chat.videoFile.value)) { 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.imageFiles.value = null; this.chat.videoFile = null; this.chat.input.focus(); this.chat.sendButton.setAttribute('disabled', ''); this.chatTimeout = setTimeout(( ) => { delete this.chatTimeout; this.chat.sendButton.removeAttribute('disabled'); }, 1000); return true; } async deleteChatMessage (event) { const target = event.currentTarget || event.target; const messageId = target.getAttribute('data-message-id'); event.preventDefault(); event.stopPropagation(); try { const response = await fetch(`/chat/message/${messageId}`, { method: 'DELETE' }); await this.processResponse(response); } catch (error) { this.log.error('deleteChatMessage', 'failed to delete chat message', { error }); UIkit.modal.alert(`Failed to delete chat message: ${error.message}`); } } async toggleMessageReaction (event) { const target = event.currentTarget || event.target; const messageId = target.getAttribute('data-message-id'); const emoji = target.getAttribute('data-emoji'); try { const response = await fetch(`/chat/message/${messageId}/reaction`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ emoji }), }); await this.processResponse(response); } catch (error) { this.log.error('toggleMessageReaction', 'failed to send emoji react', { error }); UIkit.modal.alert(`Failed to send emoji react: ${error.message}`); } } async onDtpLoad ( ) { this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.'); await this.connect({ mode: 'User', onSocketConnect: this.onChatSocketConnect.bind(this), onSocketDisconnect: this.onChatSocketDisconnect.bind(this), }); if (this.chat.messageList) { try { this.notificationPermission = await Notification.requestPermission(); this.log.debug('onDtpLoad', 'Notification permission status', { permission: this.notificationPermission }); } catch (error) { this.log.error('onDtpLoad', 'failed to request Notification permission', { error }); } } } 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.chat.messageList.insertAdjacentHTML('beforeend', message.html); this.audio.playSound(ChatApp.SFX_CHAT_MESSAGE); this.scrollChatToBottom(isAtBottom); if (!this.haveFocus && (this.notificationPermission === 'granted')) { const chatMessage = message.message; new Notification(chatMessage.channel.name, { body: `Message received from ${chatMessage.author.displayName || chatMessage.author.username}`, }); } } async onChatControl (message) { const isAtBottom = this.chat.isAtBottom; if (message.audio) { if (message.audio.playSound) { this.audio.playSound(message.audio.playSound); } } 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}