The Digital Telepresence Platform core implementing user account management, authentication, search, global directory, and other platform-wide services. https://digitaltelepresence.com/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

805 lines
26 KiB

// 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 = `<video playsinline autoplay muted loop><source src="/sticker/${sticker}.mp4"></source></video>`;
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 <img> 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;