// 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 SiteChat from './site-chat' ;
import SiteComments from './site-comments' ;
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 ) ;
if ( dtp . env === 'production' ) {
const isSafari = ! ! navigator . userAgent . match ( /Version\/[\d\.]+.*Safari/ ) ;
const isIOS = /iPad|iPhone|iPod/ . test ( navigator . userAgent ) && ! window . MSStream ;
this . isIOS = isSafari || isIOS ;
} else {
this . isIOS = false ;
}
this . log . debug ( 'constructor' , 'app instance created' , {
env : dtp . env ,
isIOS : this . isIOS ,
} ) ;
this . chat = new SiteChat ( this ) ;
this . comments = new SiteComments ( 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 ( 'system-message' , this . chat . appendSystemMessage . bind ( this . chat ) ) ;
socket . on ( 'user-chat' , this . chat . appendUserChat . bind ( this . chat ) ) ;
socket . on ( 'user-react' , this . chat . createEmojiReact . bind ( this . chat ) ) ;
}
}
async goBack ( ) {
if ( document . referrer && ( document . referrer . indexOf ( ` :// ${ window . dtp . domain } ` ) >= 0 ) ) {
window . history . back ( ) ;
} else {
window . location . href = '/' ;
}
return false ;
}
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 submitDialogForm ( event , userAction ) {
await this . submitForm ( event , userAction ) ;
await this . closeCurrentDialog ( ) ;
}
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 } ` ) ;
}
}
}
dtp . DtpSiteApp = DtpSiteApp ;