diff --git a/.gitignore b/.gitignore index ad7635f..8f394ae 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ node_modules data/minio -dist +dist \ No newline at end of file diff --git a/LICENSE b/LICENSE index 639bcff..8942413 100644 --- a/LICENSE +++ b/LICENSE @@ -1,2 +1,2 @@ -DTP Chat Copyright (C) 2024 DTP Technologies, LLC +DTP Time Tracker Copyright (C) 2024 DTP Technologies, LLC All Rights Reserved diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 95b2ba7..2e783e7 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -10,8 +10,6 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line import mongoose from 'mongoose'; const User = mongoose.model('User'); -const ChatRoom = mongoose.model('ChatRoom'); -const ChatMessage = mongoose.model('ChatMessage'); const ChatImage = mongoose.model('Image'); const Video = mongoose.model('Video'); @@ -54,8 +52,6 @@ export default class AdminController extends SiteController { res.locals.stats = { userCount: await User.estimatedDocumentCount(), - chatRoomCount: await ChatRoom.estimatedDocumentCount(), - chatMessageCount: await ChatMessage.estimatedDocumentCount(), imageCount: await ChatImage.estimatedDocumentCount(), videoCount: await Video.estimatedDocumentCount(), }; diff --git a/app/controllers/chat.js b/app/controllers/chat.js deleted file mode 100644 index 2d4b26b..0000000 --- a/app/controllers/chat.js +++ /dev/null @@ -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, - }); - } - } -} \ No newline at end of file diff --git a/app/controllers/client.js b/app/controllers/client.js new file mode 100644 index 0000000..8955269 --- /dev/null +++ b/app/controllers/client.js @@ -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); + } + } +} \ No newline at end of file diff --git a/app/controllers/home.js b/app/controllers/home.js index f5de677..bcf181a 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -13,11 +13,6 @@ export default class HomeController extends SiteController { static get name ( ) { return 'HomeController'; } static get slug ( ) { return 'home'; } - static create (dtp) { - const instance = new HomeController(dtp); - return instance; - } - constructor (dtp) { super(dtp, HomeController.slug); this.dtp = dtp; @@ -33,24 +28,23 @@ export default class HomeController extends SiteController { } async getHome (req, res, next) { - const { chat: chatService } = this.dtp.services; + const { client: clientService, task: taskService } = this.dtp.services; try { if (!req.user) { return res.redirect('/welcome'); } res.locals.currentView = 'home'; - res.locals.pageDescription = 'DTP Chat Home'; + res.locals.pageDescription = 'DTP Time Tracker'; - res.locals.ownerRooms = await chatService.getRoomsForOwner(req.user); + res.locals.clients = await clientService.getClientsForUser(req.user); + res.locals.projects = await clientService.getProjectsForUser(req.user); - res.locals.pagination = this.getPaginationParameters(req, 10); - res.locals.memberRooms = await chatService.getRoomsForMember(req.user, res.locals.pagination); - res.locals.memberRooms = res.locals.memberRooms.filter((room) => !room.owner._id.equals(req.user._id)); - res.locals.invites = await chatService.getInvitationsForUser(req.user, { skip: 0, cpp: 5 }); + res.locals.taskGrid = await taskService.getTaskGridForUser(req.user); res.render('home'); } catch (error) { + this.log.error('failed to present the home view', { error }); return next(error); } } diff --git a/app/controllers/task.js b/app/controllers/task.js new file mode 100644 index 0000000..f1f02f7 --- /dev/null +++ b/app/controllers/task.js @@ -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); + } + } +} \ No newline at end of file diff --git a/app/controllers/welcome.js b/app/controllers/welcome.js index 80ace52..373acbd 100644 --- a/app/controllers/welcome.js +++ b/app/controllers/welcome.js @@ -4,8 +4,6 @@ 'use strict'; -const DTP_COMPONENT_NAME = 'welcome'; - import express from 'express'; import { SiteController } from '../../lib/site-controller.js'; @@ -15,13 +13,8 @@ export default class WelcomeController extends SiteController { static get name ( ) { return 'WelcomeController'; } static get slug ( ) { return 'welcome'; } - static create (dtp) { - const instance = new WelcomeController(dtp); - return instance; - } - constructor (dtp) { - super(dtp, DTP_COMPONENT_NAME); + super(dtp, WelcomeController); } async start ( ) { @@ -34,12 +27,12 @@ export default class WelcomeController extends SiteController { dtp.app.use('/welcome', router); router.use(async (req, res, next) => { - res.locals.currentView = DTP_COMPONENT_NAME; + res.locals.currentView = WelcomeController.name; return next(); }); router.post('/signup', this.postSignup.bind(this)); - router.get('/signup', this.getSignup.bind(this)); + router.get('/signup-complete', this.getSignupComplete.bind(this)); router.get('/login', this.getLogin.bind(this)); @@ -51,13 +44,22 @@ export default class WelcomeController extends SiteController { try { this.log.info('create new user account', { body: req.body }); res.locals.user = await userService.create(req.body); - res.render('welcome/signup-complete'); + req.login(res.locals.user, async (error) => { + if (error) { + return next(error); + } + return res.redirect('/'); + }); } catch (error) { this.log.error('failed to create new User account', { error }); return next(error); } } + async getSignupComplete (req, res) { + res.render('welcome/signup-complete'); + } + async getSignup (req, res) { res.render('welcome/signup'); } diff --git a/app/models/chat-filter.js b/app/models/chat-filter.js deleted file mode 100644 index f5215d4..0000000 --- a/app/models/chat-filter.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/app/models/chat-message.js b/app/models/chat-message.js deleted file mode 100644 index f6f59b2..0000000 --- a/app/models/chat-message.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/app/models/chat-room-invite.js b/app/models/chat-room-invite.js deleted file mode 100644 index 29ba522..0000000 --- a/app/models/chat-room-invite.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/app/models/chat-room.js b/app/models/chat-room.js deleted file mode 100644 index 2d9de01..0000000 --- a/app/models/chat-room.js +++ /dev/null @@ -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); \ No newline at end of file diff --git a/app/models/client-project.js b/app/models/client-project.js new file mode 100644 index 0000000..c12115c --- /dev/null +++ b/app/models/client-project.js @@ -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); \ No newline at end of file diff --git a/app/models/client.js b/app/models/client.js new file mode 100644 index 0000000..9722be4 --- /dev/null +++ b/app/models/client.js @@ -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); \ No newline at end of file diff --git a/app/models/connect-token.js b/app/models/connect-token.js index 75aeb78..88cd89f 100644 --- a/app/models/connect-token.js +++ b/app/models/connect-token.js @@ -10,8 +10,6 @@ const Schema = mongoose.Schema; const RESOURCE_TYPE_LIST = [ 'Channel', 'User', - 'ChatRoom', - 'ChannelCall', ]; const ConnectTokenSchema = new Schema({ diff --git a/app/models/task-session.js b/app/models/task-session.js new file mode 100644 index 0000000..d304147 --- /dev/null +++ b/app/models/task-session.js @@ -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); \ No newline at end of file diff --git a/app/models/task.js b/app/models/task.js new file mode 100644 index 0000000..df23a88 --- /dev/null +++ b/app/models/task.js @@ -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); \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js index 262a1ec..2571460 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -16,7 +16,6 @@ const UserFlagsSchema = new Schema({ const UserPermissionsSchema = new Schema({ canLogin: { type: Boolean, default: true, required: true }, - canChat: { type: Boolean, default: true, required: true }, canComment: { type: Boolean, default: true, required: true }, canReport: { type: Boolean, default: true, required: true }, canShareLinks: { type: Boolean, default: true, required: true }, @@ -46,7 +45,7 @@ const UserSchema = new Schema({ }, bio: { type: String }, ui: { - theme: { type: String, default: 'chat-light', required: true }, + theme: { type: String, default: 'tracker-light', required: true }, }, flags: { type: UserFlagsSchema, default: { }, required: true, select: false }, permissions: { type: UserPermissionsSchema, default: { }, required: true, select: false }, diff --git a/app/services/chat.js b/app/services/chat.js deleted file mode 100644 index 81bcd9a..0000000 --- a/app/services/chat.js +++ /dev/null @@ -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); - }); - } -} \ No newline at end of file diff --git a/app/services/client.js b/app/services/client.js new file mode 100644 index 0000000..d641109 --- /dev/null +++ b/app/services/client.js @@ -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; + } +} \ No newline at end of file diff --git a/app/services/image.js b/app/services/image.js index b84b04b..cc5521c 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -43,7 +43,7 @@ export default class ImageService extends SiteService { this.log.debug('processing uploaded image', { imageDefinition, file }); - const sharpImage = await sharp(file.path); + const sharpImage = sharp(file.path); const metadata = await sharpImage.metadata(); // create an Image model instance, but leave it here in application memory. diff --git a/app/services/link.js b/app/services/link.js deleted file mode 100644 index 0767f41..0000000 --- a/app/services/link.js +++ /dev/null @@ -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 }); - }); - } -} \ No newline at end of file diff --git a/app/services/task.js b/app/services/task.js new file mode 100644 index 0000000..51dcfd7 --- /dev/null +++ b/app/services/task.js @@ -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)); + } +} \ No newline at end of file diff --git a/app/services/text.js b/app/services/text.js index 42eec86..95e737c 100644 --- a/app/services/text.js +++ b/app/services/text.js @@ -8,7 +8,6 @@ import mongoose from 'mongoose'; const User = mongoose.model('User'); const Link = mongoose.model('Link'); -const ChatFilter = mongoose.model('ChatFilter'); import striptags from 'striptags'; import unzalgo from 'unzalgo'; @@ -28,8 +27,6 @@ export default class TextService extends SiteService { } async start ( ) { - await this.loadChatFilters(); - const { jobQueue: jobQueueService } = this.dtp.services; this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links); } @@ -67,14 +64,6 @@ export default class TextService extends SiteService { text = shoetest.simplify(text); text = diacritics.remove(text); - for (const filter of this.chatFilters) { - const regex = new RegExp(filter, 'gi'); - if (text.match(regex)) { - this.log.alert('chat filter text match', { filter }); - throw new SiteError(403, 'Text input rejected'); - } - } - /* * Once all the stupidity has been stripped, strip the HTML * tags that might remain. @@ -82,12 +71,6 @@ export default class TextService extends SiteService { return this.clean(text); } - async loadChatFilters ( ) { - this.chatFilters = await ChatFilter.find().lean(); - this.chatFilters = this.chatFilters.map((filter) => filter.filter); - this.log.debug('loading chat filters', { count: this.chatFilters.length }); - } - /** * Scans input text for username mentions (`@username`) and resolves those * names to an array of User IDs. diff --git a/app/services/user.js b/app/services/user.js index 976d48d..8e90962 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -673,7 +673,6 @@ export default class UserService extends SiteService { 'flags.isEmailVerified': userDefinition['flags.isEmailVerified'] === 'on', 'permissions.canLogin': userDefinition['permissions.canLogin'] === 'on', - 'permissions.canChat': userDefinition['permissions.canChat'] === 'on', 'permissions.canReport': userDefinition['permissions.canReport'] === 'on', 'permissions.canShareLinks': userDefinition['permissions.canShareLinks'] === 'on', @@ -686,7 +685,6 @@ export default class UserService extends SiteService { async banUser (user) { const { - chat: chatService, image: imageService, link: linkService, otpAuth: otpAuthService, @@ -696,7 +694,6 @@ export default class UserService extends SiteService { const userTag = { _id: user._id, username: user.username }; this.log.alert('banning user', userTag); - await chatService.removeAllForUser(user); await otpAuthService.removeForUser(user); await linkService.removeForUser(user); @@ -712,7 +709,6 @@ export default class UserService extends SiteService { 'flags.isModerator': false, 'flags.isEmailVerified': false, 'permissions.canLogin': false, - 'permissions.canChat': false, 'permissions.canComment': false, 'permissions.canReport': false, 'permissions.canShareLinks': false, diff --git a/app/views/admin/dashboard.pug b/app/views/admin/dashboard.pug index f570bd8..f042fbe 100644 --- a/app/views/admin/dashboard.pug +++ b/app/views/admin/dashboard.pug @@ -12,10 +12,6 @@ block admin-content div(uk-grid) .uk-width-1-5 +renderStatsBlock('Users', formatCount(stats.userCount)) - .uk-width-1-5 - +renderStatsBlock('Chat Rooms', formatCount(stats.chatRoomCount)) - .uk-width-1-5 - +renderStatsBlock('Chat Messages', formatCount(stats.chatMessageCount)) .uk-width-1-5 +renderStatsBlock('Images', formatCount(stats.imageCount)) .uk-width-1-5 diff --git a/app/views/admin/user/view.pug b/app/views/admin/user/view.pug index f00ffc9..b1cdf97 100644 --- a/app/views/admin/user/view.pug +++ b/app/views/admin/user/view.pug @@ -58,11 +58,6 @@ block admin-content input(type="checkbox", name="permissions.canLogin", checked= userAccount.permissions.canLogin) .state.p-success label Can Login - .uk-width-auto - .pretty.p-default - input(type="checkbox", name="permissions.canChat", checked= userAccount.permissions.canChat) - .state.p-success - label Can Chat .uk-width-auto .pretty.p-default input(type="checkbox", name="permissions.canReport", checked= userAccount.permissions.canReport) diff --git a/app/views/chat/components/member-list-item-standalone.pug b/app/views/chat/components/member-list-item-standalone.pug deleted file mode 100644 index 5562967..0000000 --- a/app/views/chat/components/member-list-item-standalone.pug +++ /dev/null @@ -1,2 +0,0 @@ -include member-list-item -+renderChatMemberListItem (room, member, { isHost, isGuest }) \ No newline at end of file diff --git a/app/views/chat/components/member-list-item.pug b/app/views/chat/components/member-list-item.pug deleted file mode 100644 index 3a0dc15..0000000 --- a/app/views/chat/components/member-list-item.pug +++ /dev/null @@ -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} \ No newline at end of file diff --git a/app/views/chat/components/message-standalone.pug b/app/views/chat/components/message-standalone.pug deleted file mode 100644 index 270c119..0000000 --- a/app/views/chat/components/message-standalone.pug +++ /dev/null @@ -1,2 +0,0 @@ -include message -+renderChatMessage(message) \ No newline at end of file diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug deleted file mode 100644 index efa48a7..0000000 --- a/app/views/chat/components/message.pug +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/views/chat/components/reaction-bar-standalone.pug b/app/views/chat/components/reaction-bar-standalone.pug deleted file mode 100644 index 95990df..0000000 --- a/app/views/chat/components/reaction-bar-standalone.pug +++ /dev/null @@ -1,3 +0,0 @@ -include ../../components/library -include reaction-bar -+renderReactionBar(message) \ No newline at end of file diff --git a/app/views/chat/components/reaction-bar.pug b/app/views/chat/components/reaction-bar.pug deleted file mode 100644 index 679e01f..0000000 --- a/app/views/chat/components/reaction-bar.pug +++ /dev/null @@ -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(',') \ No newline at end of file diff --git a/app/views/chat/room/create.pug b/app/views/chat/room/create.pug deleted file mode 100644 index 1975588..0000000 --- a/app/views/chat/room/create.pug +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/views/chat/room/invite.pug b/app/views/chat/room/invite.pug deleted file mode 100644 index 2a006c3..0000000 --- a/app/views/chat/room/invite.pug +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/views/chat/room/settings.pug b/app/views/chat/room/settings.pug deleted file mode 100644 index 4f31411..0000000 --- a/app/views/chat/room/settings.pug +++ /dev/null @@ -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`; - } \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug deleted file mode 100644 index 4e8d93c..0000000 --- a/app/views/chat/room/view.pug +++ /dev/null @@ -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)}; \ No newline at end of file diff --git a/app/views/client/create.pug b/app/views/client/create.pug new file mode 100644 index 0000000..891739a --- /dev/null +++ b/app/views/client/create.pug @@ -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 \ No newline at end of file diff --git a/app/views/client/dashboard.pug b/app/views/client/dashboard.pug new file mode 100644 index 0000000..1e6e7fb --- /dev/null +++ b/app/views/client/dashboard.pug @@ -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 \ No newline at end of file diff --git a/app/views/client/project/create.pug b/app/views/client/project/create.pug new file mode 100644 index 0000000..e200717 --- /dev/null +++ b/app/views/client/project/create.pug @@ -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 \ No newline at end of file diff --git a/app/views/client/project/view.pug b/app/views/client/project/view.pug new file mode 100644 index 0000000..c6336ee --- /dev/null +++ b/app/views/client/project/view.pug @@ -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) \ No newline at end of file diff --git a/app/views/client/view.pug b/app/views/client/view.pug new file mode 100644 index 0000000..0286d00 --- /dev/null +++ b/app/views/client/view.pug @@ -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 \ No newline at end of file diff --git a/app/views/components/emoji-picker.pug b/app/views/components/emoji-picker.pug index 1b55848..856a144 100644 --- a/app/views/components/emoji-picker.pug +++ b/app/views/components/emoji-picker.pug @@ -2,7 +2,7 @@ .emoji-picker-prompt.sr-only Select an emoji emoji-picker( class={ - 'dark': (user && (user.ui.theme === 'chat-dark')), - 'light': (!user || (user.ui.theme === 'chat-light')), + 'dark': (user && (user.ui.theme === 'tracker-dark')), + 'light': (!user || (user.ui.theme === 'tracker-light')), } ) \ No newline at end of file diff --git a/app/views/components/library.pug b/app/views/components/library.pug index 00c3f72..8da76a4 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -18,7 +18,6 @@ const fG = `${user.flags.isCloaked ? 'G' : '-'}`; const pL = `${user.permissions.canLogin ? 'L' : '-'}`; - const pC = `${user.permissions.canChat ? 'C' : '-'}`; const pO = `${user.permissions.canComment ? 'O' : '-'}`; const pR = `${user.permissions.canReport ? 'R' : '-'}`; const pI = `${user.permissions.canShareLinks ? 'I' : '-'}`; diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index e845b32..b5a358c 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -4,6 +4,14 @@ nav(style="background: #000000;").uk-navbar-container.uk-light .uk-navbar-left a(href="/", aria-label="Back to Home").uk-navbar-item.uk-logo.uk-padding-remove-left img(src="/img/nav-icon.png").navbar-logo + + ul.uk-navbar-nav + li + a(href="/", aria-label="Back to Home").uk-navbar-item + .uk-text-bold.no-select Home + li + a(href="/client", aria-label="Manage your clients").uk-navbar-item + .uk-text-bold.no-select Clients .uk-navbar-right if !user @@ -24,7 +32,7 @@ nav(style="background: #000000;").uk-navbar-container.uk-light ).profile-navbar else img( - src= '/img/default-member.png', + src= '/img/default-member.svg', title="Member Menu", ).profile-navbar div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown diff --git a/app/views/home.pug b/app/views/home.pug index 723991c..113453c 100644 --- a/app/views/home.pug +++ b/app/views/home.pug @@ -1,84 +1,12 @@ extends layout/main block view-content - mixin renderRoomListEntry (room) - div(uk-grid) - .uk-width-expand - a(href=`/chat/room/${room._id}`).uk-link-reset - .uk-text-bold= room.name - .uk-text-small= room.topic || '(no topic assigned)' - .uk-width-auto - .uk-text-small Active - .uk-text-bold(class={ - 'uk-text-success': (room.stats.presentCount > 0), - 'uk-text-muted': (room.stats.presentCount === 0), - })= room.stats.presentCount - .uk-width-auto - .uk-text-small Members - .uk-text-bold= room.stats.memberCount + include task/components/grid section.uk-section.uk-section-default .uk-container - - div(uk-grid) - if Array.isArray(invites) && (invites.length > 0) - div(class="uk-width-1-1 uk-width-1-3@m") - .uk-margin-medium - .uk-background-secondary.uk-light.uk-padding-small.uk-border-rounded - h4 Room Invites - ul.uk-list - each invite in invites - li(data-invite-id= invite._id) - div(uk-grid).uk-grid-small - .uk-width-expand - .uk-text-bold.uk-text-truncate= invite.room.name - .uk-text-small @#{invite.owner.username} #{dayjs(invite.created).fromNow()} - - .uk-width-auto - a( - href="", - data-room-id= invite.room._id, - data-invite-id= invite._id, - data-invite-action= "accepted", - onclick="return dtp.app.processRoomInvite(event)", - ).uk-text-success - i.fa-solid.fa-check - - .uk-width-auto - a( - href="", - data-room-id= invite.room._id, - data-invite-id= invite._id, - data-invite-action= "rejected", - onclick="return dtp.app.processRoomInvite(event)", - ).uk-text-danger - i.fa-solid.fa-times - - .uk-width-expand - .uk-margin-medium - div(uk-grid) - .uk-width-expand - .uk-text-lead YOUR ROOMS - .uk-width-auto - a(href="/chat/room/create").uk-button.uk-button-default.uk-border-rounded - span - i.fas.fa-plus - span.uk-margin-small-left Create Room - - if (Array.isArray(ownerRooms) && (ownerRooms.length > 0)) - ul.uk-list - each room in ownerRooms - li.uk-list-divider - +renderRoomListEntry(room) - else - p You don't own any rooms. - - .uk-margin-medium - .uk-text-lead ROOM MEMBERSHIPS - if (Array.isArray(memberRooms) && (memberRooms.length > 0)) - ul.uk-list - each room in memberRooms - li.uk-list-divider - +renderRoomListEntry(room) - else - p You haven't joined any rooms that you don't own. \ No newline at end of file + +renderTaskGrid( + taskGrid.pendingTasks, + taskGrid.activeTasks, + taskGrid.finishedTasks, + ) \ No newline at end of file diff --git a/app/views/layout/main.pug b/app/views/layout/main.pug index 6bca822..9a0df72 100644 --- a/app/views/layout/main.pug +++ b/app/views/layout/main.pug @@ -22,7 +22,7 @@ html(lang='en', data-obs-widget= obsWidget) block vendorcss - link(rel='stylesheet', href=`/dist/${(user) ? user.ui.theme : 'chat-light'}.css?v=${pkg.version}`) + link(rel='stylesheet', href=`/dist/${(user) ? user.ui.theme : 'tracker-light'}.css?v=${pkg.version}`) link(rel='stylesheet', href=`/pretty-checkbox/pretty-checkbox.min.css?v=${pkg.version}`) block viewcss diff --git a/app/views/link/components/preview-standalone.pug b/app/views/link/components/preview-standalone.pug deleted file mode 100644 index 04622d7..0000000 --- a/app/views/link/components/preview-standalone.pug +++ /dev/null @@ -1,2 +0,0 @@ -include preview -+renderLinkPreview(link) \ No newline at end of file diff --git a/app/views/link/components/preview.pug b/app/views/link/components/preview.pug deleted file mode 100644 index 0659764..0000000 --- a/app/views/link/components/preview.pug +++ /dev/null @@ -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 \ No newline at end of file diff --git a/app/views/link/timeline.pug b/app/views/link/timeline.pug deleted file mode 100644 index ba350b6..0000000 --- a/app/views/link/timeline.pug +++ /dev/null @@ -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) \ No newline at end of file diff --git a/app/views/task/components/grid.pug b/app/views/task/components/grid.pug new file mode 100644 index 0000000..25c44b8 --- /dev/null +++ b/app/views/task/components/grid.pug @@ -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 \ No newline at end of file diff --git a/app/views/task/components/list.pug b/app/views/task/components/list.pug new file mode 100644 index 0000000..db41828 --- /dev/null +++ b/app/views/task/components/list.pug @@ -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} diff --git a/app/views/task/session/view.pug b/app/views/task/session/view.pug new file mode 100644 index 0000000..6ac2f2b --- /dev/null +++ b/app/views/task/session/view.pug @@ -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. \ No newline at end of file diff --git a/app/views/task/view.pug b/app/views/task/view.pug new file mode 100644 index 0000000..1329872 --- /dev/null +++ b/app/views/task/view.pug @@ -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)}; \ No newline at end of file diff --git a/app/views/user/components/profile-picture.pug b/app/views/user/components/profile-picture.pug index 57636b5..61e33f1 100644 --- a/app/views/user/components/profile-picture.pug +++ b/app/views/user/components/profile-picture.pug @@ -1,6 +1,6 @@ mixin renderProfilePicture (user, options) - - var iconImageUrl = '/img/default-member.png'; + var iconImageUrl = '/img/default-member.svg'; if (user?.picture?.large) { iconImageUrl = `/image/${user.picture.large._id}`; } else if (user?.picture?.small) { diff --git a/app/views/user/settings.pug b/app/views/user/settings.pug index 75dd6fb..7d2dc25 100644 --- a/app/views/user/settings.pug +++ b/app/views/user/settings.pug @@ -23,7 +23,7 @@ block view-content 'profile-picture-upload', 'profile-picture-file', 'streamray-profile-picture', - `/img/default-member.png`, + `/img/default-member.svg`, currentImage, { aspectRatio: 1 }, ) @@ -68,8 +68,8 @@ block view-content .uk-margin label(for="ui-theme").uk-form-label UI Theme select(id="ui-theme", name="uiTheme").uk-select - option(value="chat-light", selected= (user.ui.theme === 'chat-light')) Light - option(value="chat-dark", selected= (user.ui.theme === 'chat-dark')) Dark + option(value="tracker-light", selected= (user.ui.theme === 'tracker-light')) Light + option(value="tracker-dark", selected= (user.ui.theme === 'tracker-dark')) Dark li .uk-margin diff --git a/app/views/welcome/signup-complete.pug b/app/views/welcome/signup-complete.pug new file mode 100644 index 0000000..c2ae5db --- /dev/null +++ b/app/views/welcome/signup-complete.pug @@ -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) \ No newline at end of file diff --git a/app/workers/chat-links.js b/app/workers/chat-links.js deleted file mode 100644 index 0898237..0000000 --- a/app/workers/chat-links.js +++ /dev/null @@ -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); - } - -})(); \ No newline at end of file diff --git a/app/workers/chat-processor.js b/app/workers/chat-processor.js deleted file mode 100644 index edc7dfb..0000000 --- a/app/workers/chat-processor.js +++ /dev/null @@ -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); - } - -})(); \ No newline at end of file diff --git a/client/css/dtp-site.less b/client/css/dtp-site.less index 79e5c68..076d03f 100644 --- a/client/css/dtp-site.less +++ b/client/css/dtp-site.less @@ -5,10 +5,8 @@ @import "site/button.less"; @import "site/drop-feedback.less"; -@import "site/emoji-picker.less"; @import "site/image.less"; -@import "site/link-preview.less"; @import "site/menu.less"; @import "site/navbar.less"; -@import "site/stage.less"; -@import "site/stats.less"; \ No newline at end of file +@import "site/stats.less"; +@import "site/video.less"; \ No newline at end of file diff --git a/client/css/site/emoji-picker.less b/client/css/site/emoji-picker.less deleted file mode 100644 index d38ec78..0000000 --- a/client/css/site/emoji-picker.less +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/client/css/site/link-preview.less b/client/css/site/link-preview.less deleted file mode 100644 index 33b9575..0000000 --- a/client/css/site/link-preview.less +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/client/css/site/stage.less b/client/css/site/stage.less deleted file mode 100644 index dd3bf0f..0000000 --- a/client/css/site/stage.less +++ /dev/null @@ -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; - } - } -} \ No newline at end of file diff --git a/client/css/site/video.less b/client/css/site/video.less new file mode 100644 index 0000000..2deaae3 --- /dev/null +++ b/client/css/site/video.less @@ -0,0 +1,9 @@ +video.dtp-video { + display: block; + + width: 100%; + height: auto; + aspect-ratio: 16 / 9; + + border-radius: 5px; +} \ No newline at end of file diff --git a/client/img/app-icon.png b/client/img/app-icon.png new file mode 100644 index 0000000..1bb258e Binary files /dev/null and b/client/img/app-icon.png differ diff --git a/client/img/default-member.png b/client/img/default-member.png deleted file mode 100644 index afeaaa0..0000000 Binary files a/client/img/default-member.png and /dev/null differ diff --git a/client/img/default-member.svg b/client/img/default-member.svg new file mode 100644 index 0000000..be2df94 --- /dev/null +++ b/client/img/default-member.svg @@ -0,0 +1,57 @@ + + + + diff --git a/client/img/default-poster.png b/client/img/default-poster.png index f7dc2cf..bce1079 100644 Binary files a/client/img/default-poster.png and b/client/img/default-poster.png differ diff --git a/client/img/default-poster.svg b/client/img/default-poster.svg new file mode 100644 index 0000000..b4f2693 --- /dev/null +++ b/client/img/default-poster.svg @@ -0,0 +1,3409 @@ + + diff --git a/client/img/icon/icon-114x114.png b/client/img/icon/icon-114x114.png index a0183fd..dd8aba2 100644 Binary files a/client/img/icon/icon-114x114.png and b/client/img/icon/icon-114x114.png differ diff --git a/client/img/icon/icon-120x120.png b/client/img/icon/icon-120x120.png index 12465e2..5d6a5d4 100644 Binary files a/client/img/icon/icon-120x120.png and b/client/img/icon/icon-120x120.png differ diff --git a/client/img/icon/icon-144x144.png b/client/img/icon/icon-144x144.png index a429832..2f3ce51 100644 Binary files a/client/img/icon/icon-144x144.png and b/client/img/icon/icon-144x144.png differ diff --git a/client/img/icon/icon-150x150.png b/client/img/icon/icon-150x150.png index ed9c2d4..c80f261 100644 Binary files a/client/img/icon/icon-150x150.png and b/client/img/icon/icon-150x150.png differ diff --git a/client/img/icon/icon-152x152.png b/client/img/icon/icon-152x152.png index 82be507..c2a418c 100644 Binary files a/client/img/icon/icon-152x152.png and b/client/img/icon/icon-152x152.png differ diff --git a/client/img/icon/icon-16x16.png b/client/img/icon/icon-16x16.png index ee02676..f84c448 100644 Binary files a/client/img/icon/icon-16x16.png and b/client/img/icon/icon-16x16.png differ diff --git a/client/img/icon/icon-180x180.png b/client/img/icon/icon-180x180.png index ecf2e7f..2b62c4e 100644 Binary files a/client/img/icon/icon-180x180.png and b/client/img/icon/icon-180x180.png differ diff --git a/client/img/icon/icon-192x192.png b/client/img/icon/icon-192x192.png index a0d8f54..9ca324a 100644 Binary files a/client/img/icon/icon-192x192.png and b/client/img/icon/icon-192x192.png differ diff --git a/client/img/icon/icon-256x256.png b/client/img/icon/icon-256x256.png index fbd23cd..c300431 100644 Binary files a/client/img/icon/icon-256x256.png and b/client/img/icon/icon-256x256.png differ diff --git a/client/img/icon/icon-310x310.png b/client/img/icon/icon-310x310.png index 8b34bf7..78181ee 100644 Binary files a/client/img/icon/icon-310x310.png and b/client/img/icon/icon-310x310.png differ diff --git a/client/img/icon/icon-32x32.png b/client/img/icon/icon-32x32.png index da88e0c..f43696e 100644 Binary files a/client/img/icon/icon-32x32.png and b/client/img/icon/icon-32x32.png differ diff --git a/client/img/icon/icon-36x36.png b/client/img/icon/icon-36x36.png index f73b4c6..b90b1a9 100644 Binary files a/client/img/icon/icon-36x36.png and b/client/img/icon/icon-36x36.png differ diff --git a/client/img/icon/icon-384x384.png b/client/img/icon/icon-384x384.png index 704436c..2635bc2 100644 Binary files a/client/img/icon/icon-384x384.png and b/client/img/icon/icon-384x384.png differ diff --git a/client/img/icon/icon-48x48.png b/client/img/icon/icon-48x48.png index 02c8574..c7a6d16 100644 Binary files a/client/img/icon/icon-48x48.png and b/client/img/icon/icon-48x48.png differ diff --git a/client/img/icon/icon-512x512.png b/client/img/icon/icon-512x512.png index 9debbe7..166fb65 100644 Binary files a/client/img/icon/icon-512x512.png and b/client/img/icon/icon-512x512.png differ diff --git a/client/img/icon/icon-57x57.png b/client/img/icon/icon-57x57.png index 349e689..971001f 100644 Binary files a/client/img/icon/icon-57x57.png and b/client/img/icon/icon-57x57.png differ diff --git a/client/img/icon/icon-60x60.png b/client/img/icon/icon-60x60.png index 43b02c7..676d3cf 100644 Binary files a/client/img/icon/icon-60x60.png and b/client/img/icon/icon-60x60.png differ diff --git a/client/img/icon/icon-70x70.png b/client/img/icon/icon-70x70.png index 87e8506..8251b9f 100644 Binary files a/client/img/icon/icon-70x70.png and b/client/img/icon/icon-70x70.png differ diff --git a/client/img/icon/icon-72x72.png b/client/img/icon/icon-72x72.png index ba5e38c..ddf77de 100644 Binary files a/client/img/icon/icon-72x72.png and b/client/img/icon/icon-72x72.png differ diff --git a/client/img/icon/icon-76x76.png b/client/img/icon/icon-76x76.png index cf2e3b9..6422e45 100644 Binary files a/client/img/icon/icon-76x76.png and b/client/img/icon/icon-76x76.png differ diff --git a/client/img/icon/icon-96x96.png b/client/img/icon/icon-96x96.png index aaac378..a4cff5e 100644 Binary files a/client/img/icon/icon-96x96.png and b/client/img/icon/icon-96x96.png differ diff --git a/client/img/nav-icon.png b/client/img/nav-icon.png index 473c431..0c10246 100644 Binary files a/client/img/nav-icon.png and b/client/img/nav-icon.png differ diff --git a/client/js/chat-client.js b/client/js/chat-client.js deleted file mode 100644 index 59ff8eb..0000000 --- a/client/js/chat-client.js +++ /dev/null @@ -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(`
You are navigating to ${href}
, a link or button that was displayed as:
${text}
You are navigating to ${href}
, a link or button that was displayed as:
${text}