// site-app.js // Copyright (C) 2021 Digital Telepresence, LLC // License: Apache-2.0 'use strict'; const DTP_COMPONENT_NAME = 'SiteApp'; const dtp = window.dtp = window.dtp || { }; import DtpApp from 'dtp/dtp-app.js'; import UIkit from 'uikit'; import QRCode from 'qrcode'; import Cropper from 'cropperjs'; export default class DtpSiteApp extends DtpApp { constructor (user) { super(DTP_COMPONENT_NAME, user); this.log.debug('constructor', 'app instance created'); 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'), isAtBottom: true, }; if (this.chat.messageList) { this.chat.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); } if (this.chat.input) { this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); } } async connect ( ) { // can't use "super" because Webpack this.log.info('connect', 'connecting WebSocket layer'); await DtpApp.prototype.connect.call(this, { withRetry: true, withError: false }); if (this.user) { const { socket } = this.socket; socket.on('user-chat', this.onUserChat.bind(this)); } } async onChatInputKeyDown (event) { this.log.info('onChatInputKeyDown', 'chat input received', { event }); if (event.key === 'Enter' && !event.shiftKey) { return this.sendUserChat(event); } } async sendUserChat (event) { event.preventDefault(); if (!dtp.channel || !dtp.channel._id) { UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); return; } const channelId = dtp.channel._id; this.log.info('chat form', channelId); const content = this.chat.input.value; this.chat.input.value = ''; if (content.length === 0) { return true; } this.log.info('sendUserChat', 'sending chat message', { channel: this.user._id, content }); this.socket.sendUserChat(channelId, content); // set focus back to chat input this.chat.input.focus(); return true; } async onUserChat (message) { this.log.info('onUserChat', 'message received', { user: message.user, content: message.content }); const chatMessage = document.createElement('div'); chatMessage.classList.add('uk-margin-small'); chatMessage.classList.add('chat-message'); const chatUser = document.createElement('div'); chatUser.classList.add('uk-text-small'); chatUser.classList.add('chat-username'); chatUser.textContent = message.user.username; chatMessage.appendChild(chatUser); const chatContent = document.createElement('div'); chatContent.classList.add('chat-content'); chatContent.innerHTML = message.content; chatMessage.appendChild(chatContent); if (Array.isArray(message.stickers) && message.stickers.length) { message.stickers.forEach((sticker) => { const chatContent = document.createElement('div'); chatContent.classList.add('chat-sticker'); chatContent.innerHTML = ``; chatMessage.appendChild(chatContent); }); } this.chat.messageList.appendChild(chatMessage); this.chat.messages.push(chatMessage); 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); } } async onChatMessageListScroll (/* event */) { const prevBottom = this.chat.isAtBottom; const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight; this.chat.isAtBottom = scrollPos >= this.chat.messageList.scrollHeight; if (this.chat.isAtBottom !== prevBottom) { this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.chat.isAtBottom }); 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.scrollTop = this.chat.messageList.scrollHeight; } async submitForm (event, userAction) { event.preventDefault(); event.stopPropagation(); try { const formElement = event.currentTarget || event.target; const form = new FormData(formElement); this.log.info('submitForm', userAction, { event, action: formElement.action }); const response = await fetch(formElement.action, { method: formElement.method, body: form, }); if (!response.ok) { throw new Error('Server error'); } await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to ${userAction}: ${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 const cropperOptions = event.target.getAttribute('data-cropper-options'); 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 = moment(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); }; // read in the file, which will trigger everything else in the event handler above. reader.readAsDataURL(selectedFile); } async createImageCropper (img) { this.log.info("createImageCropper", "Creating image cropper", { img }); this.cropper = new Cropper(img, { aspectRatio: 1, dragMode: 'move', autoCropArea: 0.85, restore: false, guides: false, center: false, highlight: false, cropBoxMovable: false, cropBoxResizable: false, toggleDragModeOnDblclick: false, modal: true, }); } async attachTinyMCE (editor) { editor.on('KeyDown', async (e) => { if (dtp.autosaveTimeout) { window.clearTimeout(dtp.autosaveTimeout); delete dtp.autosaveTimeout; } dtp.autosaveTimeout = window.setTimeout(async ( ) => { console.log('document autosave'); }, 1000); if ((e.keyCode === 8 || e.keyCode === 46) && editor.selection) { var selectedNode = editor.selection.getNode(); if (selectedNode && selectedNode.nodeName === 'IMG') { console.log('removing image', selectedNode); await dtp.app.deleteImage(selectedNode.src.slice(-24)); } } }); } async deleteImage (imageId) { try { throw new Error(`would want to delete /image/${imageId}`); } catch (error) { UIkit.modal.alert(error.message); } } async openPaymentModal ( ) { await UIkit.modal('#donate-modal').show(); await UIkit.slider('#payment-slider').show(0); } async generateQRCode (event) { const selectorQR = event.target.getAttribute('data-selector-qr'); const selectorPrompt = event.target.getAttribute('data-selector-prompt'); const targetAmount = event.target.getAttribute('data-amount'); const amountLabel = numeral(targetAmount).format('$0,0.00'); const currencyAmountLabel = numeral(targetAmount * this.exchangeRates[this.paymentCurrency].conversionRateUSD).format('0,0.0000000000000000'); const prompt = `Donate ${amountLabel} using ${this.paymentCurrencyLabel}.`; event.preventDefault(); let targetUrl; switch (this.paymentCurrency) { case 'BTC': targetUrl = `bitcoin:${dtp.channel.wallet.btc}?amount=${targetAmount}&message=Donation to ${dtp.channel.name}`; break; case 'ETH': targetUrl = `ethereum:${dtp.channel.wallet.eth}?amount=${targetAmount}`; break; case 'LTC': targetUrl = `litecoin:${dtp.channel.wallet.ltc}?amount=${targetAmount}&message=Donation to ${dtp.channel.name}`; break; } try { let elements; const imageUrl = await QRCode.toDataURL(targetUrl); elements = document.querySelectorAll(selectorQR); elements.forEach((element) => element.setAttribute('src', imageUrl)); elements = document.querySelectorAll(selectorPrompt); elements.forEach((e) => e.textContent = prompt); elements = document.querySelectorAll('span.prompt-donate-amount'); elements.forEach((element) => { element.textContent = amountLabel; }); elements = document.querySelectorAll('span.prompt-donate-amount-crypto'); elements.forEach((element) => { element.textContent = currencyAmountLabel; }); elements = document.querySelectorAll('a.payment-target-link'); elements.forEach((element) => { element.setAttribute('href', targetUrl); }); const e = document.getElementById('payment-link'); e.setAttribute('href', targetUrl); UIkit.slider('#payment-slider').show(2); } catch (error) { this.log.error('failed to generate QR code to image', { error }); UIkit.modal.alert(`Failed to generate QR code: ${error.message}`); } return true; } async setPaymentCurrency (currency) { this.paymentCurrency = currency; this.log.info('setPaymentCurrency', 'payment currency', { currency: this.paymentCurrency }); await this.updateExchangeRates(); switch (this.paymentCurrency) { case 'BTC': this.paymentCurrencyLabel = 'Bitcoin (BTC)'; break; case 'ETH': this.paymentCurrencyLabel = 'Ethereum (ETH)'; break; case 'LTC': this.paymentCurrencyLabel = 'Litecoin (LTC)'; break; } let elements = document.querySelectorAll('span.prompt-donate-currency'); elements.forEach((element) => { element.textContent = this.paymentCurrencyLabel; }); UIkit.slider('#payment-slider').show(1); } async updateExchangeRates ( ) { const NOW = Date.now(); try { let exchangeRates; if (!window.localStorage.exchangeRates) { exchangeRates = await this.loadExchangeRates(); this.log.info('updateExchangeRates', 'current exchange rates received and cached', { exchangeRates }); window.localStorage.exchangesRates = JSON.stringify(exchangeRates); } else { exchangeRates = JSON.parse(window.localStorage.exchangeRates); if (exchangeRates.timestamp < (NOW - 60000)) { exchangeRates = await this.loadExchangeRates(); this.log.info('updateExchangeRates', 'current exchange rates received and cached', { exchangeRates }); window.localStorage.exchangesRates = JSON.stringify(exchangeRates); } } this.exchangeRates = exchangeRates.symbols; } catch (error) { this.log.error('updateExchangeRates', 'failed to fetch currency exchange rates', { error }); UIkit.modal.alert(`Failed to fetch current exchange rates: ${error.message}`); } } async loadExchangeRates ( ) { this.log.info('loadExchangeRates', 'fetching current exchange rates'); const response = await fetch('/crypto-exchange/current-rates'); if (!response.ok) { throw new Error('Server error'); } let exchangeRates = await response.json(); if (!exchangeRates.success) { throw new Error(exchangeRates.message); } exchangeRates.timestamp = Date.now(); return exchangeRates; } async generateOtpQR (canvas, keyURI) { QRCode.toCanvas(canvas, keyURI); } async removeImageFile (event) { const imageType = (event.target || event.currentTarget).getAttribute('data-image-type'); try { this.log.info('removeImageFile', 'request to remove image', event); let response; switch (imageType) { case 'channel-app-icon': const channelId = (event.target || event.currentTarget).getAttribute('data-channel-id'); response = await fetch(`/channel/${channelId}/app-icon`, { method: 'DELETE', }); break; default: throw new Error('Invalid image type'); } if (!response.ok) { throw new Error('Server error'); } await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } } dtp.DtpSiteApp = DtpSiteApp;