// 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 QRCode from 'qrcode';
import Cropper from 'cropperjs';
import dayjs from 'dayjs';
export class ChatApp extends DtpApp {
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,
};
window.addEventListener('dtp-load', this.onDtpLoad.bind(this));
window.addEventListener('unload', this.onDtpUnload.bind(this));
}
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-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-control', this.onChatControl.bind(this));
socket.off('system-message', this.onSystemMessage.bind(this));
}
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');
const chatContent = document.createElement('div');
chatContent.classList.add('chat-content');
chatContent.classList.add('uk-text-break');
chatContent.innerHTML = message.content;
systemMessage.appendChild(chatContent);
const chatTimestamp = document.createElement('div');
chatTimestamp.classList.add('chat-timestamp');
chatTimestamp.classList.add('uk-text-small');
chatTimestamp.setAttribute('data-dtp-timestamp', message.created);
chatTimestamp.innerHTML = dayjs(message.created).format('hh:mm:ss a');
systemMessage.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 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 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);
}
}
}