// site-app.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const DTP_COMPONENT = { name: 'Site App', slug: 'site-app' }; 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'; import { EmojiButton } from '@joeattardi/emoji-button'; const GRID_COLOR = 'rgb(64, 64, 64)'; const GRID_TICK_COLOR = 'rgb(192,192,192)'; const AXIS_TICK_COLOR = 'rgb(192, 192, 192)'; const CHART_LINE_USER = 'rgb(0, 192, 0)'; const CHART_FILL_USER = 'rgb(0, 128, 0)'; export default class DtpSiteApp extends DtpApp { constructor (user) { super(DTP_COMPONENT, 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, }; this.emojiPicker = new EmojiButton({ theme: 'dark' }); this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this)); 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)); } this.charts = {/* will hold rendered charts */}; this.scrollToHash(); } async scrollToHash ( ) { const { hash } = window.location; if (hash === '') { return; } const target = document.getElementById(hash.slice(1)); if (target && target.scrollIntoView) { target.scrollIntoView({ behavior: 'smooth' }); } } 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 goBack ( ) { if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) { window.history.back(); } else { window.location.href= '/'; } return false; } 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) { let json; try { json = await response.json(); } catch (error) { throw new Error('Server error'); } throw new Error(json.message || 'Server error'); } await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to ${userAction}: ${error.message}`); } return; } 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 { form.append('imageFile', imageData, 'profile.png'); this.log.info('submitImageForm', 'updating user settings', { event, action: formElement.action }); const response = await fetch(formElement.action, { method: formElement.method, body: form, }); if (!response.ok) { let json; try { json = await response.json(); } catch (error) { throw new Error('Server error'); } throw new Error(json.message || 'Server error'); } await this.processResponse(response); window.location.reload(); } catch (error) { UIkit.modal.alert(`Failed to update profile photo: ${error.message}`); } }); return; } async closeCurrentDialog ( ) { if (!this.currentDialog) { return; } this.currentDialog.hide(); delete this.currentDialog; } async copyHtmlToText (event, textContentId) { const content = this.editor.getContent({ format: 'text' }); const text = document.getElementById(textContentId); text.value = content; } 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 reader = new FileReader(); reader.onload = (e) => { const img = document.getElementById(imageId); img.onload = (e) => { console.log('image loaded', e, img.naturalWidth, img.naturalHeight); 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, cropperOptions); }; // read in the file, which will trigger everything else in the event handler above. reader.readAsDataURL(selectedFile); } async createImageCropper (img, options) { options = Object.assign({ aspectRatio: 1, 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); } 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 'profile-picture-file': response = await fetch(`/user/${this.user._id}/profile-photo`, { method: 'DELETE' }); break; default: throw new Error('Invalid image type'); } if (!response.ok) { throw new Error('Server error'); } await this.processResponse(response); window.location.reload(); } catch (error) { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } async onCommentInput (event) { const label = document.getElementById('comment-character-count'); label.textContent = numeral(event.target.value.length).format('0,0'); } async showEmojiPicker (event) { const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element'); this.emojiTargetElement = document.getElementById(targetElementName); this.emojiPicker.togglePicker(this.emojiTargetElement); } async onEmojiSelected (selection) { this.emojiTargetElement.value += selection.emoji; } async showReportCommentForm (event) { event.preventDefault(); event.stopPropagation(); const resourceType = event.currentTarget.getAttribute('data-resource-type'); const resourceId = event.currentTarget.getAttribute('data-resource-id'); const commentId = event.currentTarget.getAttribute('data-comment-id'); this.closeCommentDropdownMenu(commentId); try { const response = await fetch('/content-report/comment/form', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ resourceType, resourceId, commentId }), }); if (!response.ok) { throw new Error('failed to load report form'); } const html = await response.text(); this.currentDialog = UIkit.modal.dialog(html); } catch (error) { this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); UIkit.modal.alert(`Failed to report comment: ${error.message}`); } return true; } async deleteComment (event) { event.preventDefault(); event.stopPropagation(); const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); try { const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error('Server error'); } this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to delete comment: ${error.message}`); } } async submitDialogForm (event, userAction) { await this.submitForm(event, userAction); await this.closeCurrentDialog(); } async blockCommentAuthor (event) { event.preventDefault(); event.stopPropagation(); const resourceType = event.currentTarget.getAttribute('data-resource-type'); const resourceId = event.currentTarget.getAttribute('data-resource-id'); const commentId = event.currentTarget.getAttribute('data-comment-id'); const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); this.closeCommentDropdownMenu(commentId); try { this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); const response = await fetch(actionUrl, { method: 'POST'}); await this.processResponse(response); } catch (error) { this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); UIkit.modal.alert(`Failed to block comment author: ${error.message}`); } return true; } closeCommentDropdownMenu (commentId) { const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); UIkit.dropdown(dropdown).hide(false); } getCommentActionUrl (resourceType, resourceId, commentId, action) { switch (resourceType) { case 'Newsletter': return `/newsletter/${resourceId}/comment/${commentId}/${action}`; case 'Page': return `/page/${resourceId}/comment/${commentId}/${action}`; case 'Post': return `/post/${resourceId}/comment/${commentId}/${action}`; default: break; } throw new Error('Invalid resource type for comment operation'); } async submitCommentVote (event) { const target = (event.currentTarget || event.target); const commentId = target.getAttribute('data-comment-id'); const vote = target.getAttribute('data-vote'); try { const response = await fetch(`/comment/${commentId}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ vote }), }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to submit vote: ${error.message}`); } } async renderStatsGraph (selector, title, data) { try { const canvas = document.querySelector(selector); const ctx = canvas.getContext('2d'); this.charts.profileStats = new Chart(ctx, { type: 'bar', data: { labels: data.map((item) => new Date(item.date)), datasets: [ { label: title, data: data.map((item) => item.count), borderColor: CHART_LINE_USER, borderWidth: 1, backgroundColor: CHART_FILL_USER, tension: 0, }, ], }, options: { scales: { yAxis: { display: true, ticks: { color: AXIS_TICK_COLOR, callback: (value) => { return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0'); }, }, grid: { color: GRID_COLOR, tickColor: GRID_TICK_COLOR, }, }, x: { type: 'time', }, xAxis: { display: false, grid: { color: GRID_COLOR, tickColor: GRID_TICK_COLOR, }, }, }, plugins: { title: { display: false }, subtitle: { display: false }, legend: { display: false }, }, maintainAspectRatio: true, aspectRatio: 16.0 / 9.0, onResize: (chart, event) => { if (event.width >= 960) { chart.config.options.aspectRatio = 16.0 / 5.0; } else if (event.width >= 640) { chart.config.options.aspectRatio = 16.0 / 9.0; } else if (event.width >= 480) { chart.config.options.aspectRatio = 16.0 / 12.0; } else { chart.config.options.aspectRatio = 16.0 / 16.0; } }, }, }); } catch (error) { this.log.error('renderStatsGraph', 'failed to render stats graph', { title, error }); UIkit.modal.alert(`Failed to render chart: ${error.message}`); } } async openReplies (event) { event.preventDefault(); event.stopPropagation(); const target = event.currentTarget || event.target; const commentId = target.getAttribute('data-comment-id'); const container = document.querySelector(`.dtp-reply-list-container[data-comment-id="${commentId}"]`); const replyList = document.querySelector(`ul.dtp-reply-list[data-comment-id="${commentId}"]`); const isOpen = !container.hasAttribute('hidden'); if (isOpen) { container.setAttribute('hidden', ''); while (replyList.firstChild) { replyList.removeChild(replyList.firstChild); } return; } try { const response = await fetch(`/comment/${commentId}/replies`); this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to load replies: ${error.message}`); } return true; } async openReplyComposer (event) { event.preventDefault(); event.stopPropagation(); const target = event.currentTarget || event.target; const commentId = target.getAttribute('data-comment-id'); const composer = document.querySelector(`.dtp-reply-composer[data-comment-id="${commentId}"]`); composer.toggleAttribute('hidden'); return true; } async loadMoreComments (event) { event.preventDefault(); event.stopPropagation(); const target = event.currentTarget || event.target; const buttonId = target.getAttribute('data-button-id'); const rootUrl = target.getAttribute('data-root-url'); const nextPage = target.getAttribute('data-next-page'); try { const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to load more comments: ${error.message}`); } } } dtp.DtpSiteApp = DtpSiteApp;