@ -1,2 +1,2 @@ |
|||||
DTP Chat Copyright (C) 2024 DTP Technologies, LLC |
DTP Time Tracker Copyright (C) 2024 DTP Technologies, LLC |
||||
All Rights Reserved |
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 |
extends layout/main |
||||
block view-content |
block view-content |
||||
|
|
||||
mixin renderRoomListEntry (room) |
include task/components/grid |
||||
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 |
|
||||
|
|
||||
section.uk-section.uk-section-default |
section.uk-section.uk-section-default |
||||
.uk-container |
.uk-container |
||||
|
+renderTaskGrid( |
||||
div(uk-grid) |
taskGrid.pendingTasks, |
||||
if Array.isArray(invites) && (invites.length > 0) |
taskGrid.activeTasks, |
||||
div(class="uk-width-1-1 uk-width-1-3@m") |
taskGrid.finishedTasks, |
||||
.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. |
|
@ -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
|
// Copyright (C) 2024 DTP Technologies, LLC
|
||||
// All Rights Reserved
|
// All Rights Reserved
|
||||
|
|
||||
'use strict'; |
'use strict'; |
||||
|
|
||||
const DTP_COMPONENT_NAME = 'ChatAudio'; |
const DTP_COMPONENT_NAME = 'TimeTrackerAudio'; |
||||
|
|
||||
import DtpLog from 'lib/dtp-log'; |
import DtpLog from 'lib/dtp-log'; |
||||
|
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext; |
const AudioContext = window.AudioContext || window.webkitAudioContext; |
||||
|
|
||||
export default class ChatAudio { |
export default class TimeTrackerAudio { |
||||
|
|
||||
constructor ( ) { |
constructor ( ) { |
||||
this.log = new DtpLog(DTP_COMPONENT_NAME); |
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 }); |
||||
|
} |
||||
|
} |
||||
|
} |