@ -1,2 +1,2 @@ |
|||
DTP Chat Copyright (C) 2024 DTP Technologies, LLC |
|||
DTP Time Tracker Copyright (C) 2024 DTP Technologies, LLC |
|||
All Rights Reserved |
|||
|
@ -1,393 +0,0 @@ |
|||
// auth.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class ChatController extends SiteController { |
|||
|
|||
static get name ( ) { return 'ChatController'; } |
|||
static get slug ( ) { return 'chat'; } |
|||
|
|||
static get MESSAGES_PER_PAGE ( ) { return 20; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ChatController); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { |
|||
// csrfToken: csrfTokenService,
|
|||
// limiter: limiterService,
|
|||
session: sessionService, |
|||
} = this.dtp.services; |
|||
|
|||
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true }); |
|||
const multer = this.createMulter(ChatController.slug); |
|||
|
|||
async function requireRoomOwner (req, res, next) { |
|||
if (!req.user) { |
|||
return next(new SiteError(403, 'Must be logged in to proceed')); |
|||
} |
|||
if (!res.locals.room) { |
|||
return next(new SiteError(403, 'Room not found')); |
|||
} |
|||
if (!res.locals.room.owner._id.equals(req.user._id)) { |
|||
return next(new SiteError(403, 'This is not your room')); |
|||
} |
|||
|
|||
return next(); |
|||
} |
|||
|
|||
const router = express.Router(); |
|||
this.dtp.app.use('/chat', router); |
|||
|
|||
router.use( |
|||
async (req, res, next) => { |
|||
res.locals.currentView = 'auth'; |
|||
return next(); |
|||
}, |
|||
authRequired, |
|||
); |
|||
|
|||
router.param('roomId', this.populateRoomId.bind(this)); |
|||
router.param('messageId', this.populateMessageId.bind(this)); |
|||
router.param('inviteId', this.populateInviteId.bind(this)); |
|||
|
|||
router.post( |
|||
'/room/:roomId/message', |
|||
// limiterService.create(limiterService.config.chat.postRoomMessage),
|
|||
multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFiles', maxCount: 1 }]), |
|||
this.postRoomMessage.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room/:roomId/invite/:inviteId', |
|||
// limiterService.create(limiterService.config.chat.postRoomInviteAction),
|
|||
multer.none(), |
|||
this.postRoomInviteAction.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room/:roomId/invite', |
|||
// limiterService.create(limiterService.config.chat.postRoomInvite),
|
|||
multer.none(), |
|||
this.postRoomInvite.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room/:roomId/settings', |
|||
requireRoomOwner, |
|||
// limiterService.create(limiterService.config.chat.postRoomSettings),
|
|||
this.postRoomSettings.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room', |
|||
// limiterService.create(limiterService.config.chat.postCreateRoom),
|
|||
this.postCreateRoom.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/message/:messageId/reaction', |
|||
// limiterService.create(limiterService.config.chat.postMessageReaction),
|
|||
multer.none(), |
|||
this.postMessageReaction.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/create', |
|||
// limiterService.create(limiterService.config.chat.getRoomCreateView),
|
|||
this.getRoomCreateView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/invite', |
|||
// limiterService.create(limiterService.config.chat.getRoomInviteForm),
|
|||
this.getRoomInviteForm.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/join', |
|||
// limiterService.create(limiterService.config.chat.getRoomJoinView),
|
|||
this.getRoomJoinView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/messages', |
|||
// limiterService.create(limiterService.config.chat.getRoomMessages),
|
|||
this.getRoomMessages.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/settings', |
|||
// limiterService.create(limiterService.config.chat.getRoomMessages),
|
|||
requireRoomOwner, |
|||
this.getRoomSettingsView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId', |
|||
// limiterService.create(limiterService.config.chat.getRoomView),
|
|||
this.getRoomView.bind(this), |
|||
); |
|||
|
|||
router.delete( |
|||
'/message/:messageId', |
|||
// limiterService.create(limiterService.config.chat.deleteChatMessage),
|
|||
this.deleteChatMessage.bind(this), |
|||
); |
|||
|
|||
router.delete( |
|||
'/room/:roomId', |
|||
// limiterService.create(limiterService.config.chat.deleteRoom),
|
|||
this.deleteRoom.bind(this), |
|||
); |
|||
|
|||
return router; |
|||
} |
|||
|
|||
async populateRoomId (req, res, next, roomId) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.room = await chatService.getRoomById(roomId); |
|||
if (!res.locals.room) { |
|||
throw new SiteError(404, "The chat room doesn't exist."); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async populateMessageId (req, res, next, messageId) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.message = await chatService.getMessageById(messageId); |
|||
if (!res.locals.message) { |
|||
throw new SiteError(404, "The chat message doesn't exist."); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async populateInviteId (req, res, next, inviteId) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.invite = await chatService.getInviteById(inviteId); |
|||
if (!res.locals.invite) { |
|||
throw new SiteError(404, "The chat room invite doesn't exist."); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postMessageReaction (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.checkRoomMember(res.locals.message.channel, req.user); |
|||
await chatService.toggleMessageReaction(req.user, res.locals.message, req.body); |
|||
return res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to send chat room message', { error }); |
|||
return res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postRoomMessage (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.checkRoomMember(res.locals.room, req.user); |
|||
|
|||
await chatService.sendRoomMessage( |
|||
res.locals.room, |
|||
req.user, |
|||
req.body, |
|||
req.files.imageFiles, |
|||
req.files.videoFiles, |
|||
); |
|||
|
|||
return res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to send chat room message', { error }); |
|||
return res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postRoomInviteAction (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
this.log.info('processing invite action', { |
|||
inviteId: res.locals.invite._id, |
|||
action: req.body.action, |
|||
}); |
|||
await chatService.processRoomInvite( |
|||
req.user, |
|||
res.locals.invite, |
|||
req.body.action, |
|||
); |
|||
|
|||
const displayList = this.createDisplayList('invite-result'); |
|||
displayList.removeElement(`li[data-invite-id="${res.locals.invite._id}"]`); |
|||
displayList.showNotification( |
|||
`Room invite ${req.body.action} successfully.`, |
|||
'success', |
|||
'bottom-center', |
|||
3000, |
|||
); |
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to process room invite', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postRoomInvite (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.inviteUserToRoom(res.locals.room, req.body); |
|||
|
|||
const displayList = this.createDisplayList('invite-result'); |
|||
displayList.showNotification( |
|||
'Member invited successfully', |
|||
'success', |
|||
'bottom-center', |
|||
3000, |
|||
); |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to invite user to room', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postRoomSettings (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.updateRoomSettings(res.locals.room, req.body); |
|||
res.redirect(`/chat/room/${res.locals.room._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to present the room settings view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postCreateRoom (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.room = await chatService.createRoom(req.user, req.body); |
|||
res.redirect(`/chat/room/${res.locals.room._id}`); |
|||
} catch (error) { |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomCreateView (req, res) { |
|||
res.render('chat/room/create'); |
|||
} |
|||
|
|||
async getRoomInviteForm (req, res) { |
|||
res.render('chat/room/invite'); |
|||
} |
|||
|
|||
async getRoomJoinView (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.joinRoom(res.locals.room, req.user); |
|||
res.status(200).json({ success: true, room: res.locals.room }); |
|||
} catch (error) { |
|||
this.log.error('failed to join chat room', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomMessages (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.pagination = this.getPaginationParameters(req, ChatController.MESSAGES_PER_PAGE); |
|||
res.locals.messages = await chatService.getRoomMessages(res.locals.room, res.locals.pagination); |
|||
|
|||
res.render('chat/room/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present the chat room view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomSettingsView (req, res) { |
|||
res.locals.currentView = 'chat-room'; |
|||
res.locals.pageTitle = `${res.locals.room.name} (Settings)`; |
|||
res.render('chat/room/settings'); |
|||
} |
|||
|
|||
async getRoomView (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.checkRoomMember(res.locals.room, req.user); |
|||
|
|||
res.locals.currentView = 'chat-room'; |
|||
res.locals.pageTitle = res.locals.room.name; |
|||
|
|||
res.locals.pagination = this.getPaginationParameters(req, ChatController.MESSAGES_PER_PAGE); |
|||
res.locals.messages = await chatService.getRoomMessages(res.locals.room, res.locals.pagination); |
|||
|
|||
res.render('chat/room/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present the chat room view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async deleteChatMessage (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.removeMessage(res.locals.message); |
|||
res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to destroy chat room', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async deleteRoom (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
await chatService.destroyRoom(req.user, res.locals.room); |
|||
|
|||
const displayList = this.createDisplayList('chat-room-delete'); |
|||
displayList.navigateTo('/'); |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to destroy chat room', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,192 @@ |
|||
// client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class ClientController extends SiteController { |
|||
|
|||
static get name ( ) { return 'ClientController'; } |
|||
static get slug ( ) { return 'client'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ClientController.slug); |
|||
this.dtp = dtp; |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/client', router); |
|||
|
|||
const { limiter: limiterService } = dtp.services; |
|||
const limiterConfig = limiterService.config.client; |
|||
|
|||
function checkClientOwnership (req, res, next) { |
|||
if (!res.locals.client.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your client'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
function checkProjectOwnership (req, res, next) { |
|||
if (!res.locals.project.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your client'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
router.param('clientId', this.populateClientId.bind(this)); |
|||
router.param('projectId', this.populateProjectId.bind(this)); |
|||
|
|||
router.post( |
|||
'/:clientId/project', |
|||
limiterService.create(limiterConfig.postProjectCreate), |
|||
checkClientOwnership, |
|||
this.postProjectCreate.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/', |
|||
limiterService.create(limiterConfig.postCreateClient), |
|||
this.postCreateClient.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId/project/create', |
|||
limiterService.create(limiterConfig.getProjectCreate), |
|||
checkClientOwnership, |
|||
this.getProjectCreate.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId/project/:projectId', |
|||
limiterService.create(limiterConfig.getProjectView), |
|||
checkProjectOwnership, |
|||
this.getProjectView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/create', |
|||
limiterService.create(limiterConfig.getClientCreate), |
|||
this.getClientCreate.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:clientId', |
|||
limiterService.create(limiterConfig.getClientView), |
|||
checkClientOwnership, |
|||
this.getClientView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/', |
|||
limiterService.create(limiterConfig.getHome), |
|||
this.getHome.bind(this), |
|||
); |
|||
|
|||
return router; |
|||
} |
|||
|
|||
async populateClientId (req, res, next, clientId) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.client = await clientService.getClientById(clientId); |
|||
if (!res.locals.client) { |
|||
throw new SiteError(404, 'Client not found'); |
|||
} |
|||
if (!res.locals.client.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your client'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate client', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async populateProjectId (req, res, next, projectId) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.project = await clientService.getProjectById(projectId); |
|||
if (!res.locals.project) { |
|||
throw new SiteError(404, 'Project not found'); |
|||
} |
|||
if (!res.locals.project.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your project'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate project', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postProjectCreate (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.project = await clientService.createProject(res.locals.client, req.body); |
|||
res.redirect(`/client/${res.locals.client._id}/project/${res.locals.project._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create client project', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postCreateClient (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.client = await clientService.createClient(req.user, req.body); |
|||
res.redirect(`/client/${res.locals.client._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create client', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getProjectCreate (req, res) { |
|||
res.render('client/project/create'); |
|||
} |
|||
|
|||
async getProjectView (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.taskGrid = await taskService.getTaskGridForProject(res.locals.project); |
|||
res.render('client/project/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present project view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getClientCreate (req, res) { |
|||
res.render('client/create'); |
|||
} |
|||
|
|||
async getClientView (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.projects = await clientService.getProjectsForClient(res.locals.client); |
|||
res.render('client/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present client home view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getHome (req, res, next) { |
|||
const { client: clientService } = this.dtp.services; |
|||
try { |
|||
res.locals.clients = await clientService.getClientsForUser(req.user); |
|||
res.render('client/dashboard'); |
|||
} catch (error) { |
|||
this.log.error('failed to present client home view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,235 @@ |
|||
// task.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import express from 'express'; |
|||
|
|||
import { SiteController, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class TaskController extends SiteController { |
|||
|
|||
static get name ( ) { return 'TaskController'; } |
|||
static get slug ( ) { return 'task'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, TaskController); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
|
|||
const { limiter: limiterService } = dtp.services; |
|||
const limiterConfig = limiterService.config.task; |
|||
|
|||
const multer = this.createMulter(TaskController.slug, { |
|||
limits: { |
|||
fileSize: 1024 * 1000 * 5, |
|||
}, |
|||
}); |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/task', router); |
|||
|
|||
router.use(async (req, res, next) => { |
|||
res.locals.currentView = TaskController.name; |
|||
return next(); |
|||
}); |
|||
|
|||
async function checkTaskOwnership (req, res, next) { |
|||
if (!res.locals.task.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your task'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
async function checkSessionOwnership (req, res, next) { |
|||
if (!res.locals.session.user._id.equals(req.user._id)) { |
|||
throw new SiteError(401, 'This is not your session'); |
|||
} |
|||
return next(); |
|||
} |
|||
|
|||
router.param('taskId', this.populateTaskId.bind(this)); |
|||
router.param('sessionId', this.populateSessionId.bind(this)); |
|||
|
|||
router.post( |
|||
'/:taskId/session/start', |
|||
limiterService.create(limiterConfig.postStartTaskSession), |
|||
this.postStartTaskSession.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/session/:sessionId/screenshot', |
|||
checkSessionOwnership, |
|||
limiterService.create(limiterConfig.postTaskSessionScreenshot), |
|||
multer.single('image'), |
|||
this.postTaskSessionScreenshot.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/session/:sessionId/close', |
|||
limiterService.create(limiterConfig.postCloseTaskSession), |
|||
checkSessionOwnership, |
|||
this.postCloseTaskSession.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/:taskId/start', |
|||
limiterService.create(limiterConfig.postStartTask), |
|||
checkTaskOwnership, |
|||
this.postStartTask.bind(this), |
|||
); |
|||
router.post( |
|||
'/:taskId/close', |
|||
limiterService.create(limiterConfig.postCloseTask), |
|||
checkTaskOwnership, |
|||
this.postCloseTask.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/', |
|||
limiterService.create(limiterConfig.postCreateTask), |
|||
this.postCreateTask.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:taskId/session/:sessionId', |
|||
limiterService.create(limiterConfig.getTaskSessionView), |
|||
checkSessionOwnership, |
|||
this.getTaskSessionView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/:taskId', |
|||
limiterService.create(limiterConfig.getTaskView), |
|||
checkTaskOwnership, |
|||
this.getTaskView.bind(this), |
|||
); |
|||
} |
|||
|
|||
async populateTaskId (req, res, next, taskId) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.task = await taskService.getTaskById(taskId); |
|||
if (!res.locals.task) { |
|||
throw new SiteError(404, 'Task not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async populateSessionId (req, res, next, sessionId) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.session = await taskService.getTaskSessionById(sessionId); |
|||
if (!res.locals.session) { |
|||
throw new SiteError(404, 'Task session not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postStartTaskSession (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.session = await taskService.createTaskSession(res.locals.task); |
|||
res.status(200).json({ |
|||
success: true, |
|||
session: res.locals.session, |
|||
}); |
|||
} catch (error) { |
|||
this.log.error('failed to create task session', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postTaskSessionScreenshot (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.addTaskSessionScreenshot(res.locals.session, req.file); |
|||
res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to add task session screenshot', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postCloseTaskSession (req, res) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.closeTaskSession(res.locals.session); |
|||
res.status(200).json({ success: true }); |
|||
} catch (error) { |
|||
this.log.error('failed to close task session', { error }); |
|||
res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postStartTask (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.startTask(res.locals.task); |
|||
res.redirect(`/task/${res.locals.task._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to close task session', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postCloseTask (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
await taskService.closeTask(res.locals.task); |
|||
res.redirect('/'); |
|||
} catch (error) { |
|||
this.log.error('failed to close task', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postCreateTask (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.task = await taskService.createTask(req.user, req.body); |
|||
res.redirect(`/task/${res.locals.task._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to create new task', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getTaskSessionView (req, res) { |
|||
res.render('task/session/view'); |
|||
} |
|||
|
|||
async getTaskView (req, res, next) { |
|||
const { task: taskService } = this.dtp.services; |
|||
try { |
|||
res.locals.pagination = this.getPaginationParameters(req, 50); |
|||
res.locals.sessions = await taskService.getSessionsForTask( |
|||
res.locals.task, |
|||
res.locals.pagination, |
|||
); |
|||
|
|||
res.render('task/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to present the Task view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
@ -1,15 +0,0 @@ |
|||
// chat-filter.js
|
|||
// Copyright (C) 2022,2023 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ChatFilterSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1, expires: '300d' }, |
|||
filter: { type: String, required: true, unique: true, lowercase: true, index: 1 }, |
|||
}); |
|||
|
|||
export default mongoose.model('ChatFilter', ChatFilterSchema); |
@ -1,34 +0,0 @@ |
|||
// chat-message.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const CHANNEL_TYPE_LIST = ['User', 'ChatRoom']; |
|||
|
|||
const ReactionSchema = new Schema({ |
|||
emoji: { type: String }, |
|||
users: { type: [Schema.ObjectId], ref: 'User' }, |
|||
}); |
|||
|
|||
const ChatMessageSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, |
|||
expires: { type: Date, index: -1 }, |
|||
channelType: { type: String, enum: CHANNEL_TYPE_LIST, required: true }, |
|||
channel: { type: Schema.ObjectId, required: true, index: 1, refPath: 'channelType' }, |
|||
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
content: { type: String }, |
|||
mentions: { type: [Schema.ObjectId], select: false, ref: 'User' }, |
|||
hashtags: { type: [String], select: false }, |
|||
links: { type: [Schema.ObjectId], ref: 'Link' }, |
|||
attachments: { |
|||
images: { type: [Schema.ObjectId], ref: 'Image' }, |
|||
videos: { type: [Schema.ObjectId], ref: 'Video' }, |
|||
}, |
|||
reactions: { type: [ReactionSchema], default: [ ] }, |
|||
}); |
|||
|
|||
export default mongoose.model('ChatMessage', ChatMessageSchema); |
@ -1,27 +0,0 @@ |
|||
// chat-room-invite.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const INVITE_STATUS_LIST = ['new', 'viewed', 'accepted', 'rejected']; |
|||
|
|||
const InviteeSchema = new Schema({ |
|||
user: { type: Schema.ObjectId, ref: 'User' }, |
|||
email: { type: String }, |
|||
}); |
|||
|
|||
const ChatRoomInviteSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, |
|||
token: { type: String, required: true, unique: true }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
room: { type: Schema.ObjectId, required: true, index: 1, ref: 'ChatRoom' }, |
|||
member: { type: InviteeSchema, required: true }, |
|||
status: { type: String, enum: INVITE_STATUS_LIST, default: 'new', required: true }, |
|||
message: { type: String }, |
|||
}); |
|||
|
|||
export default mongoose.model('ChatRoomInvite', ChatRoomInviteSchema); |
@ -1,32 +0,0 @@ |
|||
// chat-room.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import { MIN_ROOM_CAPACITY, MAX_ROOM_CAPACITY } from './lib/constants.js'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ChatRoomSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: 1 }, |
|||
lastActivity: { type: Date, index: -1 }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
name: { type: String, required: true }, |
|||
topic: { type: String }, |
|||
capacity: { type: Number, required: true, min: MIN_ROOM_CAPACITY, max: MAX_ROOM_CAPACITY }, |
|||
invites: { type: [Schema.ObjectId], select: false, ref: 'ChatRoomInvite' }, |
|||
members: { type: [Schema.ObjectId], select: false, ref: 'User' }, |
|||
present: { type: [Schema.ObjectId], select: false, ref: 'User' }, |
|||
banned: { type: [Schema.ObjectId], select: false, ref: 'User' }, |
|||
settings: { |
|||
expireDays: { type: Number, default: 7, min: 1, max: 30, required: true }, |
|||
}, |
|||
stats: { |
|||
memberCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, |
|||
presentCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, |
|||
}, |
|||
}); |
|||
|
|||
export default mongoose.model('ChatRoom', ChatRoomSchema); |
@ -0,0 +1,19 @@ |
|||
// project.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ClientProjectSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: 1 }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|||
name: { type: String, required: true }, |
|||
description: { type: String }, |
|||
hourlyRate: { type: Number, required: true }, |
|||
}); |
|||
|
|||
export default mongoose.model('ClientProject', ClientProjectSchema); |
@ -0,0 +1,16 @@ |
|||
// client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ClientSchema = new Schema({ |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
name: { type: String, required: true }, |
|||
description: { type: String }, |
|||
}); |
|||
|
|||
export default mongoose.model('Client', ClientSchema); |
@ -0,0 +1,32 @@ |
|||
// task-session.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const STATUS_LIST = ['active', 'finished']; |
|||
|
|||
const ScreenshotSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
image: { type: Schema.ObjectId, required: true, ref: 'Image' }, |
|||
}); |
|||
|
|||
const TaskSessionSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
lastUpdated: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
finished: { type: Date, index: 1 }, |
|||
duration: { type: Number, default: 0, required: true }, |
|||
hourlyRate: { type: Number, required: true }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|||
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' }, |
|||
task: { type: Schema.ObjectId, required: true, index: 1, ref: 'Task' }, |
|||
status: { type: String, enum: STATUS_LIST, default: 'active', required: true }, |
|||
notes: { type: [String], default: [ ] }, |
|||
screenshots: { type: [ScreenshotSchema] }, |
|||
}); |
|||
|
|||
export default mongoose.model('TaskSession', TaskSessionSchema); |
@ -0,0 +1,23 @@ |
|||
// task.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Schema = mongoose.Schema; |
|||
|
|||
const STATUS_LIST = ['pending', 'active', 'finished']; |
|||
|
|||
const TaskSchema = new Schema({ |
|||
created: { type: Date, required: true, default: Date.now, index: 1 }, |
|||
finished: { type: Date, index: 1 }, |
|||
duration: { type: Number, default: 0, required: true }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
client: { type: Schema.ObjectId, required: true, index: 1, ref: 'Client' }, |
|||
project: { type: Schema.ObjectId, required: true, index: 1, ref: 'ClientProject' }, |
|||
status: { type: String, enum: STATUS_LIST, default: 'pending', required: true }, |
|||
note: { type: String, required: true }, |
|||
}); |
|||
|
|||
export default mongoose.model('Task', TaskSchema); |
@ -1,703 +0,0 @@ |
|||
// chat.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const ChatRoom = mongoose.model('ChatRoom'); |
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
|||
const User = mongoose.model('User'); |
|||
|
|||
import numeral from 'numeral'; |
|||
import dayjs from 'dayjs'; |
|||
|
|||
import { v4 as uuidv4 } from 'uuid'; |
|||
|
|||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|||
import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; |
|||
|
|||
export default class ChatService extends SiteService { |
|||
|
|||
static get name ( ) { return 'ChatService'; } |
|||
static get slug () { return 'chat'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ChatService); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { link: linkService, user: userService } = this.dtp.services; |
|||
|
|||
this.templates = { |
|||
message: this.loadViewTemplate('chat/components/message-standalone.pug'), |
|||
memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'), |
|||
reactionBar: this.loadViewTemplate('chat/components/reaction-bar-standalone.pug'), |
|||
}; |
|||
|
|||
this.populateChatRoom = [ |
|||
{ |
|||
path: 'owner', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'present', |
|||
select: userService.USER_SELECT, |
|||
populate: userService.populateUser, |
|||
}, |
|||
]; |
|||
|
|||
this.populateChatMessage = [ |
|||
{ |
|||
path: 'channel', |
|||
}, |
|||
{ |
|||
path: 'author', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'mentions', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'links', |
|||
populate: linkService.populateLink, |
|||
}, |
|||
{ |
|||
path: 'attachments.images', |
|||
}, |
|||
{ |
|||
path: 'attachments.videos', |
|||
}, |
|||
{ |
|||
path: 'reactions', |
|||
populate: [ |
|||
{ |
|||
path: 'users', |
|||
select: 'username displayName', |
|||
}, |
|||
], |
|||
}, |
|||
]; |
|||
|
|||
this.populateChatRoomInvite = [ |
|||
{ |
|||
path: 'owner', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'room', |
|||
populate: this.populateChatRoom, |
|||
}, |
|||
{ |
|||
path: 'member.user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
async createRoom (owner, roomDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
const room = new ChatRoom(); |
|||
room.created = NOW; |
|||
room.owner = owner._id; |
|||
room.name = textService.filter(roomDefinition.name); |
|||
if (roomDefinition.topic) { |
|||
room.topic = textService.filter(roomDefinition.topic); |
|||
} |
|||
room.capacity = MAX_ROOM_CAPACITY; |
|||
room.members = [owner._id]; |
|||
room.stats.memberCount = 1; |
|||
|
|||
await room.save(); |
|||
|
|||
return room.toObject(); |
|||
} |
|||
|
|||
async updateRoomSettings (room, settingsDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const update = { $set: { }, $unset: { } }; |
|||
|
|||
update.$set.name = textService.filter(settingsDefinition.name); |
|||
if (!update.$set.name) { |
|||
throw new SiteError(400, 'Room must have a name'); |
|||
} |
|||
|
|||
const topic = textService.filter(settingsDefinition.topic); |
|||
if (topic && (room.topic !== topic)) { |
|||
update.$set.topic = topic; |
|||
} else { |
|||
update.$unset.topic = 1; |
|||
} |
|||
|
|||
update.$set['settings.expireDays'] = parseInt(settingsDefinition.expireDays, 10); |
|||
|
|||
await ChatRoom.updateOne({ _id: room._id }, update); |
|||
} |
|||
|
|||
async destroyRoom (user, room) { |
|||
if (!user._id.equals(room.owner._id)) { |
|||
throw new SiteError(401, 'This is not your chat room'); |
|||
} |
|||
await this.removeInvitesForRoom(room); |
|||
await this.removeMessagesForChannel(room); |
|||
await ChatRoom.deleteOne({ _id: room._id }); |
|||
} |
|||
|
|||
async inviteUserToRoom (room, inviteDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
inviteDefinition.usernameOrEmail = inviteDefinition.usernameOrEmail.trim().toLowerCase(); |
|||
|
|||
const invitee = await User.findOne({ |
|||
$or: [ |
|||
{ username_lc: inviteDefinition.usernameOrEmail }, |
|||
{ email: inviteDefinition.usernameOrEmail }, |
|||
], |
|||
}).lean(); |
|||
|
|||
const existingInvite = await ChatRoomInvite |
|||
.findOne({ room: room._id, member: invitee._id }) |
|||
.select('_id') |
|||
.lean(); |
|||
if (existingInvite) { |
|||
throw new SiteError(400, 'User already invited to room'); |
|||
} |
|||
|
|||
const invite = new ChatRoomInvite(); |
|||
invite.created = NOW; |
|||
invite.token = uuidv4(); |
|||
invite.owner = room.owner._id; |
|||
invite.room = room._id; |
|||
|
|||
let userAccount = await User.findOne({ email: inviteDefinition.usernameOrEmail }); |
|||
if (!userAccount) { |
|||
userAccount = await User.findOne({ username_lc: inviteDefinition.usernameOrEmail.toLowerCase() }); |
|||
} |
|||
|
|||
if (userAccount) { |
|||
invite.member = { user: userAccount._id }; |
|||
} else { |
|||
invite.member = { email: inviteDefinition.usernameOrEmail.trim() }; |
|||
} |
|||
|
|||
invite.status = 'new'; |
|||
if (inviteDefinition.message) { |
|||
invite.message = textService.filter(inviteDefinition.message); |
|||
} |
|||
|
|||
this.log.info('inviting user to chat room', { |
|||
user: inviteDefinition.usernameOrEmail, |
|||
room: { |
|||
_id: room._id, |
|||
name: room.name, |
|||
}, |
|||
}); |
|||
|
|||
await invite.save(); |
|||
|
|||
return invite.toObject(); |
|||
} |
|||
|
|||
async getInvitationsForUser (user, pagination) { |
|||
const invites = await ChatRoomInvite |
|||
.find({ 'member.user': user._id, status: 'new' }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateChatRoomInvite) |
|||
.lean(); |
|||
return invites; |
|||
} |
|||
|
|||
async getInviteById (inviteId) { |
|||
const invite = await ChatRoomInvite |
|||
.findOne({ _id: inviteId }) |
|||
.populate(this.populateChatRoomInvite) |
|||
.lean(); |
|||
return invite; |
|||
} |
|||
|
|||
async processRoomInvite (user, invite, action) { |
|||
await ChatRoom.updateOne( |
|||
{ _id: invite.room._id }, |
|||
{ |
|||
$addToSet: { members: user._id }, |
|||
}, |
|||
); |
|||
|
|||
await ChatRoomInvite.updateOne( |
|||
{ _id: invite._id }, |
|||
{ |
|||
$set: { |
|||
'member.user': user._id, |
|||
status: action, |
|||
}, |
|||
$unset: { |
|||
'member.email': 1, |
|||
}, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async joinRoom (room, user) { |
|||
const roomData = await ChatRoom.findOne({ _id: room._id, banned: user._id }).lean(); |
|||
if (roomData) { |
|||
throw new SiteError(401, 'You are banned from this chat room'); |
|||
} |
|||
|
|||
const response = await ChatRoom.updateOne( |
|||
{ _id: room._id }, |
|||
{ |
|||
$addToSet: { members: user._id }, |
|||
}, |
|||
); |
|||
|
|||
this.log.debug('joinRoom complete', { response }); |
|||
return response; |
|||
} |
|||
|
|||
async chatRoomCheckIn (room, member) { |
|||
// indicate presence in the chat room's Mongo document
|
|||
const roomData = await ChatRoom.findOneAndUpdate( |
|||
{ _id: room._id }, |
|||
{ |
|||
$addToSet: { present: member._id }, |
|||
$inc: { 'stats.presentCount': 1 }, |
|||
}, |
|||
{ |
|||
new: true, |
|||
}, |
|||
); |
|||
|
|||
this.log.debug('member checking into chat room', { |
|||
room: { |
|||
_id: room._id, |
|||
name: room.name, |
|||
presentCount: roomData.stats.presentCount, |
|||
}, |
|||
member: { |
|||
_id: member._id, |
|||
username: member.username, |
|||
}, |
|||
}); |
|||
|
|||
/* |
|||
* Broadcast a control message to all room members that a new member has |
|||
* joined the room. |
|||
*/ |
|||
const displayList = this.createDisplayList('chat-control'); |
|||
displayList.removeElement( |
|||
`ul#chat-active-members li[data-member-id="${member._id}"]`, |
|||
); |
|||
displayList.removeElement( |
|||
`ul#chat-idle-members li[data-member-id="${member._id}"]`, |
|||
); |
|||
|
|||
displayList.addElement( |
|||
`ul#chat-active-members[data-room-id="${room._id}"]`, |
|||
'afterBegin', |
|||
this.templates.memberListItem({ room, member }), |
|||
); |
|||
|
|||
displayList.setTextContent( |
|||
`.chat-present-count`, |
|||
numeral(roomData.stats.presentCount).format('0,0'), |
|||
); |
|||
|
|||
this.dtp.emitter |
|||
.to(room._id.toString()) |
|||
.emit('chat-control', { |
|||
displayList, |
|||
audio: { playSound: 'chat-room-connect' }, |
|||
}); |
|||
} |
|||
|
|||
async chatRoomCheckOut (room, member) { |
|||
const roomData = await ChatRoom.findOneAndUpdate( |
|||
{ _id: room._id }, |
|||
{ |
|||
$pull: { present: member._id }, |
|||
$inc: { 'stats.presentCount': -1 }, |
|||
}, |
|||
{ |
|||
new: true, |
|||
}, |
|||
); |
|||
|
|||
this.log.debug('member checking out of chat room', { |
|||
room: { |
|||
_id: room._id, |
|||
name: room.name, |
|||
presentCount: roomData.stats.presentCount, |
|||
}, |
|||
member: { |
|||
_id: member._id, |
|||
username: member.username, |
|||
}, |
|||
}); |
|||
|
|||
/* |
|||
* Broadcast a control message to all room members that a new member has |
|||
* joined the room. |
|||
*/ |
|||
const displayList = this.createDisplayList('chat-control'); |
|||
displayList.removeElement(`ul#chat-active-members li[data-member-id="${member._id}"]`); |
|||
|
|||
displayList.setTextContent( |
|||
`.chat-present-count`, |
|||
numeral(roomData.stats.presentCount).format('0,0'), |
|||
); |
|||
|
|||
this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList }); |
|||
} |
|||
|
|||
async sendRoomMessage (room, author, messageDefinition, imageFiles, videoFiles) { |
|||
const NOW = new Date(); |
|||
const { |
|||
image: imageService, |
|||
text: textService, |
|||
user: userService, |
|||
video: videoService, |
|||
} = this.dtp.services; |
|||
|
|||
const message = new ChatMessage(); |
|||
message.created = NOW; |
|||
message.expires = dayjs(NOW).add(room?.settings?.expireDays || 7, 'day'); |
|||
message.channelType = 'ChatRoom'; |
|||
message.channel = room._id; |
|||
message.author = author._id; |
|||
message.content = textService.filter(messageDefinition.content); |
|||
|
|||
message.mentions = await textService.findMentions(message.content); |
|||
message.hashtags = await textService.findHashtags(message.content); |
|||
message.links = await textService.findLinks(author, message.content, { channelId: room._id }); |
|||
|
|||
if (imageFiles) { |
|||
for (const imageFile of imageFiles) { |
|||
const image = await imageService.create(author, { }, imageFile); |
|||
message.attachments.images.push(image._id); |
|||
} |
|||
} |
|||
|
|||
if (videoFiles) { |
|||
for (const videoFile of videoFiles) { |
|||
switch (videoFile.mimetype) { |
|||
case 'video/mp4': |
|||
const video = await videoService.createVideo(author, { }, videoFile); |
|||
message.attachments.videos.push(video._id); |
|||
break; |
|||
|
|||
case 'video/quicktime': |
|||
await videoService.transcodeMov(videoFile); |
|||
const mov = await videoService.createVideo(author, { }, videoFile); |
|||
message.attachments.videos.push(mov._id); |
|||
break; |
|||
|
|||
case 'image/gif': |
|||
await videoService.transcodeGif(videoFile); |
|||
const gif = await videoService.createVideo(author, { fromGif: true }, videoFile); |
|||
message.attachments.videos.push(gif._id); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
await message.save(); |
|||
await ChatMessage.populate(message, this.populateChatMessage); |
|||
|
|||
let viewModel = Object.assign({ }, this.dtp.app.locals); |
|||
viewModel = Object.assign(viewModel, { user: author, message }); |
|||
const html = this.templates.message(viewModel); |
|||
|
|||
const messageObj = message.toObject(); |
|||
messageObj.author = userService.filterUserObject(author); |
|||
|
|||
this.dtp.emitter |
|||
.to(room._id.toString()) |
|||
.emit('chat-message', { message: messageObj, html }); |
|||
|
|||
return messageObj; |
|||
} |
|||
|
|||
async getRoomMessages (room, pagination) { |
|||
const messages = await ChatMessage |
|||
.find({ channel: room._id }) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateChatMessage) |
|||
.lean(); |
|||
return messages.reverse(); |
|||
} |
|||
|
|||
async toggleMessageReaction (sender, message, reactionDefinition) { |
|||
const reaction = message.reactions ? message.reactions.find((r) => r.emoji === reactionDefinition.emoji) : undefined; |
|||
if (reaction) { |
|||
const currentReact = reaction.users.find((user) => user._id.equals(sender._id)); |
|||
if (currentReact) { |
|||
if (reaction.users.length === 1) { |
|||
// last user to react, remove the whole reaction for this emoji
|
|||
await ChatMessage.updateOne( |
|||
{ |
|||
_id: message._id, |
|||
'reactions.emoji': reactionDefinition.emoji, |
|||
}, |
|||
{ |
|||
$pull: { |
|||
'reactions': { emoji: reactionDefinition.emoji }, |
|||
}, |
|||
}, |
|||
); |
|||
return this.updateMessageReactionBar(message, { playSound: 'reaction-remove' }); |
|||
} |
|||
|
|||
// just pull the user from the emoji's users array
|
|||
await ChatMessage.updateOne( |
|||
{ |
|||
_id: message._id, |
|||
'reactions.emoji': reactionDefinition.emoji, |
|||
}, |
|||
{ |
|||
$pull: { |
|||
'reactions': { user: sender._id }, |
|||
}, |
|||
}, |
|||
); |
|||
return this.updateMessageReactionBar(message, { playSound: 'reaction-remove' }); |
|||
} else { |
|||
// add sender to emoji's users array
|
|||
await ChatMessage.updateOne( |
|||
{ |
|||
_id: message._id, |
|||
'reactions.emoji': reactionDefinition.emoji, |
|||
}, |
|||
{ |
|||
$push: { |
|||
'reactions.$.users': sender._id, |
|||
} |
|||
}, |
|||
); |
|||
return this.updateMessageReactionBar(message, { playSound: 'reaction' }); |
|||
} |
|||
} |
|||
|
|||
// create a reaction for the emoji
|
|||
await ChatMessage.updateOne( |
|||
{ _id: message._id }, |
|||
{ |
|||
$push: { |
|||
reactions: { |
|||
emoji: reactionDefinition.emoji, |
|||
users: [sender._id], |
|||
}, |
|||
}, |
|||
}, |
|||
); |
|||
return this.updateMessageReactionBar(message, { playSound: 'reaction' }); |
|||
} |
|||
|
|||
async updateMessageReactionBar (message, audio) { |
|||
message = await ChatMessage |
|||
.findOne({ _id: message._id }) |
|||
.populate(this.populateChatMessage) |
|||
.lean(); |
|||
|
|||
let viewModel = Object.assign({ }, this.dtp.app.locals); |
|||
viewModel = Object.assign(viewModel, { message }); |
|||
|
|||
const displayList = this.createDisplayList('reaction-bar-update'); |
|||
displayList.replaceElement( |
|||
`.chat-message[data-message-id="${message._id}"] .message-reaction-bar`, |
|||
this.templates.reactionBar(viewModel), |
|||
); |
|||
|
|||
const payload = { displayList }; |
|||
if (audio) { |
|||
payload.audio = audio; |
|||
} |
|||
|
|||
this.dtp.emitter |
|||
.to(message.channel._id.toString()) |
|||
.emit('chat-control', payload); |
|||
} |
|||
|
|||
async checkRoomMember (room, member) { |
|||
if (room.owner._id.equals(member._id)) { |
|||
return true; |
|||
} |
|||
|
|||
const search = { _id: room._id, members: member._id }; |
|||
const checkRoom = await ChatRoom.findOne(search).select('name').lean(); |
|||
if (!checkRoom) { |
|||
throw new SiteError(403, `You are not a member of ${checkRoom.name}`); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
async isRoomMember (room, member) { |
|||
if (room.owner._id.equals(member._id)) { |
|||
return true; |
|||
} |
|||
const search = { _id: room._id, members: member._id }; |
|||
const checkRoom = await ChatRoom.findOne(search).select('name').lean(); |
|||
return !!checkRoom; |
|||
} |
|||
|
|||
async leaveRoom (room, user) { |
|||
await ChatRoom.updateOne( |
|||
{ _id: room._id }, |
|||
{ |
|||
$pull: { members: user._id }, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async getRoomMemberList (room) { |
|||
const roomData = await ChatRoom.findOne({ _id: room._id }).select('members'); |
|||
if (!roomData) { |
|||
throw new SiteError(404, 'Room not found'); |
|||
} |
|||
return roomData.members; |
|||
} |
|||
|
|||
async getRoomBlockList (room) { |
|||
const roomData = await ChatRoom.findOne({ _id: room._id }).select('members'); |
|||
if (!roomData) { |
|||
throw new SiteError(404, 'Room not found'); |
|||
} |
|||
return roomData.banned; |
|||
} |
|||
|
|||
async getRoomById (roomId) { |
|||
const room = await ChatRoom |
|||
.findOne({ _id: roomId }) |
|||
.select('+present') |
|||
.populate(this.populateChatRoom) |
|||
.lean(); |
|||
return room; |
|||
} |
|||
|
|||
async getRoomsForOwner (owner) { |
|||
const rooms = await ChatRoom |
|||
.find({ owner: owner._id }) |
|||
.populate(this.populateChatRoom) |
|||
.lean(); |
|||
return rooms; |
|||
} |
|||
|
|||
async getRoomsForMember (member, pagination) { |
|||
const rooms = await ChatRoom |
|||
.find({ members: member._id }) |
|||
.populate(this.populateChatRoom) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.lean(); |
|||
return rooms; |
|||
} |
|||
|
|||
async removeInvitesForRoom (room) { |
|||
await ChatRoomInvite.deleteMany({ room: room._id }); |
|||
} |
|||
|
|||
async getMessageById (messageId) { |
|||
const message = await ChatMessage |
|||
.findOne({ _id: messageId }) |
|||
.populate(this.populateChatMessage) |
|||
.lean(); |
|||
return message; |
|||
} |
|||
|
|||
async removeMessagesForChannel (channel) { |
|||
this.log.alert('removing all messages for channel', { channelId: channel._id }); |
|||
await ChatMessage |
|||
.find({ channel: channel._id }) |
|||
.cursor() |
|||
.eachAsync(async (message) => { |
|||
await this.removeMessage(message); |
|||
}, 4); |
|||
} |
|||
|
|||
async expireMessages ( ) { |
|||
const NOW = new Date(); |
|||
|
|||
this.log.info('expiring chat messages'); |
|||
|
|||
await ChatMessage |
|||
.find({ |
|||
$or: [ |
|||
{ expires: { $lt: NOW } }, |
|||
{ expires: { $exists: false } }, |
|||
], |
|||
}) |
|||
.cursor() |
|||
.eachAsync(async (message) => { |
|||
await this.removeMessage(message); |
|||
}, 4); |
|||
} |
|||
|
|||
async removeMessage (message) { |
|||
const { image: imageService, video: videoService } = this.dtp.services; |
|||
if (message.attachments) { |
|||
if (Array.isArray(message.attachments.images) && (message.attachments.images.length > 0)) { |
|||
for (const image of message.attachments.images) { |
|||
this.log.debug('removing message attachment', { imageId: image._id }); |
|||
await imageService.deleteImage(image); |
|||
} |
|||
} |
|||
if (Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0)) { |
|||
for (const video of message.attachments.videos) { |
|||
this.log.debug('removing video attachment', { videoId: video._id }); |
|||
await videoService.removeVideo(video); |
|||
} |
|||
} |
|||
} |
|||
|
|||
this.log.debug('removing chat message', { messageId: message._id }); |
|||
await ChatMessage.deleteOne({ _id: message._id }); |
|||
|
|||
const displayList = this.createDisplayList('remove-chat-message'); |
|||
displayList.removeElement(`.chat-message[data-message-id="${message._id}"]`); |
|||
|
|||
this.dtp.emitter |
|||
.to(message.channel._id.toString()) |
|||
.emit('chat-control', { |
|||
displayList, |
|||
audio: { playSound: 'message-remove' }, |
|||
}); |
|||
} |
|||
|
|||
async removeAllForUser (user) { |
|||
this.log.info('removing all chat rooms for user', { |
|||
user: { |
|||
_id: user._id, |
|||
username: user.username, |
|||
}, |
|||
}); |
|||
await ChatRoom |
|||
.find({ owner: user._id }) |
|||
.populate(this.populateChatRoom) |
|||
.cursor() |
|||
.eachAsync(async (room) => { |
|||
await this.destroyRoom(room); |
|||
}); |
|||
|
|||
this.log.info('removing all chat messages for user', { |
|||
user: { |
|||
_id: user._id, |
|||
username: user.username, |
|||
}, |
|||
}); |
|||
await ChatMessage |
|||
.find({ author: user._id }) |
|||
.populate(this.populateChatMessage) |
|||
.cursor() |
|||
.eachAsync(async (message) => { |
|||
await this.removeMessage(message); |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,118 @@ |
|||
// client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Client = mongoose.model('Client'); |
|||
const ClientProject = mongoose.model('ClientProject'); |
|||
|
|||
import { SiteService/*, SiteError*/ } from '../../lib/site-lib.js'; |
|||
|
|||
export default class ClientService extends SiteService { |
|||
|
|||
static get name ( ) { return 'ClientService'; } |
|||
static get slug () { return 'client'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, ClientService); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { user: userService } = this.dtp.services; |
|||
|
|||
this.populateClient = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
|
|||
this.populateClientProject = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'client', |
|||
populate: this.populateClient, |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
async createClient (user, clientDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
|
|||
const client = new Client(); |
|||
client.user = user._id; |
|||
client.name = textService.filter(clientDefinition.name); |
|||
if (clientDefinition.description && clientDefinition.description.length > 0) { |
|||
client.description = textService.filter(clientDefinition.description); |
|||
} |
|||
|
|||
await client.save(); |
|||
|
|||
return client.toObject(); |
|||
} |
|||
|
|||
async getClientById (clientId) { |
|||
const client = Client |
|||
.findOne({ _id: clientId }) |
|||
.populate(this.populateClient) |
|||
.lean(); |
|||
return client; |
|||
} |
|||
|
|||
async getClientsForUser (user) { |
|||
const clients = Client |
|||
.find({ user: user._id }) |
|||
.sort({ name: 1 }) |
|||
.populate(this.populateClient) |
|||
.lean(); |
|||
return clients; |
|||
} |
|||
|
|||
async createProject (client, projectDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
|
|||
const project = new ClientProject(); |
|||
project.user = client.user._id; |
|||
project.client = client._id; |
|||
project.name = textService.filter(projectDefinition.name); |
|||
if (projectDefinition.description && (projectDefinition.description.length > 0)) { |
|||
project.description = textService.filter(projectDefinition.description); |
|||
} |
|||
project.hourlyRate = projectDefinition.hourlyRate; |
|||
|
|||
await project.save(); |
|||
|
|||
return project.toObject(); |
|||
} |
|||
|
|||
async getProjectById (projectId) { |
|||
const project = ClientProject |
|||
.findOne({ _id: projectId }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return project; |
|||
} |
|||
|
|||
async getProjectsForUser (user) { |
|||
const projects = ClientProject |
|||
.find({ user: user._id }) |
|||
.sort({ created: -1 }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return projects; |
|||
} |
|||
|
|||
async getProjectsForClient (client) { |
|||
const projects = ClientProject |
|||
.find({ client: client._id }) |
|||
.sort({ created: -1 }) |
|||
.populate(this.populateClientProject) |
|||
.lean(); |
|||
return projects; |
|||
} |
|||
} |
@ -1,310 +0,0 @@ |
|||
// link.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Link = mongoose.model('Link'); |
|||
|
|||
import { JSDOM } from 'jsdom'; |
|||
|
|||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class LinkService extends SiteService { |
|||
|
|||
static get name ( ) { return 'LinkService'; } |
|||
static get slug ( ) { return 'link'; } |
|||
|
|||
static get DOMAIN_BLACKLIST_KEY ( ) { return 'dblklist'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, LinkService); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const { jobQueue: jobQueueService, user: userService } = this.dtp.services; |
|||
this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links); |
|||
|
|||
this.userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; |
|||
|
|||
this.templates = { |
|||
linkPreview: this.loadViewTemplate('link/components/preview-standalone.pug'), |
|||
}; |
|||
|
|||
this.populateLink = [ |
|||
{ |
|||
path: 'submittedBy', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
} |
|||
|
|||
async getRecent (pagination) { |
|||
const search = { }; |
|||
const links = await Link |
|||
.find(search) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateLink) |
|||
.lean(); |
|||
const totalLinkCount = await Link.estimatedDocumentCount(); |
|||
return { links, totalLinkCount }; |
|||
} |
|||
|
|||
async getById (linkId) { |
|||
return Link |
|||
.findOne({ _id: linkId }) |
|||
.populate(this.populateLink) |
|||
.lean(); |
|||
} |
|||
|
|||
async recordVisit (link) { |
|||
await Link.updateOne( |
|||
{ _id: link._id }, |
|||
{ |
|||
$inc: { 'stats.visitCount': 1 }, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async isDomainBlocked (domain) { |
|||
const isBlocked = await this.dtp.redis.sismember(LinkService.DOMAIN_BLACKLIST_KEY, domain); |
|||
return (isBlocked !== 0); |
|||
} |
|||
|
|||
async ingest (author, url) { |
|||
const NOW = new Date(); |
|||
const domain = new URL(url).hostname.toLowerCase(); |
|||
if (domain.endsWith('.cn')) { |
|||
throw new SiteError(403, 'Linking to Chinese websites is prohibited.'); |
|||
} |
|||
if (domain.endsWith('.il')) { |
|||
throw new SiteError(403, 'Linking to websites in Israel is prohibited.'); |
|||
} |
|||
if (domain.includes('tiktok.com')) { |
|||
throw new SiteError(403, 'Linking to TikTok is prohibited.'); |
|||
} |
|||
|
|||
if (await this.isDomainBlocked(domain)) { |
|||
this.log.alert('detected blocked domain in shared link', { |
|||
author: { _id: author._id, username: author.username }, |
|||
domain, url, |
|||
}); |
|||
throw new SiteError(403, `All links/URLs pointing to ${domain} are prohibited.`); |
|||
} |
|||
|
|||
/* |
|||
* An upsert is used to create a document if one doesn't exist. The domain |
|||
* and url are set on insert, and lastShared is always set so it will be |
|||
* current. |
|||
* |
|||
* submittedBy is an array that holds the User._id of each member that |
|||
* submitted the link. This enables their Link History view, which becomes |
|||
* it's own feed. |
|||
*/ |
|||
const link = await Link.findOneAndUpdate( |
|||
{ domain, url }, |
|||
{ |
|||
$setOnInsert: { |
|||
created: NOW, |
|||
domain, url, |
|||
}, |
|||
$addToSet: { submittedBy: author._id }, |
|||
$set: { lastShared: NOW }, |
|||
}, |
|||
{ upsert: true, new: true }, |
|||
); |
|||
|
|||
this.linksQueue.add('link-ingest', { |
|||
submitterId: author._id, |
|||
linkId: link._id, |
|||
}); |
|||
|
|||
return link; |
|||
} |
|||
|
|||
async generatePagePreview (url, options) { |
|||
const NOW = new Date(); |
|||
const linkUrlObj = new URL(url); |
|||
this.log.debug('generating page preview', { url, linkUrlObj }); |
|||
|
|||
const { /*window,*/ document } = await this.loadUrlAsDOM(url, options); |
|||
const preview = { |
|||
fetched: NOW, |
|||
domain: linkUrlObj.hostname, |
|||
tags: [ ], |
|||
images: [ ], |
|||
videos: [ ], |
|||
audios: [ ], |
|||
favicons: [ ], |
|||
}; |
|||
|
|||
function getMetaContent (selector) { |
|||
const element = document.querySelector(selector); |
|||
if (!element) { |
|||
return; |
|||
} |
|||
return element.getAttribute('content'); |
|||
} |
|||
|
|||
function getElementContent (selector) { |
|||
const element = document.querySelector(selector); |
|||
if (!element) { |
|||
return; |
|||
} |
|||
return element.textContent; |
|||
} |
|||
|
|||
function getLinkHref (selector) { |
|||
const element = document.querySelector(selector); |
|||
if (!element) { |
|||
return; |
|||
} |
|||
return element.getAttribute('href'); |
|||
} |
|||
|
|||
preview.mediaType = getMetaContent('head meta[property="og:type"]'); |
|||
preview.title = |
|||
getMetaContent('head meta[property="og:title"]') || |
|||
getElementContent(`head title`); |
|||
preview.siteName = |
|||
getMetaContent('head meta[property="og:site_name') || |
|||
getElementContent(`head title`); |
|||
|
|||
preview.description = |
|||
getMetaContent('head meta[property="og:description"]') || |
|||
getMetaContent('head meta[name="description"]'); |
|||
|
|||
let href = |
|||
getMetaContent('head meta[property="og:image:secure_url') || |
|||
getMetaContent('head meta[property="og:image') || |
|||
getMetaContent('head meta[name="twitter:image:src"]'); |
|||
if (href) { |
|||
preview.images.push(href); |
|||
} |
|||
|
|||
href = getLinkHref('head link[rel="shortcut icon"]'); |
|||
if (href) { |
|||
preview.favicons.push(href); |
|||
} |
|||
|
|||
const keywords = getMetaContent('head meta[name="keywords"]'); |
|||
if (keywords) { |
|||
preview.tags = keywords.split(',').map((keyword) => keyword.trim()); |
|||
} |
|||
|
|||
const videoTags = document.querySelectorAll('head meta[property="og:video:tag"]'); |
|||
if (videoTags) { |
|||
videoTags.forEach((tag) => { |
|||
tag = tag.getAttribute('content'); |
|||
if (!tag) { |
|||
return; |
|||
} |
|||
tag = tag.trim().toLowerCase(); |
|||
if (!tag.length) { |
|||
return; |
|||
} |
|||
preview.tags.push(tag); |
|||
}); |
|||
} |
|||
|
|||
const icons = document.querySelectorAll('head link[rel="icon"]'); |
|||
if (icons) { |
|||
icons.forEach((icon) => { |
|||
preview.favicons.push(icon.getAttribute('href')); |
|||
}); |
|||
} |
|||
|
|||
//TODO: oEmbed spec allows for JSON and XML. May need to implement an XML
|
|||
// reader for `head link[rel="alternate"][type="text/xml+oembed"]`
|
|||
|
|||
preview.oembed = { }; |
|||
preview.oembed.href = getLinkHref('head link[type="application/json+oembed"]'); |
|||
if (preview.oembed.href) { |
|||
this.log.info('fetching oEmbed data for url', { url, href: preview.oembed.href }); |
|||
const json = await this.fetchOembedJson(preview.oembed.href); |
|||
|
|||
preview.oembed.version = json.version; |
|||
preview.oembed.type = json.type; |
|||
if (json.cache_age) { |
|||
preview.oembed.cache_age = json.cache_age; |
|||
} |
|||
preview.oembed.title = json.title; |
|||
preview.oembed.provider_name = json.provider_name; |
|||
preview.oembed.provider_url = json.provider_url; |
|||
preview.oembed.author_name = json.author_name; |
|||
preview.oembed.author_url = json.author_url; |
|||
preview.oembed.thumbnail_url = json.thumbnail_url; |
|||
preview.oembed.thumbnail_width = json.thumbnail_width; |
|||
preview.oembed.thumbnail_height = json.thumbnail_height; |
|||
|
|||
switch (json.type) { |
|||
case 'video': |
|||
preview.oembed.html = json.html; |
|||
preview.oembed.width = json.width; |
|||
preview.oembed.height = json.height; |
|||
break; |
|||
|
|||
case 'photo': |
|||
preview.oembed.url = json.url; |
|||
preview.oembed.width = json.width; |
|||
preview.oembed.height = json.height; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
return preview; |
|||
} |
|||
|
|||
async fetchOembedJson (url) { |
|||
const response = await fetch(url); |
|||
const json = await response.json(); |
|||
return json; |
|||
} |
|||
|
|||
async loadUrlAsDOM (url, options) { |
|||
options = Object.assign({ |
|||
userAgent: this.userAgent, |
|||
acceptLanguage: 'en-US', |
|||
}, options); |
|||
|
|||
const response = await fetch(url, { |
|||
method: "GET", |
|||
headers: { |
|||
"User-Agent": options.userAgent, |
|||
"Accept-Language": options.acceptLanguage, |
|||
}, |
|||
}); |
|||
|
|||
const html = await response.text(); |
|||
const { window } = new JSDOM(html); |
|||
return { window, document: window.document }; |
|||
} |
|||
|
|||
async renderPreview (viewModel) { |
|||
return this.renderTemplate(this.templates.linkPreview, viewModel); |
|||
} |
|||
|
|||
async removeForUser (user) { |
|||
this.log.info('removing all links for user', { |
|||
user: { |
|||
_id: user._id, |
|||
username: user.username, |
|||
}, |
|||
}); |
|||
await Link |
|||
.find({ submittedBy: user._id }) |
|||
.populate(this.populateLink) |
|||
.cursor() |
|||
.eachAsync(async (link) => { |
|||
if (link.submittedBy.length > 1) { |
|||
return Link.updateOne({ _id: link._id }, { $pull: { submittedBy: user._id } }); |
|||
} |
|||
await Link.deleteOne({ _id: link._id }); |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,294 @@ |
|||
// task.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import mongoose from 'mongoose'; |
|||
const Task = mongoose.model('Task'); |
|||
const TaskSession = mongoose.model('TaskSession'); |
|||
|
|||
import dayjs from 'dayjs'; |
|||
|
|||
import { SiteService, SiteError } from '../../lib/site-lib.js'; |
|||
|
|||
export default class TaskService extends SiteService { |
|||
|
|||
static get name ( ) { return 'TaskService'; } |
|||
static get slug () { return 'task'; } |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, TaskService); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { client: clientService, user: userService } = this.dtp.services; |
|||
|
|||
// const { jobQueue: jobQueueService } = this.dtp.services;
|
|||
// this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links);
|
|||
|
|||
this.populateTask = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'client', |
|||
populate: clientService.populateClient, |
|||
}, |
|||
{ |
|||
path: 'project', |
|||
populate: clientService.populateClientProject, |
|||
}, |
|||
]; |
|||
|
|||
this.populateTaskSession = [ |
|||
{ |
|||
path: 'user', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
{ |
|||
path: 'client', |
|||
populate: clientService.populateClient, |
|||
}, |
|||
{ |
|||
path: 'project', |
|||
populate: clientService.populateClientProject, |
|||
}, |
|||
{ |
|||
path: 'task', |
|||
populate: this.populateTask, |
|||
}, |
|||
{ |
|||
path: 'screenshots.image', |
|||
} |
|||
]; |
|||
} |
|||
|
|||
async createTask (user, taskDefinition) { |
|||
const { text: textService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
const task = new Task(); |
|||
task.created = NOW; |
|||
task.user = user._id; |
|||
task.client = mongoose.Types.ObjectId.createFromHexString(taskDefinition.clientId); |
|||
task.project = mongoose.Types.ObjectId.createFromHexString(taskDefinition.projectId); |
|||
task.status = 'pending'; |
|||
task.note = textService.filter(taskDefinition.note); |
|||
|
|||
await task.save(); |
|||
|
|||
return task.toObject(); |
|||
} |
|||
|
|||
async startTask (task) { |
|||
if (task.status !== 'pending') { |
|||
throw new SiteError(400, 'The task is not in the pending state'); |
|||
} |
|||
await Task.updateOne( |
|||
{ _id: task._id }, |
|||
{ $set: { status: 'active' } }, |
|||
); |
|||
} |
|||
|
|||
async closeTask (task) { |
|||
await TaskSession |
|||
.find({ task: task._id, status: 'active' }) |
|||
.cursor() |
|||
.eachAsync(async (session) => { |
|||
await this.closeTaskSession(session); |
|||
}); |
|||
|
|||
await Task.updateOne( |
|||
{ _id: task._id }, |
|||
{ $set: { status: 'finished' } }, |
|||
); |
|||
} |
|||
|
|||
async getTasksForUser (user, options, pagination) { |
|||
const search = { user: user._id }; |
|||
|
|||
options = options || { }; |
|||
if (options.pending) { |
|||
search.status = 'pending'; |
|||
} else if (options.active) { |
|||
search.status = 'active'; |
|||
} else if (options.finished) { |
|||
search.status = 'finished'; |
|||
} |
|||
|
|||
const tasks = await Task |
|||
.find(search) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateTask) |
|||
.lean(); |
|||
|
|||
return tasks.reverse(); |
|||
} |
|||
|
|||
async getTasksForProject (project, options, pagination) { |
|||
const search = { project: project._id }; |
|||
|
|||
options = options || { }; |
|||
if (options.pending) { |
|||
search.status = 'pending'; |
|||
} else if (options.active) { |
|||
search.status = 'active'; |
|||
} else if (options.finished) { |
|||
search.status = 'finished'; |
|||
} |
|||
|
|||
const tasks = await Task |
|||
.find(search) |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateTask) |
|||
.lean(); |
|||
|
|||
return tasks.reverse(); |
|||
} |
|||
|
|||
async getTaskGridForUser (user) { |
|||
const pagination = { skip: 0, cpp: 10 }; |
|||
const pendingTasks = await this.getTasksForUser(user, { pending: true }, pagination); |
|||
const activeTasks = await this.getTasksForUser(user, { active: true }, pagination); |
|||
const finishedTasks = await this.getTasksForUser(user, { finished: true }, pagination); |
|||
return { pendingTasks, activeTasks, finishedTasks }; |
|||
} |
|||
|
|||
async getTaskGridForProject (project) { |
|||
const pagination = { skip: 0, cpp: 10 }; |
|||
const pendingTasks = await this.getTasksForProject(project, { pending: true }, pagination); |
|||
const activeTasks = await this.getTasksForProject(project, { active: true }, pagination); |
|||
const finishedTasks = await this.getTasksForProject(project, { finished: true }, pagination); |
|||
return { pendingTasks, activeTasks, finishedTasks }; |
|||
} |
|||
|
|||
async getTaskById (taskId) { |
|||
const task = await Task |
|||
.findOne({ _id: taskId }) |
|||
.populate(this.populateTask) |
|||
.lean(); |
|||
return task; |
|||
} |
|||
|
|||
async createTaskSession (task) { |
|||
const NOW = new Date(); |
|||
|
|||
if (await TaskSession.findOne({ user: task.user._id, status: 'active' })) { |
|||
throw new SiteError(401, "Can't start new session with a currently active session."); |
|||
} |
|||
|
|||
const session = new TaskSession(); |
|||
session.created = NOW; |
|||
session.lastUpdated = NOW; |
|||
session.hourlyRate = task.project.hourlyRate; |
|||
session.user = task.user._id; |
|||
session.client = task.client._id; |
|||
session.project = task.project._id; |
|||
session.task = task._id; |
|||
session.status = 'active'; |
|||
|
|||
await session.save(); |
|||
|
|||
this.log.info('task session created', { |
|||
user: { |
|||
_id: task.user._id, |
|||
username: task.user.username, |
|||
}, |
|||
}); |
|||
|
|||
return session.toObject(); |
|||
} |
|||
|
|||
async addTaskSessionScreenshot (session, file) { |
|||
const NOW = new Date(); |
|||
const { image: imageService } = this.dtp.services; |
|||
const image = await imageService.create(session.user._id, { }, file); |
|||
await TaskSession.updateOne( |
|||
{ _id: session._id }, |
|||
{ |
|||
$push: { |
|||
screenshots: { |
|||
created: NOW, |
|||
image: image._id, |
|||
}, |
|||
}, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async closeTaskSession (session) { |
|||
const NOW = new Date(); |
|||
|
|||
if (session.status !== 'active') { |
|||
throw new SiteError(400, 'The session is not currently active'); |
|||
} |
|||
|
|||
const duration = dayjs(NOW).diff(session.created, 'second'); |
|||
await TaskSession.updateOne( |
|||
{ _id: session._id }, |
|||
{ |
|||
$set: { |
|||
finished: NOW, |
|||
status: 'finished', |
|||
duration, |
|||
}, |
|||
}, |
|||
); |
|||
|
|||
await Task.updateOne( |
|||
{ _id: session.task._id }, |
|||
{ |
|||
$inc: { duration }, |
|||
}, |
|||
); |
|||
|
|||
this.log.info('task session closed', { |
|||
user: { |
|||
_id: session.user._id, |
|||
username: session.user.username, |
|||
}, |
|||
duration, |
|||
}); |
|||
} |
|||
|
|||
async getSessionsForTask (task, pagination) { |
|||
const sessions = TaskSession |
|||
.find({ task: task._id }) |
|||
.sort({ created: 1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateTaskSession) |
|||
.lean(); |
|||
return sessions; |
|||
} |
|||
|
|||
async getTaskSessionById (sessionId) { |
|||
const session = await TaskSession |
|||
.findOne({ _id: sessionId }) |
|||
.populate(this.populateTaskSession) |
|||
.lean(); |
|||
return session; |
|||
} |
|||
|
|||
async closeTaskSessionForUser (user) { |
|||
await TaskSession |
|||
.find({ user: user._id, status: 'active' }) |
|||
.populate(this.populateTaskSession) |
|||
.cursor() |
|||
.eachAsync(async (session) => { |
|||
await this.closeTaskSession(session); |
|||
}); |
|||
} |
|||
|
|||
startOfWeek (date) { |
|||
date = date || new Date(); |
|||
var diff = date.getDate() - date.getDay() + (date.getDay() === 0 ? -6 : 1); |
|||
return new Date(date.setDate(diff)); |
|||
} |
|||
} |
@ -1,2 +0,0 @@ |
|||
include member-list-item |
|||
+renderChatMemberListItem (room, member, { isHost, isGuest }) |
@ -1,105 +0,0 @@ |
|||
include ../../user/components/profile-picture |
|||
mixin renderChatMemberListItem (room, member, options) |
|||
- |
|||
options = Object.assign({ |
|||
isHost: false, |
|||
isGuest: false, |
|||
audioIndicatorActive: false, |
|||
}, options); |
|||
|
|||
var isRoomOwner = room.owner._id.equals(member._id); |
|||
var isSystemMod = user && user.flags && (user.flags.isAdmin || user.flags.isModerator); |
|||
var memberName = member.displayName || member.username; |
|||
// var isChannelMod = user && Array.isArray(message.channel.moderators) && !!message.channel.moderators.find((moderator) => moderator._id.equals(user._id)); |
|||
|
|||
li(data-member-id= member._id, data-member-username= member.username) |
|||
.uk-flex.uk-flex-middle.member-list-item |
|||
.uk-width-auto |
|||
+renderProfilePicture(member, { iconClass: 'member-profile-icon' }) |
|||
.uk-width-expand.uk-text-small |
|||
a(href=`/member/${member.username}`, uk-tooltip={ title: `Visit ${member.username}`}).uk-link-reset |
|||
.member-display-name= member.displayName || member.username |
|||
.member-username @#{member.username} |
|||
|
|||
.uk-width-auto |
|||
div(data-member-id= member._id, class={ 'indicator-active': options.audioIndicatorActive }).member-audio-indicator |
|||
i.fas.fa-volume-off |
|||
|
|||
.uk-width-auto.chat-user-menu |
|||
button(type="button").dtp-button-dropdown |
|||
i.fas.fa-ellipsis-v |
|||
div( |
|||
data-room-id= room._id, |
|||
uk-dropdown={ animation: "uk-animation-scale-up", duration: 250 }, |
|||
data-mode="click", |
|||
).dtp-chatmsg-menu |
|||
ul.uk-nav.uk-dropdown-nav |
|||
li.uk-nav-header= member.username |
|||
li |
|||
a( |
|||
href="", |
|||
data-username= member.username, |
|||
onclick="return dtp.app.mentionChatUser(event);", |
|||
) Mention |
|||
li |
|||
a( |
|||
href="", |
|||
data-room-id= room._id, |
|||
data-room-name= room.name, |
|||
data-user-id= member._id, |
|||
data-username= member.username, |
|||
onclick="return dtp.app.muteChatUser(event);", |
|||
) Mute |
|||
|
|||
if (options.isHost || options.isGuest) && !isRoomOwner |
|||
li.uk-nav-divider |
|||
if options.isHost |
|||
li |
|||
a( |
|||
href, |
|||
data-environment="ChatRoom", |
|||
data-room-id= room._id, |
|||
data-room-name= room.name, |
|||
data-user-id= member._id, |
|||
data-username= member.username, |
|||
data-display-name= member.displayName, |
|||
onclick="return dtp.app.removeRoomHost(event);", |
|||
) Remove host |
|||
if options.isGuest |
|||
li |
|||
a( |
|||
href, |
|||
data-environment="ChatRoom", |
|||
data-room-id= room._id, |
|||
data-room-name= room.name, |
|||
data-user-id= member._id, |
|||
data-username= member.username, |
|||
data-display-name= member.displayName, |
|||
onclick="return dtp.app.removeRoomGuest(event);", |
|||
) Remove guest |
|||
|
|||
if isSystemMod || isChannelMod |
|||
li.uk-nav-divider |
|||
li |
|||
a( |
|||
href="", |
|||
data-environment="ChatRoom", |
|||
data-room-id= room._id, |
|||
data-room-name= room.name, |
|||
data-user-id= member._id, |
|||
data-username= member.username, |
|||
data-display-name= member.displayName, |
|||
onclick="return dtp.app.confirmBanUserFromEnvironment(event);", |
|||
) Ban from room |
|||
|
|||
if isSystemMod |
|||
li |
|||
a( |
|||
href="", |
|||
data-room-id= room._id, |
|||
data-room-name= room.name, |
|||
data-user-id= member._id, |
|||
data-username= member.username, |
|||
data-display-name= member.displayName, |
|||
onclick="return dtp.adminApp.confirmBanUser(event);", |
|||
) Ban from #{site.name} |
@ -1,2 +0,0 @@ |
|||
include message |
|||
+renderChatMessage(message) |
@ -1,130 +0,0 @@ |
|||
include ../../link/components/preview |
|||
include ../../user/components/profile-picture |
|||
include ./reaction-bar |
|||
|
|||
mixin renderChatMessage (message) |
|||
div(data-message-id= message._id).chat-message |
|||
.uk-flex |
|||
.uk-width-auto.no-select |
|||
+renderProfilePicture(message.author, { iconClass: 'member-profile-icon' }) |
|||
.uk-width-expand |
|||
.message-attribution.uk-margin-small.no-select |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
.author-display-name= message.author.displayName || message.author.username |
|||
.uk-width-auto |
|||
.message-timestamp( |
|||
data-dtp-timestamp= message.created, |
|||
data-dtp-timestamp-format= "time", |
|||
uk-tooltip={ title: dayjs(message.created).format('MMM D, YYYY') } |
|||
)= dayjs(message.created).format('h:mm a') |
|||
|
|||
if message.content && (message.content.length > 0) |
|||
.message-content |
|||
div!= marked.parse(message.content, { renderer: fullMarkedRenderer }) |
|||
|
|||
if message.attachments |
|||
.message-attachments |
|||
if Array.isArray(message.attachments.images) && (message.attachments.images.length > 0) |
|||
div(class="uk-child-width-1-1 uk-child-width-1-2@s uk-child-width-1-3@m uk-child-width-1-4@l uk-child-width-1-5@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small |
|||
each image of message.attachments.images |
|||
a(href=`/image/${image._id}`, data-type="image", data-caption= `${image.metadata.width}x${image.metadata.height} | ${image.metadata.space.toUpperCase()} | ${image.metadata.format.toUpperCase()} | ${numeral(image.size).format('0,0.0b')}`) |
|||
img(src=`/image/${image._id}`, width= image.metadata.width, height= image.metadata.height, alt="Image attachment").image-attachment |
|||
|
|||
if Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0) |
|||
each video of message.attachments.videos |
|||
if video.flags && video.flags.fromGif |
|||
video( |
|||
data-video-id= video._id, |
|||
data-from-gif, |
|||
poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false, |
|||
disablepictureinpicture, disableremoteplayback, playsinline, muted, autoplay, loop, |
|||
).video-attachment |
|||
source(src=`/video/${video._id}/media`) |
|||
else |
|||
video( |
|||
data-video-id= video._id, |
|||
poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false, |
|||
controls, disablepictureinpicture, disableremoteplayback, playsinline, |
|||
).video-attachment |
|||
source(src=`/video/${video._id}/media`) |
|||
|
|||
if Array.isArray(message.links) && (message.links.length > 0) |
|||
each link in message.links |
|||
div(class="uk-width-large").uk-margin-small |
|||
+renderLinkPreview(link, { layout: 'responsive' }) |
|||
|
|||
+renderReactionBar(message) |
|||
|
|||
if process.env.NODE_ENV !== 'production' |
|||
.uk-margin-small.uk-text-small.uk-text-muted id:#{message._id} |
|||
|
|||
.message-menu |
|||
.uk-flex.uk-flex-middle |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji="👍️", |
|||
uk-tooltip="React with thumbs-up" |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-menu-button 👍️ |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji="👎️", |
|||
uk-tooltip="React with thumbs-down" |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-menu-button 👎️ |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji="😃", |
|||
uk-tooltip="React with smiley" |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-menu-button 😃 |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji="🤣", |
|||
uk-tooltip="React with laugh" |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-menu-button 🤣 |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji="🫡", |
|||
uk-tooltip="React with salute" |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-menu-button 🫡 |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji="🎉", |
|||
uk-tooltip="React with a tada!" |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-menu-button 🎉 |
|||
|
|||
.uk-width-auto |
|||
button(type="button").dropdown-menu |
|||
span |
|||
i.fa-solid.fa-ellipsis-vertical |
|||
div(uk-dropdown="mode: click") |
|||
ul.uk-nav.uk-dropdown-nav |
|||
if !user._id.equals(message.author._id) |
|||
li |
|||
a(href="") Reply |
|||
if user._id.equals(message.author._id) |
|||
li |
|||
a(href="") Edit |
|||
li |
|||
a( |
|||
href="", |
|||
data-message-id= message._id, |
|||
onclick="return dtp.app.deleteChatMessage(event);", |
|||
) Delete |
@ -1,3 +0,0 @@ |
|||
include ../../components/library |
|||
include reaction-bar |
|||
+renderReactionBar(message) |
@ -1,18 +0,0 @@ |
|||
mixin renderReactionBar (message) |
|||
.message-reaction-bar |
|||
if Array.isArray(message.reactions) && (message.reactions.length > 0) |
|||
.uk-flex.uk-flex-middle |
|||
each reaction of message.reactions |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-message-id= message._id, |
|||
data-emoji= reaction.emoji, |
|||
onclick="return dtp.app.toggleMessageReaction(event);", |
|||
).message-react-button |
|||
.uk-flex.uk-flex-middle |
|||
span.reaction-emoji= reaction.emoji |
|||
span= formatCount(reaction.users.length) |
|||
div(uk-dropdown="mode: hover") |
|||
span.uk-margin-small-right= reaction.emoji |
|||
span= reaction.users.map((user) => user.username).join(',') |
@ -1,21 +0,0 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
.uk-section.uk-section-default |
|||
.uk-container |
|||
|
|||
form(method="POST", action="/chat/room").uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title Create Room |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name") Room Name |
|||
input(id="name", name="name", type="text", placeholder="Enter room name").uk-input |
|||
.uk-margin |
|||
label(for="topic") Topic |
|||
input(id="topic", name="topic", type="text", placeholder="Enter room topic or leave blank").uk-input |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Room |
@ -1,21 +0,0 @@ |
|||
button(type="button", uk-close).uk-modal-close-default |
|||
form( |
|||
method="POST", |
|||
action=`/chat/room/${room._id}/invite`, |
|||
onsubmit="return dtp.app.submitForm(event, 'invite room member');", |
|||
).uk-form |
|||
.uk-card.uk-card-secondary.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title Invite New Member |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="username").uk-form-label Username |
|||
input(id="username", name="usernameOrEmail", type="text", placeholder="Enter username or email address").uk-input |
|||
.uk-margin |
|||
label(for="message").uk-form-Label Message |
|||
textarea(id="message", name="message", rows="3", placeholder="Enter message to new member").uk-textarea.uk-resize-vertical= "Join my room!" |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Invite Member |
@ -1,56 +0,0 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
.uk-section.uk-section-default |
|||
.uk-container |
|||
|
|||
form(method="POST", action= `/chat/room/${room._id}/settings`).uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title Room Settings |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name") Room Name |
|||
input(id="name", name="name", type="text", placeholder="Enter room name", value= room.name).uk-input |
|||
.uk-margin |
|||
label(for="topic") Topic |
|||
input(id="topic", name="topic", type="text", placeholder="Enter room topic or leave blank", value= room.topic).uk-input |
|||
.uk-margin |
|||
label(for="expireDays") Message expiration |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-large |
|||
input( |
|||
id="expire-days", |
|||
name="expireDays", |
|||
type="range", |
|||
min= 1, |
|||
max= 30, |
|||
step= 1, |
|||
value= room.settings.expireDays, |
|||
oninput= "return updateExpireDays(event);", |
|||
).uk-range |
|||
.uk-width-auto |
|||
div(id="expire-days-display") #{room.settings.expireDays} days |
|||
|
|||
.uk-card-footer |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
a(href=`/chat/room/${room._id}`).uk-button.uk-button-defalt.uk-border-rounded Back to room |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-room-id= room._id, |
|||
data-room-name= room.name, |
|||
onclick="dtp.app.confirmRoomDelete(event);", |
|||
).uk-button.uk-button-danger.uk-border-rounded Delete Room |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Save Settings |
|||
block viewjs |
|||
script. |
|||
const expireDaysDisplay = document.querySelector('#expire-days-display'); |
|||
function updateExpireDays (event) { |
|||
const range = event.currentTarget || event.target; |
|||
dtp.app.log.info('ChatSettingsView', 'expiration days is changing', { range }); |
|||
expireDaysDisplay.textContent = `${range.value} days`; |
|||
} |
@ -1,141 +0,0 @@ |
|||
extends ../../layout/main |
|||
block vendorcss |
|||
if user && (user.ui.theme === 'chat-light') |
|||
link(rel='stylesheet', href=`/highlight.js/styles/qtcreator-light.min.css?v=${pkg.version}`) |
|||
else |
|||
link(rel='stylesheet', href=`/highlight.js/styles/obsidian.min.css?v=${pkg.version}`) |
|||
block view-content |
|||
|
|||
include ../components/message |
|||
include ../components/member-list-item |
|||
|
|||
mixin renderLiveMember (member) |
|||
div(data-user-id= member._id, data-username= member.username).stage-live-member |
|||
video(poster="/img/default-poster.png", disablepictureinpicture, disableremoteplayback) |
|||
.uk-flex.live-meta.no-select |
|||
.live-username.uk-width-expand |
|||
.uk-text-truncate= member.displayName || member.username |
|||
.uk-width-auto |
|||
i.fa-solid.fa-volume-off |
|||
.uk-width-auto |
|||
.uk-margin-small-left |
|||
i.fa-solid.fa-cog |
|||
|
|||
block view-navbar |
|||
|
|||
div( |
|||
ondragenter="return dtp.app.onDragEnter(event);", |
|||
ondragleave="return dtp.app.onDragLeave(event);", |
|||
ondragover="return dtp.app.onDragOver(event);", |
|||
ondrop="return dtp.app.onDrop(event);", |
|||
).dtp-chat-stage |
|||
#room-member-panel.chat-sidebar |
|||
.chat-stage-header |
|||
div(uk-grid).uk-grid-small.uk-grid-middle |
|||
.uk-width-expand |
|||
.uk-text-truncate Active Members |
|||
.uk-width-auto |
|||
.chat-present-count.uk-text-small --- |
|||
.sidebar-panel |
|||
ul(id="chat-active-members", data-room-id= room._id).uk-list.uk-list-collapse |
|||
each presentMember of room.present |
|||
- |
|||
var isHost = room.owner._id.equals(user._id) |
|||
+renderChatMemberListItem(room, presentMember, { isHost: isHost, isGuest: !isHost }) |
|||
|
|||
.chat-stage-header Idle Members |
|||
.sidebar-panel |
|||
ul(id="chat-idle-members", data-room-id= room._id, hidden).uk-list.uk-list-collapse |
|||
|
|||
.chat-container |
|||
|
|||
.chat-content-panel.uk-height-1-1 |
|||
|
|||
.live-content.uk-height-1-1 |
|||
.chat-stage-header |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
.uk-text-truncate= room.name |
|||
if room.owner._id.equals(user._id) |
|||
.uk-width-auto |
|||
a( |
|||
href=`/chat/room/${room._id}/invite`, |
|||
uk-tooltip={ title: 'Invite people to the room' }, |
|||
onclick=`return dtp.app.showForm(event, '/chat/room/${room._id}/invite', 'chat-room-invite')`, |
|||
).uk-link-reset |
|||
i.fa-solid.fa-user-plus |
|||
.uk-width-auto |
|||
a(href=`/chat/room/${room._id}/settings`, uk-tooltip={ title: 'Configure room settings' }).uk-link-reset |
|||
i.fa-solid.fa-cog |
|||
.uk-width-auto |
|||
a(href="/", uk-tooltip={ title: 'Return to home' }).uk-link-reset |
|||
i.fa-solid.fa-person-through-window |
|||
|
|||
.chat-media |
|||
div(uk-grid).uk-flex-center |
|||
div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@l uk-width-1-4@xl") |
|||
+renderLiveMember(user) |
|||
|
|||
div(id="chat-message-list").chat-messages.uk-height-1-1 |
|||
- |
|||
var testMessage = { |
|||
created: new Date(), |
|||
author: user, |
|||
content: "This is the chat content panel. It should word-wrap and scroll correctly, and will be where individual chat messages will render as they arrive and are sent.", |
|||
}; |
|||
|
|||
each message of messages |
|||
+renderChatMessage(message) |
|||
|
|||
.chat-input-panel |
|||
form( |
|||
method="POST", |
|||
action=`/chat/room/${room._id}/message`, |
|||
id="chat-input-form", |
|||
data-room-id= room._id, |
|||
onsubmit="return window.dtp.app.sendChatRoomMessage(event);", |
|||
hidden= user && user.flags && user.flags.isCloaked, |
|||
enctype="multipart/form-data" |
|||
).uk-form |
|||
textarea(id="chat-input-text", name="content", rows=2).uk-textarea.uk-resize-none.uk-border-rounded |
|||
.input-button-bar |
|||
.uk-flex |
|||
.uk-width-expand |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-target="#chat-input-text", |
|||
uk-tooltip="Select emojis to add", |
|||
onclick="dtp.app.showEmojiPicker(event);", |
|||
).uk-button.uk-button-default.uk-button-small.uk-border-rounded |
|||
i.fa-regular.fa-face-smile |
|||
.uk-width-auto |
|||
.uk-form-custom |
|||
input(id="image-files", name="imageFiles", type="file", uk-tooltip="Select an image to attach") |
|||
button(type="button").uk-button.uk-button-default.uk-button-small.uk-border-rounded |
|||
span |
|||
i.fa-regular.fa-image |
|||
.uk-width-auto |
|||
.uk-form-custom |
|||
input(id="video-file", name="videoFiles", type="file", uk-tooltip="Select a video to attach") |
|||
button(type="button").uk-button.uk-button-default.uk-button-small.uk-border-rounded |
|||
span |
|||
i.fa-solid.fa-video |
|||
.uk-width-auto |
|||
button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-default.uk-button-small.uk-border-rounded |
|||
i.fa-regular.fa-paper-plane |
|||
|
|||
.dtp-drop-feedback |
|||
.drop-feedback-container |
|||
.uk-text-center |
|||
.feedback-icon |
|||
i.fa-solid.fa-cloud-arrow-up |
|||
.drop-feedback-prompt Drop items to attach them to your message. |
|||
|
|||
include ../../components/emoji-picker |
|||
|
|||
block viewjs |
|||
script. |
|||
window.dtp = window.dtp || { }; |
|||
window.dtp.room = !{JSON.stringify(room)}; |
@ -0,0 +1,22 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
form(method="POST", action="/client").uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title New Client |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name").uk-form-label Client name |
|||
input(id="name", name="name", type="text", placeholder="Enter client name").uk-input |
|||
.uk-margin |
|||
label(for="description").uk-form-label Client description |
|||
textarea(id="name", name="description", rows=4, placeholder="Enter client description").uk-textarea.uk-resize-vertical |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Client |
@ -0,0 +1,24 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
h1 Clients |
|||
.uk-width-auto |
|||
a(href="/client/create").uk-button.uk-button-default.uk-border-rounded Create Client |
|||
|
|||
.uk-margin |
|||
if Array.isArray(clients) && (clients.length > 0) |
|||
ul.uk-list.uk-list-divider |
|||
each client in clients |
|||
li |
|||
a(href=`/client/${client._id}`).uk-link-reset.uk-display-block |
|||
.uk-text-lead= client.name |
|||
div= client.description || 'No description' |
|||
|
|||
else |
|||
div There are no clients |
@ -0,0 +1,29 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
form(method="POST", action= `/client/${client._id}/project`).uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand |
|||
h1.uk-card-title New Project |
|||
.uk-width-auto |
|||
div client: #{client.name} |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name").uk-form-label Project name |
|||
input(id="name", name="name", type="text", placeholder="Enter client name").uk-input |
|||
.uk-margin |
|||
label(for="description").uk-form-label Project description |
|||
textarea(id="name", name="description", rows=4, placeholder="Enter client description").uk-textarea.uk-resize-vertical |
|||
.uk-margin |
|||
label(for="hourly-rate").uk-form-label Hourly rate |
|||
input(id="hourly-rate", name="hourlyRate", type="number", placeholder="Enter hourly rate").uk-textarea.uk-resize-vertical |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Project |
@ -0,0 +1,34 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
include ../../task/components/grid |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand |
|||
h1.uk-margin-remove= project.name |
|||
.uk-width-auto |
|||
.uk-text-bold= client.name |
|||
.uk-text-small #{numeral(project.hourlyRate).format('$0,0.00')}/hr |
|||
|
|||
if project.description |
|||
div= project.description |
|||
|
|||
.uk-margin-medium |
|||
form(method="POST", action="/task").uk-form |
|||
input(type="hidden", name="clientId", value= client._id) |
|||
input(type="hidden", name="projectId", value= project._id) |
|||
.uk-card.uk-card-secondary.uk-card-small.uk-border-rounded |
|||
.uk-card-body.uk-border-rounded |
|||
label(for="note").uk-form-label.sr-only Task note |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
input(id="note", name="note", placeholder="What will you be working on?").uk-input.uk-border-rounded |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-default.uk-border-rounded Create Task |
|||
|
|||
.uk-margin-medium |
|||
+renderTaskGrid(taskGrid.pendingTasks, taskGrid.activeTasks, taskGrid.finishedTasks) |
@ -0,0 +1,26 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin-medium |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
h1.uk-margin-remove= client.name |
|||
if client.description |
|||
div= client.description |
|||
.uk-width-auto |
|||
a(href=`/client/${client._id}/project/create`).uk-button.uk-button-default.uk-border-rounded Add Project |
|||
|
|||
.uk-margin |
|||
h2 Projects |
|||
if Array.isArray(projects) && (projects.length > 0) |
|||
ul.uk-list.uk-list-divided |
|||
each project in projects |
|||
li |
|||
a(href=`/client/${client._id}/project/${project._id}`).uk-link-reset.uk-display-block |
|||
.uk-text-bold= project.name |
|||
.uk-text-small= project.description || 'No description' |
|||
else |
|||
div There are no projects |
@ -1,84 +1,12 @@ |
|||
extends layout/main |
|||
block view-content |
|||
|
|||
mixin renderRoomListEntry (room) |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
a(href=`/chat/room/${room._id}`).uk-link-reset |
|||
.uk-text-bold= room.name |
|||
.uk-text-small= room.topic || '(no topic assigned)' |
|||
.uk-width-auto |
|||
.uk-text-small Active |
|||
.uk-text-bold(class={ |
|||
'uk-text-success': (room.stats.presentCount > 0), |
|||
'uk-text-muted': (room.stats.presentCount === 0), |
|||
})= room.stats.presentCount |
|||
.uk-width-auto |
|||
.uk-text-small Members |
|||
.uk-text-bold= room.stats.memberCount |
|||
include task/components/grid |
|||
|
|||
section.uk-section.uk-section-default |
|||
.uk-container |
|||
|
|||
div(uk-grid) |
|||
if Array.isArray(invites) && (invites.length > 0) |
|||
div(class="uk-width-1-1 uk-width-1-3@m") |
|||
.uk-margin-medium |
|||
.uk-background-secondary.uk-light.uk-padding-small.uk-border-rounded |
|||
h4 Room Invites |
|||
ul.uk-list |
|||
each invite in invites |
|||
li(data-invite-id= invite._id) |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
.uk-text-bold.uk-text-truncate= invite.room.name |
|||
.uk-text-small @#{invite.owner.username} #{dayjs(invite.created).fromNow()} |
|||
|
|||
.uk-width-auto |
|||
a( |
|||
href="", |
|||
data-room-id= invite.room._id, |
|||
data-invite-id= invite._id, |
|||
data-invite-action= "accepted", |
|||
onclick="return dtp.app.processRoomInvite(event)", |
|||
).uk-text-success |
|||
i.fa-solid.fa-check |
|||
|
|||
.uk-width-auto |
|||
a( |
|||
href="", |
|||
data-room-id= invite.room._id, |
|||
data-invite-id= invite._id, |
|||
data-invite-action= "rejected", |
|||
onclick="return dtp.app.processRoomInvite(event)", |
|||
).uk-text-danger |
|||
i.fa-solid.fa-times |
|||
|
|||
.uk-width-expand |
|||
.uk-margin-medium |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
.uk-text-lead YOUR ROOMS |
|||
.uk-width-auto |
|||
a(href="/chat/room/create").uk-button.uk-button-default.uk-border-rounded |
|||
span |
|||
i.fas.fa-plus |
|||
span.uk-margin-small-left Create Room |
|||
|
|||
if (Array.isArray(ownerRooms) && (ownerRooms.length > 0)) |
|||
ul.uk-list |
|||
each room in ownerRooms |
|||
li.uk-list-divider |
|||
+renderRoomListEntry(room) |
|||
else |
|||
p You don't own any rooms. |
|||
|
|||
.uk-margin-medium |
|||
.uk-text-lead ROOM MEMBERSHIPS |
|||
if (Array.isArray(memberRooms) && (memberRooms.length > 0)) |
|||
ul.uk-list |
|||
each room in memberRooms |
|||
li.uk-list-divider |
|||
+renderRoomListEntry(room) |
|||
else |
|||
p You haven't joined any rooms that you don't own. |
|||
+renderTaskGrid( |
|||
taskGrid.pendingTasks, |
|||
taskGrid.activeTasks, |
|||
taskGrid.finishedTasks, |
|||
) |
@ -1,2 +0,0 @@ |
|||
include preview |
|||
+renderLinkPreview(link) |
@ -1,57 +0,0 @@ |
|||
mixin renderLinkPreview (link, options) |
|||
- |
|||
options = Object.assign({ layout: 'responsive' }, options); |
|||
function proxyUrl (url) { |
|||
return `/image/proxy?url=${encodeURIComponent(url)}`; |
|||
} |
|||
|
|||
div(data-link-id= link._id).link-container |
|||
case link.mediaType |
|||
when 'video.other' |
|||
.link-preview |
|||
if !link.oembed |
|||
pre= JSON.stringify(link, null, 2) |
|||
else |
|||
if link.oembed.html |
|||
div!= link.oembed.html |
|||
else |
|||
.uk-margin-small |
|||
a(href= link.url, target="_blank", uk-tooltip={ title: `Watch ${link.title} on ${link.oembed.provider_name}` }).uk-link-reset |
|||
img(src= link.images[0]) |
|||
.uk-margin-small |
|||
.uk-text-lead.uk-text-truncate |
|||
a(href= link.url, target="_blank", uk-tooltip={ title: `Watch ${link.title} on ${link.oembed.provider_name}` }).uk-link-reset= link.title |
|||
.uk-text-small author: #[a(href= link.oembed.author_url, target="_blank", uk-tooltip={ title: `Visit ${link.oembed.author_name} on ${link.oembed.provider_name}` })= link.oembed.author_name] |
|||
.markdown-block.link-description.uk-text-break!= marked.parse(link.oembed.description || link.description || 'No description provided', { renderer: fullMarkedRenderer }) |
|||
|
|||
default |
|||
div(uk-grid).uk-grid-small.link-preview |
|||
if Array.isArray(link.images) && (link.images.length > 0) |
|||
div(data-layout= options.layout).uk-width-auto |
|||
a(href= link.url, |
|||
data-link-id= link._id, |
|||
onclick= "return dtp.app.visitLink(event);", |
|||
).uk-link-reset |
|||
img(src= proxyUrl(link.images[0])).link-thumbnail.uk-border-rounded |
|||
|
|||
div(class="uk-width-1-1 uk-width-expand@s") |
|||
.uk-margin-small |
|||
.uk-text-bold.uk-margin-small |
|||
a(ref= link.url, |
|||
data-link-id= link._id, |
|||
onclick= "return dtp.app.visitLink(event);", |
|||
).uk-link-reset= link.title |
|||
|
|||
.markdown-block.link-description.uk-text-break!= marked.parse(link.description || 'No description provided', { renderer: fullMarkedRenderer }) |
|||
|
|||
.uk-flex.uk-flex-middle.uk-text-small |
|||
if Array.isArray(link.favicons) && (link.favicons.length > 0) |
|||
.uk-width-auto |
|||
img( |
|||
src= proxyUrl(link.favicons[0]), |
|||
style="height: 1em; width: auto;", |
|||
onerror=`this.src = '/img/icon/globe-icon.svg';`, |
|||
) |
|||
.uk-width-expand |
|||
.uk-margin-small-left |
|||
a(href=`//${link.domain}`, target="_blank", uk-tooltip={ title: link.mediaType })= link.siteName || link.domain |
@ -1,67 +0,0 @@ |
|||
extends ../layout/main |
|||
block vendorcss |
|||
if user && (user.ui.theme === 'chat-light') |
|||
link(rel='stylesheet', href=`/highlight.js/styles/qtcreator-light.min.css?v=${pkg.version}`) |
|||
else |
|||
link(rel='stylesheet', href=`/highlight.js/styles/obsidian.min.css?v=${pkg.version}`) |
|||
block content |
|||
|
|||
include ../member/components/status |
|||
include ../components/pagination-bar |
|||
include ../user/components/user-icon |
|||
|
|||
include components/preview |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
h1 |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
+renderBackButton() |
|||
.uk-width-expand |
|||
span Link Timeline |
|||
|
|||
div(uk-grid) |
|||
div(class="uk-width-1-1 uk-width-1-3@s") |
|||
.uk-margin |
|||
+renderLinkPreview(link, { layout: 'sidebar' }) |
|||
|
|||
.uk-card.uk-card-secondary.uk-card-small.uk-border-rounded |
|||
.uk-card-body |
|||
.uk-margin-small |
|||
.uk-text-small Link URL |
|||
.uk-text-bold.uk-text-break= link.url |
|||
|
|||
.uk-margin-small |
|||
.uk-text-small Site |
|||
.uk-text-bold= link.siteName || link.domain |
|||
|
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-expand |
|||
.uk-text-small Last shared |
|||
.uk-text-bold= moment(link.lastShared).format('MMM DD, YYYY') |
|||
.uk-width-auto |
|||
.uk-text-small Shares |
|||
.uk-text-bold= formatCount(link.stats.shareCount) |
|||
.uk-width-auto |
|||
.uk-text-small Visits |
|||
.uk-text-bold= formatCount(link.stats.visitCount) |
|||
|
|||
.uk-margin-small |
|||
.uk-text-small Submitted by: |
|||
div(uk-grid).uk-grid-small |
|||
each submitter in link.submittedBy |
|||
.uk-width-auto |
|||
a(href=`/member/${submitter.username}`, uk-tooltip={ title: submitter.displayName || submitter.username }) |
|||
+renderUserIcon(submitter) |
|||
|
|||
div(class="uk-width-1-1 uk-width-2-3@s") |
|||
if Array.isArray(timeline.statuses) && (timeline.statuses.length > 0) |
|||
each status in timeline.statuses |
|||
+renderStatus(status, { statusToken, commentToken }) |
|||
else |
|||
.uk-text-center #{site.name} has no remaining posts sharing this link. |
|||
|
|||
+renderPaginationBar(timelineUrl, timeline.totalStatusCount) |
@ -0,0 +1,23 @@ |
|||
include list |
|||
mixin renderTaskGrid (pendingTasks, activeTasks, finishedTasks) |
|||
div(uk-grid).uk-grid-divider |
|||
div(class="uk-width1-1 uk-width-1-3@m") |
|||
h3 Pending Tasks |
|||
if Array.isArray(pendingTasks) && (pendingTasks.length > 0) |
|||
+renderTaskList(pendingTasks) |
|||
else |
|||
div No pending tasks |
|||
|
|||
div(class="uk-width1-1 uk-width-1-3@m") |
|||
h3 Active Tasks |
|||
if Array.isArray(activeTasks) && (activeTasks.length > 0) |
|||
+renderTaskList(activeTasks) |
|||
else |
|||
div No active tasks |
|||
|
|||
div(class="uk-width1-1 uk-width-1-3@m") |
|||
h3 Finished Tasks |
|||
if Array.isArray(finishedTasks) && (finishedTasks.length > 0) |
|||
+renderTaskList(finishedTasks) |
|||
else |
|||
div No finished tasks |
@ -0,0 +1,11 @@ |
|||
mixin renderTaskList (tasks) |
|||
ul.uk-list.uk-list-divider |
|||
each task in tasks |
|||
li |
|||
a(href=`/task/${task._id}`).uk-display-block.uk-link-reset |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-expand= task.note |
|||
.uk-width-auto |
|||
.uk-text-small= numeral(task.duration).format('00:00:00') |
|||
.uk-text-small.uk-text-muted |
|||
span #{task.project.name}, #{task.client.name} |
@ -0,0 +1,55 @@ |
|||
extends ../../layout/main |
|||
block view-content |
|||
|
|||
include ../../user/components/profile-picture |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin-medium |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfilePicture(user) |
|||
.uk-width-expand |
|||
.uk-text-large= user.displayName || user.username |
|||
div Work session #[code #{session._id}] for task "#{task.note}" |
|||
.uk-width-auto.uk-text-right |
|||
div= numeral(session.duration).format('HH:MM:SS') |
|||
.uk-text-small #{numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,0.00')} |
|||
|
|||
.uk-margin-medium |
|||
.uk-flex.uk-flex-between.uk-flex-wrap |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Created |
|||
div= dayjs(session.created).format('MMM DD, YYYY [at] hh:mm:ss a') |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Last Updated |
|||
div= dayjs(session.lastUpdated).format('MMM DD, YYYY [at] hh:mm:ss a') |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Finished |
|||
div= dayjs(session.finished).format('MMM DD, YYYY [at] hh:mm:ss a') |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Project |
|||
div= session.project.name |
|||
.uk-width-auto |
|||
.uk-margin |
|||
.uk-text-small.uk-text-bold Client |
|||
div= session.client.name |
|||
|
|||
.uk-margin-medium |
|||
if Array.isArray(session.screenshots) && (session.screenshots.length > 0) |
|||
h3 Screenshots |
|||
div(class="uk-child-width-1-1 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl", uk-grid, uk-lightbox="animation: slide").uk-grid-small |
|||
each screenshot in session.screenshots |
|||
a(href=`/image/${screenshot.image._id}`, |
|||
data-type="image", |
|||
data-caption= `${dayjs(screenshot.created).format('MMM DD [at] h:mm a')} | ${screenshot.image.metadata.format.toUpperCase()} | ${numeral(screenshot.image.size).format('0,0.0b')}`, |
|||
).uk-link-reset |
|||
img(src=`/image/${screenshot.image._id}`, width= screenshot.image.metadata.width, height= screenshot.image.metadata.height, alt="Image attachment") |
|||
.uk-text-small.uk-text-muted= dayjs(screenshot.created).format('MMM DD [at] h:mm a') |
|||
else |
|||
div No screenshots were filed by the session. |
@ -0,0 +1,75 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
include ../user/components/profile-picture |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin |
|||
div(uk-grid) |
|||
div(class="uk-width-1-1 uk-width-expand@m") |
|||
.uk-margin-medium |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfilePicture(user) |
|||
.uk-width-expand |
|||
div(style="line-height: 1;").uk-text-lead.uk-text-truncated.uk-margin-small= task.note |
|||
case task.status |
|||
when 'pending' |
|||
.uk-margin-small |
|||
form(method="POST", action=`/task/${task._id}/start`).uk-form |
|||
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Start Task |
|||
when 'active' |
|||
.uk-margin-small |
|||
form(method="POST", action=`/task/${task._id}/close`).uk-form |
|||
button(type="submit").uk-button.uk-button-default.uk-button-small.uk-border-rounded Finish Task |
|||
when 'finished' |
|||
.uk-text-success Finished task |
|||
|
|||
if task.status === 'active' |
|||
.uk-width-auto |
|||
.pretty.p-switch.p-slim |
|||
input( |
|||
id="active-toggle", |
|||
type="checkbox", |
|||
data-task-id= task._id, |
|||
onchange="return dtp.app.taskActivityToggle(event);", |
|||
) |
|||
.state.p-success |
|||
label(for="active-toggle") |
|||
span.sr-only Active |
|||
#current-session-duration( |
|||
uk-tooltip="Current session duration (estimated)", |
|||
).uk-text-small= numeral(0).format('HH:MM:SS') |
|||
|
|||
.uk-margin-medium |
|||
h3 Work Sessions |
|||
if Array.isArray(sessions) && (sessions.length > 0) |
|||
ul.uk-list.uk-list-divider |
|||
each session in sessions |
|||
li |
|||
a(href=`/task/${task._id}/session/${session._id}`, target="timetrackview").uk-link-reset.uk-display-block |
|||
div(uk-grid) |
|||
.uk-width-expand= dayjs(session.created).format('MMM DD, YYYY') |
|||
.uk-width-auto= numeral(session.hourlyRate * (session.duration / 60 / 60)).format('$0,00.00') |
|||
.uk-width-auto= numeral(session.duration).format('HH:MM:SS') |
|||
else |
|||
div No work sessions |
|||
|
|||
if task.status === 'active' |
|||
div(class="uk-width-1-1 uk-width-large@m") |
|||
.uk-margin |
|||
video( |
|||
id="capture-preview", |
|||
poster="/img/default-poster.svg", |
|||
playsinline, muted, |
|||
).dtp-video |
|||
.uk-margin.uk-text-small.uk-text-muted |
|||
p One image will be captured from this live preview every 10 minutes. It will be uploaded and stored in the work session with a timestamp. |
|||
p When you start a work session, you will select the screen, application, or browser tab to share. |
|||
|
|||
block viewjs |
|||
script. |
|||
window.dtp = window.dtp || { }; |
|||
window.dtp.task = !{JSON.stringify(task)}; |
@ -0,0 +1,12 @@ |
|||
extends ../layout/main |
|||
block view-content |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
|
|||
.uk-margin-medium |
|||
h1.uk-margin-remove Welcome to #{site.name} |
|||
.uk-text-bold= site.description |
|||
|
|||
.uk-margin |
|||
pre= JSON.stringify(user, null, 2) |
@ -1,280 +0,0 @@ |
|||
// host-services.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import 'dotenv/config'; |
|||
|
|||
import path, { dirname } from 'path'; |
|||
import fs from 'node:fs'; |
|||
import readline from 'node:readline'; |
|||
|
|||
import { SiteRuntime } from '../../lib/site-lib.js'; |
|||
|
|||
import { CronJob } from 'cron'; |
|||
const CRON_TIMEZONE = 'America/New_York'; |
|||
|
|||
import { Readable, pipeline } from 'node:stream'; |
|||
import { promisify } from 'node:util'; |
|||
const streamPipeline = promisify(pipeline); |
|||
|
|||
class ChatLinksService extends SiteRuntime { |
|||
|
|||
static get name ( ) { return 'ChatLinksWorker'; } |
|||
static get slug ( ) { return 'chatLinks'; } |
|||
|
|||
static get BLOCKLIST_URL ( ) { return 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn/hosts'; } |
|||
|
|||
constructor (rootPath) { |
|||
super(ChatLinksService, rootPath); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const mongoose = await import('mongoose'); |
|||
this.Link = mongoose.model('Link'); |
|||
|
|||
this.viewModel = { }; |
|||
await this.populateViewModel(this.viewModel); |
|||
|
|||
this.blacklist = { |
|||
porn: path.join(this.config.root, 'data', 'blacklist', 'porn'), |
|||
}; |
|||
|
|||
/* |
|||
* Bull Queue job processors |
|||
*/ |
|||
|
|||
this.log.info('registering link-ingest job processor', { config: this.config.jobQueues.links }); |
|||
this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links); |
|||
this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this)); |
|||
|
|||
/* |
|||
* Cron jobs |
|||
*/ |
|||
|
|||
const cronBlacklistUpdate = '0 0 3 * * *'; // Ever day at 3:00 a.m.
|
|||
this.log.info('created URL blacklist update cron', { cronBlacklistUpdate }); |
|||
this.updateBlacklistJob = new CronJob( |
|||
cronBlacklistUpdate, |
|||
this.updateUrlBlacklist.bind(this), |
|||
null, |
|||
true, |
|||
CRON_TIMEZONE, |
|||
); |
|||
} |
|||
|
|||
async shutdown ( ) { |
|||
this.log.alert('ChatLinksWorker shutting down'); |
|||
await super.shutdown(); |
|||
} |
|||
|
|||
async ingestLink (job) { |
|||
const { link: linkService, user: userService } = this.services; |
|||
this.log.info('received link ingest job', { data: job.data }); |
|||
|
|||
try { |
|||
if (!job.data.submitterId) { |
|||
this.log.error('link ingest submitted without submitterId'); |
|||
return; |
|||
} |
|||
|
|||
job.data.submitter = await userService.getUserAccount(job.data.submitterId); |
|||
if (!job.data.submitter) { |
|||
this.log.error('link submitted with invalid User', { submitterId: job.data.submitterId }); |
|||
return; |
|||
} |
|||
|
|||
/* |
|||
* Is the submitter blocked from sharing links? |
|||
*/ |
|||
if (!job.data.submitter.permissions.canShareLinks) { |
|||
this.log.alert('Submitter is not permitted to share links', { |
|||
submitter: { |
|||
_id: job.data.submitter._id, |
|||
username: job.data.submitter.username, |
|||
}, |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
this.log.info('fetching link from database'); |
|||
job.data.link = await linkService.getById(job.data.linkId); |
|||
if (!job.data.link) { |
|||
this.log.error('link not found in database', { linkId: job.data.linkId }); |
|||
return; |
|||
} |
|||
|
|||
/* |
|||
* Is the domain or URL already known to be blocked? |
|||
*/ |
|||
if (job.data.link.flags && job.data.link.flags.isBlocked) { |
|||
this.log.alert('aborting ingest of blocked link', { |
|||
submitter: { |
|||
_id: job.data.submitter._id, |
|||
username: job.data.submitter.username, |
|||
}, |
|||
domain: job.data.link.domain, |
|||
url: job.data.link.url, |
|||
}); |
|||
return; |
|||
} |
|||
|
|||
/* |
|||
* Is the domain currently blocked? |
|||
*/ |
|||
const isDomainBlocked = await linkService.isDomainBlocked(job.data.link.domain); |
|||
if (isDomainBlocked) { |
|||
/* |
|||
* Make sure the flag is set on the Link |
|||
*/ |
|||
await this.Link.updateOne( |
|||
{ _id: job.data.link._id }, |
|||
{ |
|||
$set: { |
|||
'flags.isBlocked': true, |
|||
}, |
|||
}, |
|||
); |
|||
/* |
|||
* Log the rejection |
|||
*/ |
|||
this.log.alert('prohibiting link from blocked domain', { |
|||
submitter: { |
|||
_id: job.data.submitter._id, |
|||
username: job.data.submitter.username, |
|||
}, |
|||
domain: job.data.link.domain, |
|||
url: job.data.link.url, |
|||
}); |
|||
|
|||
return; // bye!
|
|||
} |
|||
|
|||
this.log.info('fetching link preview', { |
|||
domain: job.data.link.domain, |
|||
url: job.data.link.url, |
|||
}); |
|||
|
|||
job.data.preview = await linkService.generatePagePreview(job.data.link.url); |
|||
if (!job.data.preview) { |
|||
throw new Error('failed to load link preview'); |
|||
} |
|||
|
|||
this.log.info('updating link record in Mongo', { |
|||
link: job.data.link._id, |
|||
preview: job.data.preview, |
|||
}); |
|||
job.data.link = await this.Link.findOneAndUpdate( |
|||
{ _id: job.data.link._id }, |
|||
{ |
|||
$set: { |
|||
lastPreviewFetched: job.data.preview.fetched, |
|||
title: job.data.preview.title, |
|||
siteName: job.data.preview.siteName, |
|||
description: job.data.preview.description, |
|||
tags: job.data.preview.tags, |
|||
mediaType: job.data.preview.mediaType, |
|||
contentType: job.data.preview.contentType, |
|||
images: job.data.preview.images, |
|||
videos: job.data.preview.videos, |
|||
audios: job.data.preview.audios, |
|||
favicons: job.data.preview.favicons, |
|||
oembed: job.data.preview.oembed, |
|||
'flags.havePreview': true, |
|||
}, |
|||
}, |
|||
{ new: true }, |
|||
); |
|||
job.data.link = await this.Link.populate(job.data.link, linkService.populateLink); |
|||
|
|||
this.log.info('link ingest complete', { |
|||
submitter: { |
|||
_id: job.data.submitter._id, |
|||
username: job.data.submitter.username, |
|||
}, |
|||
link: job.data.link, |
|||
}); |
|||
|
|||
if (job.data?.options?.channelId) { |
|||
const viewModel = Object.assign({ link: job.data.link }, this.viewModel); |
|||
const displayList = linkService.createDisplayList('replace-preview'); |
|||
displayList.replaceElement( |
|||
`.link-container[data-link-id="${job.data.link._id}"]`, |
|||
await linkService.renderPreview(viewModel), |
|||
); |
|||
this.emitter.to(job.data.options.channelId).emit('chat-control', { displayList }); |
|||
} |
|||
} catch (error) { |
|||
await this.log.error('failed to ingest link', { |
|||
domain: job.data.link.domain, |
|||
url: job.data.link.url, |
|||
error |
|||
}); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async updateUrlBlacklist ( ) { |
|||
try { |
|||
/* |
|||
* Fetch latest to local file |
|||
*/ |
|||
this.log.info('fetching updated domain blacklist'); |
|||
const response = await fetch(ChatLinksService.BLOCKLIST_URL); |
|||
if (!response.ok) { |
|||
throw new Error(`unexpected response ${response.statusText}`); |
|||
} |
|||
|
|||
await streamPipeline(Readable.fromWeb(response.body), fs.createWriteStream(this.blacklist.porn)); |
|||
|
|||
/* |
|||
* Read local file line-by-line with filtering and comment removal to insert |
|||
* to Redis set of blocked domains |
|||
*/ |
|||
const fileStream = fs.createReadStream(this.blacklist.porn); |
|||
const rl = readline.createInterface({ |
|||
input: fileStream, |
|||
crlfDelay: Infinity, |
|||
}); |
|||
for await (let line of rl) { |
|||
line = line.trim(); |
|||
if (line[0] === '#') { |
|||
continue; |
|||
} |
|||
const tokens = line.split(' '); |
|||
if (tokens[0] !== '0.0.0.0' || tokens[1] === '0.0.0.0') { |
|||
continue; |
|||
} |
|||
|
|||
const r = await this.redis.sadd(ChatLinksService.DOMAIN_BLACKLIST_KEY, tokens[1]); |
|||
if (r > 0) { |
|||
this.log.info('added domain to Redis blocklist', { domain: tokens[1] }); |
|||
} |
|||
} |
|||
} catch (error) { |
|||
this.log.error('failed to update domain blacklist', { error }); |
|||
// fall through
|
|||
} finally { |
|||
this.log.info('domain block list updated'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
|
|||
try { |
|||
const { fileURLToPath } = await import('node:url'); |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
const worker = new ChatLinksService(path.resolve(__dirname, '..', '..')); |
|||
await worker.start(); |
|||
|
|||
} catch (error) { |
|||
console.error('failed to start Host Cache worker', { error }); |
|||
process.exit(-1); |
|||
} |
|||
|
|||
})(); |
@ -1,70 +0,0 @@ |
|||
// chat-processor.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
import 'dotenv/config'; |
|||
|
|||
import path, { dirname } from 'path'; |
|||
|
|||
import { SiteRuntime } from '../../lib/site-lib.js'; |
|||
|
|||
import { CronJob } from 'cron'; |
|||
const CRON_TIMEZONE = 'America/New_York'; |
|||
|
|||
class ChatProcessorService extends SiteRuntime { |
|||
|
|||
static get name ( ) { return 'ChatProcessorService'; } |
|||
static get slug ( ) { return 'chatProcessor'; } |
|||
|
|||
constructor (rootPath) { |
|||
super(ChatProcessorService, rootPath); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const mongoose = await import('mongoose'); |
|||
this.ChatMessage = mongoose.model('ChatMessage'); |
|||
|
|||
/* |
|||
* Cron jobs |
|||
*/ |
|||
|
|||
const messageExpireSchedule = '0 0 * * * *'; // Every hour
|
|||
this.cronJob = new CronJob( |
|||
messageExpireSchedule, |
|||
this.expireChatMessages.bind(this), |
|||
null, |
|||
true, |
|||
CRON_TIMEZONE, |
|||
); |
|||
} |
|||
|
|||
async shutdown ( ) { |
|||
this.log.alert('ChatLinksWorker shutting down'); |
|||
await super.shutdown(); |
|||
} |
|||
|
|||
async expireChatMessages ( ) { |
|||
const { chat: chatService } = this.services; |
|||
await chatService.expireMessages(); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
|
|||
try { |
|||
const { fileURLToPath } = await import('node:url'); |
|||
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line
|
|||
|
|||
const worker = new ChatProcessorService(path.resolve(__dirname, '..', '..')); |
|||
await worker.start(); |
|||
|
|||
} catch (error) { |
|||
console.error('failed to start chat processing worker', { error }); |
|||
process.exit(-1); |
|||
} |
|||
|
|||
})(); |
@ -1,22 +0,0 @@ |
|||
.emoji-picker-display { |
|||
position: fixed; |
|||
top: 0; right: 0; bottom: 0; left: 0; |
|||
|
|||
display: none; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
background: rgba(0,0,0, 0.8); |
|||
color: #e8e8e8; |
|||
|
|||
&.picker-active { |
|||
display: flex; |
|||
} |
|||
|
|||
.emoji-picker-prompt { |
|||
margin-bottom: 10px; |
|||
font-size: 1.5em; |
|||
color: #e8e8e8; |
|||
} |
|||
} |
@ -1,36 +0,0 @@ |
|||
|
|||
|
|||
.link-container { |
|||
box-sizing: border-box; |
|||
|
|||
padding: 5px; |
|||
background-color: @link-container-bgcolor; |
|||
|
|||
// border-top: solid 1px red; |
|||
// border-right: solid 1px red; |
|||
// border-bottom: solid 1px red; |
|||
border-left: solid 4px @link-container-border-color; |
|||
|
|||
border-radius: 5px; |
|||
|
|||
.link-preview { |
|||
|
|||
img.link-thumbnail { |
|||
width: 180px; |
|||
height: auto; |
|||
} |
|||
|
|||
.link-description { |
|||
line-height: 1.15em; |
|||
max-height: 4.65em; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
iframe { |
|||
aspect-ratio: 16 / 9; |
|||
height: auto; |
|||
width: 100%; |
|||
max-height: 540px; |
|||
} |
|||
} |
|||
} |
@ -1,371 +0,0 @@ |
|||
.dtp-chat-stage { |
|||
position: fixed; |
|||
top: 0; right: 0; bottom: 0; left: 0; |
|||
|
|||
display: flex; |
|||
|
|||
.chat-stage-header { |
|||
flex-grow: 0; |
|||
flex-shrink: 0; |
|||
|
|||
padding: @stage-panel-padding; |
|||
|
|||
font-size: 0.9em; |
|||
|
|||
background-color: @stage-header-bgcolor; |
|||
color: @stage-header-color; |
|||
} |
|||
|
|||
.chat-sidebar { |
|||
box-sizing: border-box; |
|||
width: 240px; |
|||
flex-shrink: 0; |
|||
flex-grow: 0; |
|||
|
|||
background-color: @chat-sidebar-bgcolor; |
|||
color: @chat-sidebar-color; |
|||
|
|||
overflow-y: auto; |
|||
overflow-x: hidden; |
|||
|
|||
.sidebar-panel { |
|||
box-sizing: border-box; |
|||
padding: @stage-panel-padding; |
|||
margin-bottom: 10px; |
|||
color: inherit; |
|||
} |
|||
} |
|||
|
|||
img.member-profile-icon { |
|||
width: 32px; |
|||
height: auto; |
|||
border-radius: 5px; |
|||
margin-right: 10px; |
|||
} |
|||
|
|||
.member-list-item { |
|||
line-height: 1.1em; |
|||
|
|||
.member-display-name { |
|||
line-height: 1.1em; |
|||
} |
|||
.member-username { |
|||
line-height: 1em; |
|||
} |
|||
} |
|||
|
|||
.chat-container { |
|||
box-sizing: border-box; |
|||
|
|||
display: flex; |
|||
flex-grow: 1; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
overflow: none; |
|||
|
|||
background-color: @chat-container-bgcolor; |
|||
color: @chat-container-color; |
|||
|
|||
.chat-content-panel { |
|||
box-sizing: border-box; |
|||
|
|||
display: flex; |
|||
flex-direction: column; |
|||
flex: 1; |
|||
|
|||
.live-content { |
|||
box-sizing: border-box; |
|||
display: flex; |
|||
flex-direction: column; |
|||
flex: 1; |
|||
|
|||
&.with-media { |
|||
|
|||
& .chat-media { |
|||
display: block; |
|||
} |
|||
|
|||
.chat-messages { |
|||
flex-grow: 0; |
|||
flex-shrink: 0; |
|||
width: 400px; |
|||
} |
|||
} |
|||
|
|||
.chat-media { |
|||
box-sizing: border-box; |
|||
display: none; |
|||
flex-grow: 1; |
|||
|
|||
padding: @stage-panel-padding; |
|||
overflow-y: auto; |
|||
|
|||
background-color: @chat-media-bgcolor; |
|||
color: @chat-media-color; |
|||
|
|||
.stage-live-member { |
|||
padding: 4px 4px 0 4px; |
|||
|
|||
border: solid 1px @stage-live-member-color; |
|||
border-radius: 4px; |
|||
|
|||
background-color: @stage-live-member-bgcolor; |
|||
color: @stage-live-member-color; |
|||
|
|||
video { |
|||
display: block; |
|||
|
|||
width: 100%; |
|||
height: auto; |
|||
aspect-ratio: 16 / 9; |
|||
|
|||
border-radius: 3px; |
|||
} |
|||
|
|||
.live-meta { |
|||
color: @stage-live-member-color; |
|||
|
|||
.live-username { |
|||
color: inherit; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.chat-messages { |
|||
box-sizing: border-box; |
|||
|
|||
flex-grow: 1; |
|||
padding: @stage-panel-padding; |
|||
overflow-y: scroll; |
|||
|
|||
.chat-message { |
|||
box-sizing: border-box; |
|||
position: relative; |
|||
|
|||
padding: 5px; |
|||
margin-bottom: 5px; |
|||
|
|||
line-height: 1; |
|||
border-radius: 4px; |
|||
|
|||
background-color: @chat-message-bgcolor; |
|||
color: @chat-message-color; |
|||
|
|||
&:hover { |
|||
.message-menu { |
|||
display: block; |
|||
} |
|||
} |
|||
|
|||
&:last-child { |
|||
margin-bottom: 0; |
|||
} |
|||
|
|||
.message-menu { |
|||
display: none; |
|||
|
|||
box-sizing: border-box; |
|||
position: absolute; |
|||
top: 5px; right: 5px; |
|||
|
|||
padding: 5px 10px; |
|||
background-color: @chat-message-menu-bgcolor; |
|||
color: @chat-message-menu-color; |
|||
|
|||
border-radius: 16px; |
|||
|
|||
button.message-menu-button { |
|||
background: none; |
|||
border: none; |
|||
outline: none; |
|||
|
|||
color: inherit; |
|||
font-size: 1.2em; |
|||
cursor: pointer; |
|||
} |
|||
button.dropdown-menu { |
|||
position: relative; |
|||
top: 2px; |
|||
padding: 0 5px; |
|||
|
|||
color: inherit; |
|||
background: none; |
|||
border: none; |
|||
outline: none; |
|||
|
|||
cursor: pointer; |
|||
font-size: 1.2em; |
|||
} |
|||
} |
|||
|
|||
&.system-message { |
|||
font-size: 0.8em; |
|||
background-color: @system-message-bgcolor; |
|||
color: @system-message-color; |
|||
border-radius: 4px; |
|||
|
|||
.message-content { |
|||
line-height: 1.1em; |
|||
margin-bottom: 2px; |
|||
} |
|||
|
|||
.message-timestamp { |
|||
font-size: 0.9em; |
|||
line-height: 1em; |
|||
color: @system-message-timestamp-color; |
|||
} |
|||
} |
|||
|
|||
.message-attribution { |
|||
|
|||
.author-display-name { |
|||
font-size: 1.05em; |
|||
font-weight: bold;; |
|||
} |
|||
|
|||
.author-username { |
|||
font-size: 0.8em; |
|||
} |
|||
|
|||
.message-timestamp { |
|||
font-size: 0.8em; |
|||
} |
|||
} |
|||
|
|||
.message-content { |
|||
|
|||
p { |
|||
line-height: 1.1em; |
|||
margin-bottom: 10px; |
|||
color: @chat-message-color; |
|||
} |
|||
|
|||
pre { |
|||
padding: 0; |
|||
background: transparent; |
|||
border: none; |
|||
|
|||
code { |
|||
padding: 5px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.message-attachments { |
|||
|
|||
img.image-attachment { |
|||
border-radius: 8px; |
|||
} |
|||
|
|||
video.video-attachment { |
|||
width: auto; |
|||
height: 240px; |
|||
border-radius: 8px; |
|||
|
|||
.controls .progress { |
|||
display: none; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.message-reaction-bar { |
|||
margin-top: 5px; |
|||
|
|||
button.message-react-button { |
|||
padding: 4px 6px; |
|||
margin-right: 5px; |
|||
|
|||
border: none; |
|||
outline: none; |
|||
border-radius: 6px; |
|||
|
|||
background: @message-react-button-bgcolor; |
|||
color: @message-react-button-color; |
|||
|
|||
span.reaction-emoji { |
|||
margin-right: 3px; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.chat-input-panel { |
|||
flex-grow: 0; |
|||
flex-shrink: 0; |
|||
|
|||
padding: @stage-panel-padding; |
|||
border-top: solid 1px @stage-border-color; |
|||
|
|||
background-color: @chat-input-panel-bgcolor; |
|||
color: @chat-input-panel-color; |
|||
|
|||
.uk-button.uk-button-default { |
|||
border: none; |
|||
outline: none; |
|||
|
|||
background-color: aliceblue; |
|||
color: #071E22; |
|||
} |
|||
|
|||
.uk-button.uk-button-primary { |
|||
border: none; |
|||
outline: none; |
|||
|
|||
background-color: blanchedalmond; |
|||
color: #071E22; |
|||
} |
|||
|
|||
.input-button-bar { |
|||
margin-top: 8px; |
|||
margin-bottom: 3px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.member-list-item { |
|||
background-color: transparent; |
|||
transition: background-color 0.25s; |
|||
padding: 5px 2px 2px 0; |
|||
line-height: 1em; |
|||
|
|||
& .member-audio-indicator { |
|||
display: none; |
|||
margin-right: 5px; |
|||
} |
|||
|
|||
&.entry-idle { |
|||
color: #8a8a8a; |
|||
} |
|||
|
|||
&.entry-active { |
|||
background-color: rgba(0, 160, 0, 0.2); |
|||
} |
|||
|
|||
&.entry-audio-active { |
|||
|
|||
& .member-audio-indicator { |
|||
color: #00ff00; |
|||
display: block; |
|||
} |
|||
} |
|||
|
|||
.member-profile-icon { |
|||
width: auto; |
|||
height: 2em; |
|||
margin-right: 5px; |
|||
} |
|||
} |
|||
|
|||
.member-audio-indicator { |
|||
color: #8a8a8a; |
|||
transition: color 0.25s; |
|||
|
|||
&.indicator-active { |
|||
color: #00ff00; |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
video.dtp-video { |
|||
display: block; |
|||
|
|||
width: 100%; |
|||
height: auto; |
|||
aspect-ratio: 16 / 9; |
|||
|
|||
border-radius: 5px; |
|||
} |
After Width: | Height: | Size: 108 KiB |
Before Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 146 KiB |
After Width: | Height: | Size: 210 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 9.3 KiB |
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 547 B After Width: | Height: | Size: 716 B |
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 76 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.8 KiB |
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 123 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.6 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 4.9 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.2 KiB |
@ -1,938 +0,0 @@ |
|||
// chat-client.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
const DTP_COMPONENT_NAME = 'DtpChatApp'; |
|||
const dtp = window.dtp = window.dtp || { }; |
|||
|
|||
import DtpApp from 'lib/dtp-app.js'; |
|||
import ChatAudio from './chat-audio.js'; |
|||
|
|||
import QRCode from 'qrcode'; |
|||
import Cropper from 'cropperjs'; |
|||
|
|||
import dayjs from 'dayjs'; |
|||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; |
|||
dayjs.extend(dayjsRelativeTime); |
|||
|
|||
import hljs from 'highlight.js'; |
|||
|
|||
export class ChatApp extends DtpApp { |
|||
|
|||
static get SFX_CHAT_ROOM_CONNECT ( ) { return 'chat-room-connect'; } |
|||
static get SFX_CHAT_MESSAGE ( ) { return 'chat-message'; } |
|||
static get SFX_CHAT_REACTION ( ) { return 'reaction'; } |
|||
static get SFX_CHAT_REACTION_REMOVE ( ) { return 'reaction-remove'; } |
|||
static get SFX_CHAT_MESSAGE_REMOVE ( ) { return 'message-remove'; } |
|||
|
|||
constructor (user) { |
|||
super(DTP_COMPONENT_NAME, user); |
|||
this.loadSettings(); |
|||
this.log.info('constructor', 'DTP app client online'); |
|||
|
|||
this.notificationPermission = 'default'; |
|||
this.haveFocus = true; // hard to load the app w/o also being the focused app
|
|||
|
|||
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'), |
|||
imageFiles: document.querySelector('#image-files'), |
|||
videoFile: document.querySelector('#video-file'), |
|||
sendButton: document.querySelector('#chat-send-btn'), |
|||
isAtBottom: true, |
|||
}; |
|||
|
|||
if (this.chat.input) { |
|||
this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); |
|||
this.observer = new MutationObserver(this.onChatMessageListChanged.bind(this)); |
|||
this.observer.observe(this.chat.messageList, { childList: true }); |
|||
hljs.highlightAll(); |
|||
} |
|||
|
|||
this.emojiPickerDisplay = document.querySelector('.emoji-picker-display'); |
|||
if (this.emojiPickerDisplay) { |
|||
this.emojiPickerDisplay.addEventListener('click', this.onEmojiPickerClose.bind(this)); |
|||
} |
|||
this.emojiPicker = document.querySelector('emoji-picker'); |
|||
if (this.emojiPicker) { |
|||
this.emojiPicker.addEventListener('emoji-click', this.onEmojiPicked.bind(this)); |
|||
} |
|||
|
|||
this.pendingFiles = { |
|||
images: [ ], |
|||
videos: [ ], |
|||
}; |
|||
this.dragFeedback = document.querySelector('.dtp-drop-feedback'); |
|||
|
|||
window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); |
|||
window.addEventListener('unload', this.onDtpUnload.bind(this)); |
|||
|
|||
window.addEventListener('focus', this.onWindowFocus.bind(this)); |
|||
window.addEventListener('blur', this.onWindowBlur.bind(this)); |
|||
|
|||
this.updateTimestamps(); |
|||
} |
|||
|
|||
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 startAudio ( ) { |
|||
this.log.info('startAudio', 'starting ChatAudio'); |
|||
this.audio = new ChatAudio(); |
|||
this.audio.start(); |
|||
try { |
|||
await Promise.all([ |
|||
this.audio.loadSound(ChatApp.SFX_CHAT_ROOM_CONNECT, '/static/sfx/room-connect.mp3'), |
|||
this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE, '/static/sfx/chat-message.mp3'), |
|||
this.audio.loadSound(ChatApp.SFX_CHAT_REACTION, '/static/sfx/reaction.mp3'), |
|||
this.audio.loadSound(ChatApp.SFX_CHAT_REACTION_REMOVE, '/static/sfx/reaction-remove.mp3'), |
|||
this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE_REMOVE, '/static/sfx/message-deleted.mp3'), |
|||
]); |
|||
} catch (error) { |
|||
this.log.error('startAudio', 'failed to load sound', { error }); |
|||
// fall through
|
|||
} |
|||
} |
|||
|
|||
async onChatInputKeyDown (event) { |
|||
if (event.key === 'Enter' && !event.shiftKey) { |
|||
if (dtp.room) { |
|||
return this.sendChatRoomMessage(event); |
|||
} |
|||
if (dtp.thread) { |
|||
return this.sendPrivateMessage(event); |
|||
} |
|||
return this.sendUserChat(event); |
|||
} |
|||
} |
|||
|
|||
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 onChatMessageListChanged (mutationList) { |
|||
this.log.info('onMutation', 'DOM mutation received', { mutationList }); |
|||
if (!Array.isArray(mutationList) || (mutationList.length === 0)) { |
|||
return; |
|||
} |
|||
for (const mutation of mutationList) { |
|||
for (const node of mutation.addedNodes) { |
|||
if (typeof node.querySelectorAll !== 'function') { |
|||
continue; |
|||
} |
|||
const timestamps = node.querySelectorAll("[data-dtp-timestamp]"); |
|||
this.updateTimestamps(timestamps); |
|||
} |
|||
} |
|||
hljs.highlightAll(); |
|||
} |
|||
|
|||
updateTimestamps ( ) { |
|||
const nodeList = document.querySelectorAll("[data-dtp-timestamp]"); |
|||
this.log.debug('updateTimestamps', 'updating timestamps', { count: nodeList.length }); |
|||
for (const ts of nodeList) { |
|||
const date = ts.getAttribute('data-dtp-timestamp'); |
|||
const format = ts.getAttribute('data-dtp-timestamp-format'); |
|||
if (!date) { continue; } |
|||
switch (format) { |
|||
case 'date': |
|||
ts.textContent = dayjs(date).format('MMM DD, YYYY'); |
|||
break; |
|||
case 'time': |
|||
ts.textContent = dayjs(date).format('h:mm a'); |
|||
break; |
|||
case 'datetime': |
|||
ts.textContent = dayjs(date).format('MMM D [at] h:mm a'); |
|||
break; |
|||
case 'fuzzy': |
|||
ts.textContent = dayjs(date).fromNow(); |
|||
break; |
|||
|
|||
case 'timestamp': |
|||
default: |
|||
ts.textContent = dayjs(date).format('hh:mm:ss a'); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
async sendUserChat (event) { |
|||
event.preventDefault(); |
|||
|
|||
if (!dtp.user) { |
|||
UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); |
|||
return; |
|||
} |
|||
|
|||
if (this.chatTimeout) { |
|||
return; |
|||
} |
|||
|
|||
const channelId = dtp.user._id; |
|||
const content = this.chat.input.value; |
|||
this.chat.input.value = ''; |
|||
|
|||
if (content.length === 0) { |
|||
return true; |
|||
} |
|||
|
|||
this.log.debug('sendUserChat', 'sending chat message', { channel: this.user._id, content }); |
|||
this.socket.sendUserChat(channelId, content); |
|||
|
|||
// set focus back to chat input
|
|||
this.chat.input.focus(); |
|||
|
|||
const isFreeMember = false; |
|||
this.chat.sendButton.setAttribute('disabled', ''); |
|||
this.chat.sendButton.setAttribute('uk-tooltip', isFreeMember ? 'Waiting 30 seconds' : 'Waiting 5 seconds'); |
|||
|
|||
this.chatTimeout = setTimeout(( ) => { |
|||
delete this.chatTimeout; |
|||
this.chat.sendButton.removeAttribute('disabled'); |
|||
this.chat.sendButton.setAttribute('uk-tooltip', 'Send message'); |
|||
}, isFreeMember ? 30000 : 5000); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
async sendChatRoomMessage (event) { |
|||
if (event) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
} |
|||
|
|||
if (this.chatTimeout) { |
|||
return; |
|||
} |
|||
|
|||
const form = new FormData(this.chat.form); |
|||
const roomId = this.chat.form.getAttribute('data-room-id'); |
|||
|
|||
const content = this.chat.input.value; |
|||
this.chat.input.value = ''; |
|||
if ((content.length === 0) && |
|||
(!this.chat.imageFiles.value) && |
|||
(!this.chat.videoFile.value)) { |
|||
return true; |
|||
} |
|||
|
|||
try { |
|||
this.log.info('sendChatRoomMessage', 'sending chat message', { room: roomId, content }); |
|||
const response = await fetch(this.chat.form.action, { |
|||
method: this.chat.form.method, |
|||
body: form, |
|||
}); |
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
UIkit.modal.alert(`Failed to send chat message: ${error.message}`); |
|||
} |
|||
|
|||
// set focus back to chat input
|
|||
this.chat.imageFiles.value = null; |
|||
this.chat.videoFile.value = null; |
|||
this.chat.input.focus(); |
|||
|
|||
this.chat.sendButton.setAttribute('disabled', ''); |
|||
this.chatTimeout = setTimeout(( ) => { |
|||
delete this.chatTimeout; |
|||
this.chat.sendButton.removeAttribute('disabled'); |
|||
}, 1000); |
|||
|
|||
return true; |
|||
} |
|||
|
|||
async deleteChatMessage (event) { |
|||
const target = event.currentTarget || event.target; |
|||
const messageId = target.getAttribute('data-message-id'); |
|||
|
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
try { |
|||
const response = await fetch(`/chat/message/${messageId}`, { method: 'DELETE' }); |
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
this.log.error('deleteChatMessage', 'failed to delete chat message', { error }); |
|||
UIkit.modal.alert(`Failed to delete chat message: ${error.message}`); |
|||
} |
|||
} |
|||
|
|||
async toggleMessageReaction (event) { |
|||
const target = event.currentTarget || event.target; |
|||
const messageId = target.getAttribute('data-message-id'); |
|||
const emoji = target.getAttribute('data-emoji'); |
|||
try { |
|||
const response = await fetch(`/chat/message/${messageId}/reaction`, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
body: JSON.stringify({ emoji }), |
|||
}); |
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
this.log.error('toggleMessageReaction', 'failed to send emoji react', { error }); |
|||
UIkit.modal.alert(`Failed to send emoji react: ${error.message}`); |
|||
} |
|||
} |
|||
|
|||
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), |
|||
}); |
|||
|
|||
if (this.chat.messageList) { |
|||
try { |
|||
this.notificationPermission = await Notification.requestPermission(); |
|||
this.log.debug('onDtpLoad', 'Notification permission status', { permission: this.notificationPermission }); |
|||
} catch (error) { |
|||
this.log.error('onDtpLoad', 'failed to request Notification permission', { error }); |
|||
} |
|||
} |
|||
} |
|||
|
|||
async onDtpUnload ( ) { |
|||
await this.socket.disconnect(); |
|||
} |
|||
|
|||
async onChatSocketConnect (socket) { |
|||
this.log.debug('onSocketConnect', 'attaching socket events'); |
|||
socket.on('chat-message', this.onChatMessage.bind(this)); |
|||
socket.on('chat-control', this.onChatControl.bind(this)); |
|||
socket.on('system-message', this.onSystemMessage.bind(this)); |
|||
|
|||
if (dtp.room) { |
|||
await this.joinChatChannel(dtp.room); |
|||
} |
|||
} |
|||
|
|||
async onChatSocketDisconnect (socket) { |
|||
this.log.debug('onSocketDisconnect', 'detaching socket events'); |
|||
socket.off('chat-message', this.onChatMessage.bind(this)); |
|||
socket.off('chat-control', this.onChatControl.bind(this)); |
|||
socket.off('system-message', this.onSystemMessage.bind(this)); |
|||
} |
|||
|
|||
async onChatMessage (message) { |
|||
const isAtBottom = this.chat.isAtBottom; |
|||
this.chat.messageList.insertAdjacentHTML('beforeend', message.html); |
|||
if (!this.haveFocus) { |
|||
this.audio.playSound(ChatApp.SFX_CHAT_MESSAGE); |
|||
} |
|||
this.scrollChatToBottom(isAtBottom); |
|||
|
|||
if (!this.haveFocus && (this.notificationPermission === 'granted')) { |
|||
const chatMessage = message.message; |
|||
new Notification(chatMessage.channel.name, { |
|||
body: `Message received from ${chatMessage.author.displayName || chatMessage.author.username}`, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async onChatControl (message) { |
|||
const isAtBottom = this.chat.isAtBottom; |
|||
|
|||
if (message.audio) { |
|||
if (message.audio.playSound) { |
|||
this.audio.playSound(message.audio.playSound); |
|||
} |
|||
} |
|||
if (message.displayList) { |
|||
this.displayEngine.executeDisplayList(message.displayList); |
|||
} |
|||
|
|||
if (Array.isArray(message.systemMessages) && (message.systemMessages.length > 0)) { |
|||
for await (const sm of message.systemMessages) { |
|||
await this.onSystemMessage(sm); |
|||
} |
|||
} |
|||
|
|||
if (message.cmd) { |
|||
switch (message.cmd) { |
|||
case 'call-start': |
|||
if (message.mediaServer && !this.call) { |
|||
dtp.mediaServer = message.mediaServer; |
|||
setTimeout(this.joinWebCall.bind(this), Math.floor(Math.random() * 3000)); |
|||
} |
|||
break; |
|||
|
|||
case 'call-end': |
|||
if (this.chat) { |
|||
this.chat.closeCall(); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
this.scrollChatToBottom(isAtBottom); |
|||
} |
|||
|
|||
async onSystemMessage (message) { |
|||
if (message.displayList) { |
|||
this.displayEngine.executeDisplayList(message.displayList); |
|||
} |
|||
|
|||
if (!message.created || !message.content) { |
|||
return; |
|||
} |
|||
|
|||
if (!this.chat || !this.chat.messageList) { |
|||
return; |
|||
} |
|||
|
|||
const systemMessage = document.createElement('div'); |
|||
systemMessage.classList.add('chat-message'); |
|||
systemMessage.classList.add('system-message'); |
|||
systemMessage.classList.add('no-select'); |
|||
|
|||
const grid = document.createElement('div'); |
|||
grid.toggleAttribute('uk-grid', true); |
|||
grid.classList.add('uk-grid-small'); |
|||
systemMessage.appendChild(grid); |
|||
|
|||
let column = document.createElement('div'); |
|||
column.classList.add('uk-width-expand'); |
|||
grid.appendChild(column); |
|||
|
|||
const chatContent = document.createElement('div'); |
|||
chatContent.classList.add('message-content'); |
|||
chatContent.classList.add('uk-text-break'); |
|||
chatContent.innerHTML = message.content; |
|||
column.appendChild(chatContent); |
|||
|
|||
column = document.createElement('div'); |
|||
column.classList.add('uk-width-expand'); |
|||
grid.appendChild(column); |
|||
|
|||
const chatTimestamp = document.createElement('div'); |
|||
chatTimestamp.classList.add('message-timestamp'); |
|||
chatTimestamp.classList.add('uk-text-small'); |
|||
chatTimestamp.classList.add('uk-text-right'); |
|||
chatTimestamp.setAttribute('data-dtp-timestamp', message.created); |
|||
chatTimestamp.setAttribute('data-dtp-timestamp-format', 'time'); |
|||
chatTimestamp.innerHTML = dayjs(message.created).format('h:mm:ss a'); |
|||
column.appendChild(chatTimestamp); |
|||
|
|||
this.chat.messageList.appendChild(systemMessage); |
|||
this.chat.messages.push(systemMessage); |
|||
|
|||
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 + 50000); |
|||
} |
|||
} |
|||
|
|||
async joinChatChannel (room) { |
|||
try { |
|||
const response = await fetch(`/chat/room/${dtp.room._id}/join`); |
|||
await this.processResponse(response); |
|||
|
|||
await this.socket.joinChannel(dtp.room._id, 'ChatRoom'); |
|||
} catch (error) { |
|||
this.log.error('failed to join chat room', { room, error }); |
|||
UIkit.modal.alert(`Failed to join chat room: ${error.message}`); |
|||
} |
|||
} |
|||
|
|||
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 confirmRoomDelete (event) { |
|||
const target = event.currentTarget || event.target; |
|||
const roomId = target.getAttribute('data-room-id'); |
|||
const roomName = target.getAttribute('data-room-name'); |
|||
try { |
|||
await UIkit.modal.confirm(`Are you sure you want to delete "${roomName}"?`); |
|||
} catch (error) { |
|||
return; |
|||
} |
|||
try { |
|||
const response = await fetch(`/chat/room/${roomId}`, { method: 'DELETE' }); |
|||
await this.processResponse(response); |
|||
} catch (error) { |
|||
UIkit.modal.alert(error.message); |
|||
} |
|||
} |
|||
|
|||
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 muteChatUser (event) { |
|||
const target = (event.currentTarget || event.target); |
|||
|
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
this.closeAllDropdowns(); |
|||
|
|||
const messageId = target.getAttribute('data-message-id'); |
|||
const userId = target.getAttribute('data-user-id'); |
|||
const username = target.getAttribute('data-username'); |
|||
|
|||
try { |
|||
await UIkit.modal.confirm(`Are you sure you want to mute ${username}?`); |
|||
} catch (error) { |
|||
// canceled or error
|
|||
return; |
|||
} |
|||
|
|||
this.log.info('muteChatUser', 'muting chat user', { messageId, userId, username }); |
|||
this.mutedUsers.push({ userId, username }); |
|||
window.localStorage.mutedUsers = JSON.stringify(this.mutedUsers); |
|||
|
|||
document.querySelectorAll(`.chat-message[data-author-id="${userId}"]`).forEach((message) => { |
|||
message.parentElement.removeChild(message); |
|||
}); |
|||
} |
|||
|
|||
async unmuteChatUser (event) { |
|||
const target = (event.currentTarget || event.target); |
|||
|
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const userId = target.getAttribute('data-user-id'); |
|||
const username = target.getAttribute('data-username'); |
|||
this.log.info('muteChatUser', 'muting chat user', { userId, username }); |
|||
|
|||
this.mutedUsers = this.mutedUsers.filter((block) => block.userId !== userId); |
|||
window.localStorage.mutedUsers = JSON.stringify(this.mutedUsers); |
|||
|
|||
const entry = document.querySelector(`.chat-muted-user[data-user-id="${userId}"]`); |
|||
if (!entry) { |
|||
return; |
|||
} |
|||
entry.parentElement.removeChild(entry); |
|||
} |
|||
|
|||
async filterChatView ( ) { |
|||
if (!this.mutedUsers || (this.mutedUsers.length === 0)) { |
|||
return; |
|||
} |
|||
this.mutedUsers.forEach((block) => { |
|||
document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => { |
|||
message.parentElement.removeChild(message); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
async initSettingsView ( ) { |
|||
this.log.info('initSettingsView', 'settings', { settings: this.settings }); |
|||
|
|||
const mutedUserList = document.querySelector('ul#muted-user-list'); |
|||
for (const block of this.mutedUsers) { |
|||
const li = document.createElement(`li`); |
|||
li.setAttribute('data-user-id', block.userId); |
|||
li.classList.add('chat-muted-user'); |
|||
mutedUserList.appendChild(li); |
|||
|
|||
const grid = document.createElement('div'); |
|||
grid.setAttribute('uk-grid', ''); |
|||
grid.classList.add('uk-grid-small'); |
|||
grid.classList.add('uk-flex-middle'); |
|||
li.appendChild(grid); |
|||
|
|||
let column = document.createElement('div'); |
|||
column.classList.add('uk-width-expand'); |
|||
column.textContent = block.username; |
|||
grid.appendChild(column); |
|||
|
|||
column = document.createElement('div'); |
|||
column.classList.add('uk-width-auto'); |
|||
grid.appendChild(column); |
|||
|
|||
const button = document.createElement('button'); |
|||
button.setAttribute('type', 'button'); |
|||
button.setAttribute('title', `Remove ${block.username} from your mute list`); |
|||
button.setAttribute('data-user-id', block.userId); |
|||
button.setAttribute('data-username', block.username); |
|||
button.setAttribute('onclick', "return dtp.app.unmuteChatUser(event);"); |
|||
button.classList.add('uk-button'); |
|||
button.classList.add('uk-button-default'); |
|||
button.classList.add('uk-button-small'); |
|||
button.classList.add('uk-border-rounded'); |
|||
button.textContent = 'Unmute'; |
|||
column.appendChild(button); |
|||
} |
|||
} |
|||
|
|||
loadSettings ( ) { |
|||
this.settings = { tutorials: { } }; |
|||
if (window.localStorage) { |
|||
if (window.localStorage.settings) { |
|||
this.settings = JSON.parse(window.localStorage.settings); |
|||
} else { |
|||
this.saveSettings(); |
|||
} |
|||
this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ]; |
|||
this.filterChatView(); |
|||
} |
|||
this.settings.tutorials = this.settings.tutorials || { }; |
|||
} |
|||
|
|||
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}`); |
|||
} |
|||
} |
|||
|
|||
scrollChatToBottom (isAtBottom = true) { |
|||
if (this.chat && this.chat.messageList && isAtBottom) { |
|||
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); |
|||
setTimeout(( ) => { |
|||
this.chat.isAtBottom = true; |
|||
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); |
|||
this.chat.isModifying = false; |
|||
}, 25); |
|||
} |
|||
} |
|||
|
|||
async onChatMessageListScroll (event) { |
|||
const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight; |
|||
|
|||
if (!this.chat.isModifying) { |
|||
this.chat.isAtBottom = (scrollPos >= (this.chat.messageList.scrollHeight - 10)); |
|||
this.chat.isAtTop = (scrollPos <= 0); |
|||
} |
|||
|
|||
if (event && (this.chat.isAtBottom || this.chat.isAtTop)) { |
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
} |
|||
|
|||
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.scrollTo(0, this.chat.messageList.scrollHeight + 50000); |
|||
this.chat.isAtBottom = true; |
|||
this.chat.messageMenu.classList.remove('chat-menu-visible'); |
|||
} |
|||
|
|||
async onWindowResize ( ) { |
|||
if (this.chat.messageList && this.chat.isAtBottom) { |
|||
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000); |
|||
} |
|||
} |
|||
|
|||
async showEmojiPicker (event) { |
|||
const target = event.currentTarget || event.target; |
|||
const emojiTargetSelector = target.getAttribute('data-target'); |
|||
|
|||
this.emojiPickerTarget = document.querySelector(emojiTargetSelector); |
|||
if (!this.emojiPickerTarget) { |
|||
UIkit.modal.alert('Invalid emoji picker target'); |
|||
return; |
|||
} |
|||
|
|||
this.emojiPickerDisplay.classList.add('picker-active'); |
|||
} |
|||
|
|||
async onEmojiPickerClose (event) { |
|||
if (!this.emojiPickerDisplay) { |
|||
return; |
|||
} |
|||
if (!event.target.classList.contains('emoji-picker-display')) { |
|||
return; |
|||
} |
|||
this.emojiPickerDisplay.classList.remove('picker-active'); |
|||
} |
|||
|
|||
async onEmojiPicked (event) { |
|||
event = event.detail; |
|||
this.log.info('onEmojiPicked', 'An emoji has been selected', { event }); |
|||
this.emojiPickerTarget.value += event.unicode; |
|||
} |
|||
|
|||
async processRoomInvite (event) { |
|||
const target = event.currentTarget || event.target; |
|||
|
|||
event.preventDefault(); |
|||
event.stopPropagation(); |
|||
|
|||
const roomId = target.getAttribute('data-room-id'); |
|||
const inviteId = target.getAttribute('data-invite-id'); |
|||
const action = target.getAttribute('data-invite-action'); |
|||
|
|||
try { |
|||
const url = `/chat/room/${roomId}/invite/${inviteId}`; |
|||
const payload = JSON.stringify({ action }); |
|||
const response = await fetch(url, { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
'Content-Length': payload.length, |
|||
}, |
|||
body: payload, |
|||
}); |
|||
return this.processResponse(response); |
|||
} catch (error) { |
|||
this.log.error('processRoomInvite', 'failed to process room invite', { error }); |
|||
UIkit.modal.alert(`Failed to process room invite: ${error.message}`); |
|||
} |
|||
} |
|||
} |
@ -1,16 +1,16 @@ |
|||
// chat-audio.js
|
|||
// time-tracker-audio.js
|
|||
// Copyright (C) 2024 DTP Technologies, LLC
|
|||
// All Rights Reserved
|
|||
|
|||
'use strict'; |
|||
|
|||
const DTP_COMPONENT_NAME = 'ChatAudio'; |
|||
const DTP_COMPONENT_NAME = 'TimeTrackerAudio'; |
|||
|
|||
import DtpLog from 'lib/dtp-log'; |
|||
|
|||
const AudioContext = window.AudioContext || window.webkitAudioContext; |
|||
|
|||
export default class ChatAudio { |
|||
export default class TimeTrackerAudio { |
|||
|
|||
constructor ( ) { |
|||
this.log = new DtpLog(DTP_COMPONENT_NAME); |
@ -0,0 +1,557 @@ |
|||
// 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'); |
|||
|
|||
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) { |
|||
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 ( ) { |
|||
try { |
|||
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'); |
|||
} catch (error) { |
|||
this.log.error('startTaskSession', 'failed to start task work session', { error }); |
|||
UIkit.modal.alert(`Failed to start task work session: ${error.message}`); |
|||
throw new Error('failed to start screen capture', { cause: error }); |
|||
} |
|||
} |
|||
|
|||
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'); |
|||
} |
|||
|
|||
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 }); |
|||
} |
|||
} |
|||
} |