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.
556 lines
18 KiB
556 lines
18 KiB
// 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 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.currentSessionBillable = document.querySelector('#current-session-billable');
|
|
|
|
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.onChatSocketConnect.bind(this),
|
|
onSocketDisconnect: this.onChatSocketDisconnect.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 onChatSocketConnect (socket) {
|
|
this.log.debug('onSocketConnect', 'attaching socket events');
|
|
socket.on('system-message', this.onSystemMessage.bind(this));
|
|
|
|
if (dtp.task) {
|
|
await this.socket.joinChannel(dtp.task._id, 'Task');
|
|
}
|
|
}
|
|
|
|
async onChatSocketDisconnect (socket) {
|
|
this.log.debug('onSocketDisconnect', 'detaching socket events');
|
|
socket.off('system-message', this.onSystemMessage.bind(this));
|
|
}
|
|
|
|
async onSystemMessage (message) {
|
|
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 = [
|
|
'digitaltelepresence.com',
|
|
'www.digitaltelepresence.com',
|
|
'chat.digitaltelepresence.com',
|
|
'sites.digitaltelepresence.com',
|
|
];
|
|
try {
|
|
const url = new URL(href);
|
|
if (!whitelist.includes(url.hostname)) {
|
|
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`);
|
|
}
|
|
window.open(href, hrefTarget);
|
|
} catch (error) {
|
|
this.log.info('confirmNavigation', 'navigation canceled', { error });
|
|
}
|
|
|
|
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 <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) {
|
|
// 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), 1000 * 60 * 10);
|
|
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 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();
|
|
|
|
const tracks = this.captureStream.getVideoTracks();
|
|
const constraints = tracks[0].getSettings();
|
|
|
|
this.log.info('startScreenCapture', 'creating capture canvas', {
|
|
width: constraints.width,
|
|
height: constraints.height,
|
|
});
|
|
this.captureCanvas = document.createElement('canvas');
|
|
this.captureCanvas.width = constraints.width;
|
|
this.captureCanvas.height = constraints.height;
|
|
this.captureContext = this.captureCanvas.getContext('2d');
|
|
}
|
|
|
|
async stopScreenCapture ( ) {
|
|
if (!this.captureStream) {
|
|
return;
|
|
}
|
|
|
|
this.capturePreview.pause();
|
|
this.capturePreview.srcObject = null;
|
|
|
|
this.captureStream.getTracks().forEach(track => track.stop());
|
|
delete this.captureStream;
|
|
|
|
if (this.captureContext) {
|
|
delete this.captureContext;
|
|
}
|
|
if (this.captureCanvas) {
|
|
delete this.captureCanvas;
|
|
}
|
|
}
|
|
|
|
async updateSessionDisplay ( ) {
|
|
const NOW = new Date();
|
|
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second');
|
|
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS');
|
|
this.currentSessionBillable.textContent = numeral(this.taskSession.hourlyRate * (duration / 60 / 60)).format('$0,0.00');
|
|
}
|
|
|
|
async captureScreenshot ( ) {
|
|
if (!this.captureStream || !this.taskSession) {
|
|
return;
|
|
}
|
|
try {
|
|
/*
|
|
* Capture the current preview stream frame to the capture canvas
|
|
*/
|
|
this.captureContext.drawImage(
|
|
this.capturePreview,
|
|
0, 0,
|
|
this.captureCanvas.width,
|
|
this.captureCanvas.height,
|
|
);
|
|
/*
|
|
* Generate a PNG Blob from the capture canvas
|
|
*/
|
|
this.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',
|
|
1.0,
|
|
);
|
|
} catch (error) {
|
|
this.log.error('captureScreenshot', 'failed to capture screenshot', { error });
|
|
}
|
|
}
|
|
}
|