// time-tracker-client.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; const DTP_COMPONENT_NAME = 'TimeTrackerApp'; const dtp = window.dtp = window.dtp || { }; import DtpApp from 'lib/dtp-app.js'; import TimeTrackerAudio from './time-tracker-audio.js'; import QRCode from 'qrcode'; import Cropper from 'cropperjs'; import dayjs from 'dayjs'; import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; dayjs.extend(dayjsRelativeTime); export class TimeTrackerApp extends DtpApp { static get SCREENSHOT_INTERVAL ( ) { return 1000 * 60 * 10; } static get SFX_TRACKER_START ( ) { return 'tracker-start'; } static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; } static get SFX_TRACKER_STOP ( ) { return 'tracker-stop'; } constructor (user) { super(DTP_COMPONENT_NAME, user); this.loadSettings(); this.log.info('constructor', 'TimeTrackerApp client online'); this.notificationPermission = 'default'; this.haveFocus = true; // hard to load the app w/o also being the focused app this.capturePreview = document.querySelector('video#capture-preview'); this.dragFeedback = document.querySelector('.dtp-drop-feedback'); this.currentSessionStartTime = null; this.currentSessionDuration = document.querySelector('#current-session-duration'); this.currentSessionTimeRemaining = document.querySelector('#time-remaining'); window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); window.addEventListener('focus', this.onWindowFocus.bind(this)); window.addEventListener('blur', this.onWindowBlur.bind(this)); /* * Page Visibility API hooks */ window.addEventListener('pagehide', this.onWindowPageHide.bind(this)); window.addEventListener('freeze', this.onWindowFreeze.bind(this)); window.addEventListener('resume', this.onWindowResume.bind(this)); this.updateTimestamps(); } async onDtpLoad ( ) { this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.'); await this.connect({ mode: 'User', onSocketConnect: this.onTrackerSocketConnect.bind(this), onSocketDisconnect: this.onTrackerSocketDisconnect.bind(this), }); } async onWindowFocus (event) { this.log.debug('onWindowFocus', 'window has received focus', { event }); this.haveFocus = true; } async onWindowBlur (event) { this.log.debug('onWindowBlur', 'window has lost focus', { event }); this.haveFocus = false; } async onWindowPageHide (event) { this.log.debug('onWindowPageHide', 'the page is being hidden', { event }); await this.socket.disconnect(); } async onWindowFreeze (event) { this.log.debug('onWindowFreeze', 'the page is being frozen', { event }); } async onWindowResume (event) { this.log.debug('onWindowResume', 'the page is being resumed', { event }); } async startAudio ( ) { this.log.info('startAudio', 'starting audio'); this.audio = new TimeTrackerAudio(); this.audio.start(); try { await Promise.all([ this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_START, '/static/sfx/tracker-start.mp3'), this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_UPDATE, '/static/sfx/tracker-update.mp3'), this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_STOP, '/static/sfx/tracker-stop.mp3'), ]); } catch (error) { this.log.error('startAudio', 'failed to load sound', { error }); // fall through } } async onDragEnter (event) { event.preventDefault(); event.stopPropagation(); this.log.info('onDragEnter', 'something being dragged has entered the stage', { event }); this.dragFeedback.classList.add('feedback-active'); } async onDragLeave (event) { event.preventDefault(); event.stopPropagation(); this.log.info('onDragLeave', 'something being dragged has left the stage', { event }); this.dragFeedback.classList.remove('feedback-active'); } async onDragOver (event) { /* * Inform that we want "copy" as a drop effect and prevent all default * processing so we'll actually get the files in the drop event. If this * isn't done, you simply won't get the files in the drop. */ event.preventDefault(); event.stopPropagation(); // this ends now! event.dataTransfer.dropEffect = 'copy'; // this.log.info('onDragOver', 'something was dragged over the stage', { event }); this.dragFeedback.classList.add('feedback-active'); } async onDrop (event) { event.preventDefault(); event.stopPropagation(); for (const file of event.dataTransfer.files) { this.log.info('onDrop', 'a file has been dropped', { file }); } this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files }); this.dragFeedback.classList.remove('feedback-active'); } async onTrackerSocketConnect (socket) { this.log.debug('onSocketConnect', 'attaching socket events'); this.systemMessageHandler = this.onSystemMessage.bind(this); socket.on('system-message', this.systemMessageHandler); this.sessionControlHandler = this.onSessionControl.bind(this); socket.on('session-control', this.sessionControlHandler); if (dtp.task) { await this.socket.joinChannel(dtp.task._id, 'Task'); } if (this.taskSession) { await this.setTaskSessionStatus('active'); } } async onTrackerSocketDisconnect (socket) { this.log.debug('onSocketDisconnect', 'detaching socket events'); socket.off('session-control', this.sessionControlHandler); delete this.sessionControlHandler; socket.off('system-message', this.systemMessageHandler); delete this.systemMessageHandler; if (this.taskSession) { await this.setTaskSessionStatus('reconnecting'); } } async onSystemMessage (message) { if (message.displayList) { this.displayEngine.executeDisplayList(message.displayList); } } async onSessionControl (message) { const activityToggle = document.querySelector(''); if (message.cmd) { switch (message.cmd) { case 'end-session': try { await this.closeTaskSession(); activityToggle.checked = false; } catch (error) { this.log.error('onSessionControl', 'failed to close task work session', { error }); return; } break; default: this.log.error('onSessionControl', 'invalid command received', { cmd: message.cmd }); return; } } if (message.displayList) { this.displayEngine.executeDisplayList(message.displayList); } } 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 = [ 'chat.digitaltelepresence.com', 'digitaltelepresence.com', 'sites.digitaltelepresence.com', 'tracker.digitaltelepresence.com', 'www.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 performSessionNavigation (event) { const target = event.currentTarget || event.target; event.preventDefault(); event.stopPropagation(); const href = target.getAttribute('href'); const hrefTarget = target.getAttribute('target'); if (this.taskSession || (hrefTarget && (hrefTarget.length > 0))) { return window.open(href, hrefTarget); } window.location = href; 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 initSettingsView ( ) { this.log.info('initSettingsView', 'settings', { settings: this.settings }); } loadSettings ( ) { this.settings = { }; if (window.localStorage) { if (window.localStorage.settings) { this.settings = JSON.parse(window.localStorage.settings); } else { this.saveSettings(); } } } saveSettings ( ) { if (!window.localStorage) { return; } window.localStorage.settings = JSON.stringify(this.settings); } 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 { const imageId = formElement.getAttribute('data-image-id'); form.append('imageFile', imageData, imageId); this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); const response = await fetch(formElement.action, { method: formElement.method, body: form, }); await this.processResponse(response); } catch (error) { UIkit.modal.alert(`Failed to upload image: ${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 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); } async removeImageFile (event) { const target = event.target || event.currentTarget; const imageType = target.getAttribute('data-image-type'); const channelId = dtp.channel ? dtp.channel._id : dtp.channel; try { this.log.info('removeImageFile', 'request to remove image', event); let imageUrl; switch (imageType) { case 'channel-thumbnail-file': imageUrl = `/channel/${channelId}/thumbnail`; break; case 'profile-picture-file': imageUrl = `/user/${this.user._id}/profile-photo`; break; default: throw new Error(`Invalid image type: ${imageType}`); } const response = await fetch(imageUrl, { method: 'DELETE' }); if (!response.ok) { throw new Error('Server error'); } await this.processResponse(response); } catch (error) { this.log.error('removeImageFile', 'failed to remove image', { error }); UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } async onWindowResize ( ) { if (this.chat.messageList && this.chat.isAtBottom) { this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); } } async taskActivityToggle (event) { const target = event.currentTarget || event.target; event.preventDefault(); event.stopPropagation(); try { if (target.checked) { await this.startScreenCapture(); return this.startTaskSession(); } await this.stopScreenCapture(); this.closeTaskSession(); } catch (error) { if (target.checked) { target.checked = false; } this.log.error('taskActivityToggle', 'failed to start task work session', { error }); UIkit.modal.alert(`Failed to start work session: ${error.message}`); } } async startTaskSession ( ) { try { const url = `/task/${dtp.task._id}/session/start`; const response = await fetch(url, { method: 'POST' }); await this.checkResponse(response); const json = await response.json(); if (!json.success) { throw new Error(json.message); } this.taskSession = json.session; this.currentSessionStartTime = new Date(); this.screenshotInterval = setInterval(this.captureScreenshot.bind(this), TimeTrackerApp.SCREENSHOT_INTERVAL); this.sessionDisplayUpdateInterval = setInterval(this.updateSessionDisplay.bind(this), 250); this.currentSessionDuration.classList.add('uk-text-success'); this.log.info('startTaskSession', 'task session started', { session: this.taskSession }); } catch (error) { this.log.error('startTaskSession', 'failed to start task session', { error }); UIkit.modal.alert(`Failed to start task session: ${error.message}`); throw new Error('failed to start task session', { cause: error }); } } async setTaskSessionStatus (status) { try { const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/status`; const body = JSON.stringify({ status }); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': body.length, }, body, }); await this.processResponse(response); this.taskSession.status = status; } catch (error) { UIkit.notification({ message: `Failed to update task session status: ${error.message}`, status: 'danger', pos: 'bottom-center', timeout: 5000, }); } } async closeTaskSession ( ) { try { const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`; const response = await fetch(url , { method: 'POST' }); await this.processResponse(response); clearInterval(this.sessionDisplayUpdateInterval); delete this.sessionDisplayUpdateInterval; clearInterval(this.screenshotInterval); delete this.screenshotInterval; this.currentSessionDuration.classList.remove('uk-text-success'); delete this.currentSessionStartTime; } catch (error) { this.log.error('closeTaskSession', 'failed to close task session', { session: this.taskSession, error, }); UIkit.modal.alert(`Failed to start task session: ${error.message}`); throw new Error('failed to close task session', { cause: error }); } } async startScreenCapture ( ) { this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }); this.capturePreview.srcObject = this.captureStream; this.capturePreview.play(); } async stopScreenCapture ( ) { if (!this.captureStream) { return; } this.capturePreview.pause(); this.capturePreview.srcObject = null; this.captureStream.getTracks().forEach(track => track.stop()); delete this.captureStream; } async updateSessionDisplay ( ) { if (this.taskSession.status === 'reconnecting') { this.currentSessionDuration.textContent = '---'; this.currentSessionTimeRemaining.textContent = '---'; return; } const NOW = new Date(); const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second'); this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS'); const timeRemaining = this.taskSession.client.hoursLimit - ((this.taskSession.client.weeklyTotals.timeWorked + duration) / 3600) ; this.currentSessionTimeRemaining.textContent = numeral(timeRemaining).format('0,0.00'); } async captureScreenshot ( ) { if (!this.captureStream || !this.taskSession) { return; } try { const tracks = this.captureStream.getVideoTracks(); const constraints = tracks[0].getSettings(); this.log.info('startScreenCapture', 'creating capture canvas', { width: constraints.width, height: constraints.height, }); const captureCanvas = document.createElement('canvas'); captureCanvas.width = constraints.width; captureCanvas.height = constraints.height; const captureContext = captureCanvas.getContext('2d'); /* * Capture the current preview stream frame to the capture canvas */ captureContext.drawImage( this.capturePreview, 0, 0, captureCanvas.width, captureCanvas.height, ); /* * Generate a PNG Blob from the capture canvas */ captureCanvas.toBlob( async (blob) => { const formData = new FormData(); formData.append('image', blob, 'screenshot.png'); const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/screenshot`; const response = await fetch(url, { method: 'POST', body: formData, }); await this.processResponse(response); this.log.info('captureScreenshot', 'screenshot posted to task session'); }, 'image/png', ); } catch (error) { this.log.error('captureScreenshot', 'failed to capture screenshot', { error }); } } }