Browse Source

forked to dtp-time-tracker

develop
Rob Colbert 12 months ago
parent
commit
b736aa55a0
  1. 2
      .gitignore
  2. 2
      LICENSE
  3. 4
      app/controllers/admin.js
  4. 393
      app/controllers/chat.js
  5. 192
      app/controllers/client.js
  6. 18
      app/controllers/home.js
  7. 235
      app/controllers/task.js
  8. 24
      app/controllers/welcome.js
  9. 15
      app/models/chat-filter.js
  10. 34
      app/models/chat-message.js
  11. 27
      app/models/chat-room-invite.js
  12. 32
      app/models/chat-room.js
  13. 19
      app/models/client-project.js
  14. 16
      app/models/client.js
  15. 2
      app/models/connect-token.js
  16. 32
      app/models/task-session.js
  17. 23
      app/models/task.js
  18. 3
      app/models/user.js
  19. 703
      app/services/chat.js
  20. 118
      app/services/client.js
  21. 2
      app/services/image.js
  22. 310
      app/services/link.js
  23. 294
      app/services/task.js
  24. 17
      app/services/text.js
  25. 4
      app/services/user.js
  26. 4
      app/views/admin/dashboard.pug
  27. 5
      app/views/admin/user/view.pug
  28. 2
      app/views/chat/components/member-list-item-standalone.pug
  29. 105
      app/views/chat/components/member-list-item.pug
  30. 2
      app/views/chat/components/message-standalone.pug
  31. 130
      app/views/chat/components/message.pug
  32. 3
      app/views/chat/components/reaction-bar-standalone.pug
  33. 18
      app/views/chat/components/reaction-bar.pug
  34. 21
      app/views/chat/room/create.pug
  35. 21
      app/views/chat/room/invite.pug
  36. 56
      app/views/chat/room/settings.pug
  37. 141
      app/views/chat/room/view.pug
  38. 22
      app/views/client/create.pug
  39. 24
      app/views/client/dashboard.pug
  40. 29
      app/views/client/project/create.pug
  41. 34
      app/views/client/project/view.pug
  42. 26
      app/views/client/view.pug
  43. 4
      app/views/components/emoji-picker.pug
  44. 1
      app/views/components/library.pug
  45. 10
      app/views/components/navbar.pug
  46. 84
      app/views/home.pug
  47. 2
      app/views/layout/main.pug
  48. 2
      app/views/link/components/preview-standalone.pug
  49. 57
      app/views/link/components/preview.pug
  50. 67
      app/views/link/timeline.pug
  51. 23
      app/views/task/components/grid.pug
  52. 11
      app/views/task/components/list.pug
  53. 55
      app/views/task/session/view.pug
  54. 75
      app/views/task/view.pug
  55. 2
      app/views/user/components/profile-picture.pug
  56. 6
      app/views/user/settings.pug
  57. 12
      app/views/welcome/signup-complete.pug
  58. 280
      app/workers/chat-links.js
  59. 70
      app/workers/chat-processor.js
  60. 6
      client/css/dtp-site.less
  61. 22
      client/css/site/emoji-picker.less
  62. 36
      client/css/site/link-preview.less
  63. 371
      client/css/site/stage.less
  64. 9
      client/css/site/video.less
  65. BIN
      client/img/app-icon.png
  66. BIN
      client/img/default-member.png
  67. 57
      client/img/default-member.svg
  68. BIN
      client/img/default-poster.png
  69. 3409
      client/img/default-poster.svg
  70. BIN
      client/img/icon/icon-114x114.png
  71. BIN
      client/img/icon/icon-120x120.png
  72. BIN
      client/img/icon/icon-144x144.png
  73. BIN
      client/img/icon/icon-150x150.png
  74. BIN
      client/img/icon/icon-152x152.png
  75. BIN
      client/img/icon/icon-16x16.png
  76. BIN
      client/img/icon/icon-180x180.png
  77. BIN
      client/img/icon/icon-192x192.png
  78. BIN
      client/img/icon/icon-256x256.png
  79. BIN
      client/img/icon/icon-310x310.png
  80. BIN
      client/img/icon/icon-32x32.png
  81. BIN
      client/img/icon/icon-36x36.png
  82. BIN
      client/img/icon/icon-384x384.png
  83. BIN
      client/img/icon/icon-48x48.png
  84. BIN
      client/img/icon/icon-512x512.png
  85. BIN
      client/img/icon/icon-57x57.png
  86. BIN
      client/img/icon/icon-60x60.png
  87. BIN
      client/img/icon/icon-70x70.png
  88. BIN
      client/img/icon/icon-72x72.png
  89. BIN
      client/img/icon/icon-76x76.png
  90. BIN
      client/img/icon/icon-96x96.png
  91. BIN
      client/img/nav-icon.png
  92. 938
      client/js/chat-client.js
  93. 6
      client/js/index.js
  94. 6
      client/js/time-tracker-audio.js
  95. 557
      client/js/time-tracker-client.js
  96. BIN
      client/static/sfx/message-deleted.mp3
  97. BIN
      client/static/sfx/room-connect.mp3
  98. 0
      client/static/sfx/tracker-start.mp3
  99. 0
      client/static/sfx/tracker-stop.mp3
  100. 0
      client/static/sfx/tracker-update.mp3

2
.gitignore

@ -3,4 +3,4 @@
node_modules
data/minio
dist
dist

2
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

4
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(),
};

393
app/controllers/chat.js

@ -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,
});
}
}
}

192
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);
}
}
}

18
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);
}
}

235
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);
}
}
}

24
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');
}

15
app/models/chat-filter.js

@ -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);

34
app/models/chat-message.js

@ -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);

27
app/models/chat-room-invite.js

@ -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);

32
app/models/chat-room.js

@ -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);

19
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);

16
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);

2
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({

32
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);

23
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);

3
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 },

703
app/services/chat.js

@ -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);
});
}
}

118
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;
}
}

2
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.

310
app/services/link.js

@ -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 });
});
}
}

294
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));
}
}

17
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.

4
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,

4
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

5
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)

2
app/views/chat/components/member-list-item-standalone.pug

@ -1,2 +0,0 @@
include member-list-item
+renderChatMemberListItem (room, member, { isHost, isGuest })

105
app/views/chat/components/member-list-item.pug

@ -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}

2
app/views/chat/components/message-standalone.pug

@ -1,2 +0,0 @@
include message
+renderChatMessage(message)

130
app/views/chat/components/message.pug

@ -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

3
app/views/chat/components/reaction-bar-standalone.pug

@ -1,3 +0,0 @@
include ../../components/library
include reaction-bar
+renderReactionBar(message)

18
app/views/chat/components/reaction-bar.pug

@ -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(',')

21
app/views/chat/room/create.pug

@ -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

21
app/views/chat/room/invite.pug

@ -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

56
app/views/chat/room/settings.pug

@ -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`;
}

141
app/views/chat/room/view.pug

@ -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)};

22
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

24
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

29
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

34
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)

26
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

4
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')),
}
)

1
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' : '-'}`;

10
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

84
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.
+renderTaskGrid(
taskGrid.pendingTasks,
taskGrid.activeTasks,
taskGrid.finishedTasks,
)

2
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

2
app/views/link/components/preview-standalone.pug

@ -1,2 +0,0 @@
include preview
+renderLinkPreview(link)

57
app/views/link/components/preview.pug

@ -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

67
app/views/link/timeline.pug

@ -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)

23
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

11
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}

55
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.

75
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)};

2
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) {

6
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

12
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)

280
app/workers/chat-links.js

@ -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);
}
})();

70
app/workers/chat-processor.js

@ -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);
}
})();

6
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";
@import "site/stats.less";
@import "site/video.less";

22
client/css/site/emoji-picker.less

@ -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;
}
}

36
client/css/site/link-preview.less

@ -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;
}
}
}

371
client/css/site/stage.less

@ -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;
}
}
}

9
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;
}

BIN
client/img/app-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

BIN
client/img/default-member.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

57
client/img/default-member.svg

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="800px"
height="800px"
viewBox="0 0 16 16"
version="1.1"
id="svg4"
sodipodi:docname="default-member.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1051"
id="namedview6"
showgrid="false"
inkscape:zoom="0.295"
inkscape:cx="400"
inkscape:cy="400"
inkscape:window-x="1920"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg4" />
<path
d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0"
fill="#2e3436"
id="path2"
style="fill:#999999" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
client/img/default-poster.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 146 KiB

3409
client/img/default-poster.svg

File diff suppressed because it is too large

After

Width:  |  Height:  |  Size: 210 KiB

BIN
client/img/icon/icon-114x114.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
client/img/icon/icon-120x120.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
client/img/icon/icon-144x144.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
client/img/icon/icon-150x150.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
client/img/icon/icon-152x152.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 14 KiB

BIN
client/img/icon/icon-16x16.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 B

After

Width:  |  Height:  |  Size: 716 B

BIN
client/img/icon/icon-180x180.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 19 KiB

BIN
client/img/icon/icon-192x192.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
client/img/icon/icon-256x256.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 35 KiB

BIN
client/img/icon/icon-310x310.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 53 KiB

BIN
client/img/icon/icon-32x32.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
client/img/icon/icon-36x36.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
client/img/icon/icon-384x384.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 76 KiB

BIN
client/img/icon/icon-48x48.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
client/img/icon/icon-512x512.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 123 KiB

BIN
client/img/icon/icon-57x57.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
client/img/icon/icon-60x60.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
client/img/icon/icon-70x70.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
client/img/icon/icon-72x72.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
client/img/icon/icon-76x76.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
client/img/icon/icon-96x96.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
client/img/nav-icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

938
client/js/chat-client.js

@ -1,938 +0,0 @@
// chat-client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'DtpChatApp';
const dtp = window.dtp = window.dtp || { };
import DtpApp from 'lib/dtp-app.js';
import ChatAudio from './chat-audio.js';
import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(dayjsRelativeTime);
import hljs from 'highlight.js';
export class ChatApp extends DtpApp {
static get SFX_CHAT_ROOM_CONNECT ( ) { return 'chat-room-connect'; }
static get SFX_CHAT_MESSAGE ( ) { return 'chat-message'; }
static get SFX_CHAT_REACTION ( ) { return 'reaction'; }
static get SFX_CHAT_REACTION_REMOVE ( ) { return 'reaction-remove'; }
static get SFX_CHAT_MESSAGE_REMOVE ( ) { return 'message-remove'; }
constructor (user) {
super(DTP_COMPONENT_NAME, user);
this.loadSettings();
this.log.info('constructor', 'DTP app client online');
this.notificationPermission = 'default';
this.haveFocus = true; // hard to load the app w/o also being the focused app
this.chat = {
form: document.querySelector('#chat-input-form'),
messageList: document.querySelector('#chat-message-list'),
messages: [ ],
messageMenu: document.querySelector('.chat-message-menu'),
input: document.querySelector('#chat-input-text'),
imageFiles: document.querySelector('#image-files'),
videoFile: document.querySelector('#video-file'),
sendButton: document.querySelector('#chat-send-btn'),
isAtBottom: true,
};
if (this.chat.input) {
this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this));
this.observer = new MutationObserver(this.onChatMessageListChanged.bind(this));
this.observer.observe(this.chat.messageList, { childList: true });
hljs.highlightAll();
}
this.emojiPickerDisplay = document.querySelector('.emoji-picker-display');
if (this.emojiPickerDisplay) {
this.emojiPickerDisplay.addEventListener('click', this.onEmojiPickerClose.bind(this));
}
this.emojiPicker = document.querySelector('emoji-picker');
if (this.emojiPicker) {
this.emojiPicker.addEventListener('emoji-click', this.onEmojiPicked.bind(this));
}
this.pendingFiles = {
images: [ ],
videos: [ ],
};
this.dragFeedback = document.querySelector('.dtp-drop-feedback');
window.addEventListener('dtp-load', this.onDtpLoad.bind(this));
window.addEventListener('unload', this.onDtpUnload.bind(this));
window.addEventListener('focus', this.onWindowFocus.bind(this));
window.addEventListener('blur', this.onWindowBlur.bind(this));
this.updateTimestamps();
}
async onWindowFocus (event) {
this.log.debug('onWindowFocus', 'window has received focus', { event });
this.haveFocus = true;
}
async onWindowBlur (event) {
this.log.debug('onWindowBlur', 'window has lost focus', { event });
this.haveFocus = false;
}
async startAudio ( ) {
this.log.info('startAudio', 'starting ChatAudio');
this.audio = new ChatAudio();
this.audio.start();
try {
await Promise.all([
this.audio.loadSound(ChatApp.SFX_CHAT_ROOM_CONNECT, '/static/sfx/room-connect.mp3'),
this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE, '/static/sfx/chat-message.mp3'),
this.audio.loadSound(ChatApp.SFX_CHAT_REACTION, '/static/sfx/reaction.mp3'),
this.audio.loadSound(ChatApp.SFX_CHAT_REACTION_REMOVE, '/static/sfx/reaction-remove.mp3'),
this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE_REMOVE, '/static/sfx/message-deleted.mp3'),
]);
} catch (error) {
this.log.error('startAudio', 'failed to load sound', { error });
// fall through
}
}
async onChatInputKeyDown (event) {
if (event.key === 'Enter' && !event.shiftKey) {
if (dtp.room) {
return this.sendChatRoomMessage(event);
}
if (dtp.thread) {
return this.sendPrivateMessage(event);
}
return this.sendUserChat(event);
}
}
async onDragEnter (event) {
event.preventDefault();
event.stopPropagation();
this.log.info('onDragEnter', 'something being dragged has entered the stage', { event });
this.dragFeedback.classList.add('feedback-active');
}
async onDragLeave (event) {
event.preventDefault();
event.stopPropagation();
this.log.info('onDragLeave', 'something being dragged has left the stage', { event });
this.dragFeedback.classList.remove('feedback-active');
}
async onDragOver (event) {
/*
* Inform that we want "copy" as a drop effect and prevent all default
* processing so we'll actually get the files in the drop event. If this
* isn't done, you simply won't get the files in the drop.
*/
event.preventDefault();
event.stopPropagation(); // this ends now!
event.dataTransfer.dropEffect = 'copy';
// this.log.info('onDragOver', 'something was dragged over the stage', { event });
this.dragFeedback.classList.add('feedback-active');
}
async onDrop (event) {
event.preventDefault();
event.stopPropagation();
for (const file of event.dataTransfer.files) {
this.log.info('onDrop', 'a file has been dropped', { file });
}
this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files });
this.dragFeedback.classList.remove('feedback-active');
}
async onChatMessageListChanged (mutationList) {
this.log.info('onMutation', 'DOM mutation received', { mutationList });
if (!Array.isArray(mutationList) || (mutationList.length === 0)) {
return;
}
for (const mutation of mutationList) {
for (const node of mutation.addedNodes) {
if (typeof node.querySelectorAll !== 'function') {
continue;
}
const timestamps = node.querySelectorAll("[data-dtp-timestamp]");
this.updateTimestamps(timestamps);
}
}
hljs.highlightAll();
}
updateTimestamps ( ) {
const nodeList = document.querySelectorAll("[data-dtp-timestamp]");
this.log.debug('updateTimestamps', 'updating timestamps', { count: nodeList.length });
for (const ts of nodeList) {
const date = ts.getAttribute('data-dtp-timestamp');
const format = ts.getAttribute('data-dtp-timestamp-format');
if (!date) { continue; }
switch (format) {
case 'date':
ts.textContent = dayjs(date).format('MMM DD, YYYY');
break;
case 'time':
ts.textContent = dayjs(date).format('h:mm a');
break;
case 'datetime':
ts.textContent = dayjs(date).format('MMM D [at] h:mm a');
break;
case 'fuzzy':
ts.textContent = dayjs(date).fromNow();
break;
case 'timestamp':
default:
ts.textContent = dayjs(date).format('hh:mm:ss a');
break;
}
}
}
async sendUserChat (event) {
event.preventDefault();
if (!dtp.user) {
UIkit.modal.alert('There is a problem with Chat. Please refresh the page.');
return;
}
if (this.chatTimeout) {
return;
}
const channelId = dtp.user._id;
const content = this.chat.input.value;
this.chat.input.value = '';
if (content.length === 0) {
return true;
}
this.log.debug('sendUserChat', 'sending chat message', { channel: this.user._id, content });
this.socket.sendUserChat(channelId, content);
// set focus back to chat input
this.chat.input.focus();
const isFreeMember = false;
this.chat.sendButton.setAttribute('disabled', '');
this.chat.sendButton.setAttribute('uk-tooltip', isFreeMember ? 'Waiting 30 seconds' : 'Waiting 5 seconds');
this.chatTimeout = setTimeout(( ) => {
delete this.chatTimeout;
this.chat.sendButton.removeAttribute('disabled');
this.chat.sendButton.setAttribute('uk-tooltip', 'Send message');
}, isFreeMember ? 30000 : 5000);
return true;
}
async sendChatRoomMessage (event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (this.chatTimeout) {
return;
}
const form = new FormData(this.chat.form);
const roomId = this.chat.form.getAttribute('data-room-id');
const content = this.chat.input.value;
this.chat.input.value = '';
if ((content.length === 0) &&
(!this.chat.imageFiles.value) &&
(!this.chat.videoFile.value)) {
return true;
}
try {
this.log.info('sendChatRoomMessage', 'sending chat message', { room: roomId, content });
const response = await fetch(this.chat.form.action, {
method: this.chat.form.method,
body: form,
});
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to send chat message: ${error.message}`);
}
// set focus back to chat input
this.chat.imageFiles.value = null;
this.chat.videoFile.value = null;
this.chat.input.focus();
this.chat.sendButton.setAttribute('disabled', '');
this.chatTimeout = setTimeout(( ) => {
delete this.chatTimeout;
this.chat.sendButton.removeAttribute('disabled');
}, 1000);
return true;
}
async deleteChatMessage (event) {
const target = event.currentTarget || event.target;
const messageId = target.getAttribute('data-message-id');
event.preventDefault();
event.stopPropagation();
try {
const response = await fetch(`/chat/message/${messageId}`, { method: 'DELETE' });
await this.processResponse(response);
} catch (error) {
this.log.error('deleteChatMessage', 'failed to delete chat message', { error });
UIkit.modal.alert(`Failed to delete chat message: ${error.message}`);
}
}
async toggleMessageReaction (event) {
const target = event.currentTarget || event.target;
const messageId = target.getAttribute('data-message-id');
const emoji = target.getAttribute('data-emoji');
try {
const response = await fetch(`/chat/message/${messageId}/reaction`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ emoji }),
});
await this.processResponse(response);
} catch (error) {
this.log.error('toggleMessageReaction', 'failed to send emoji react', { error });
UIkit.modal.alert(`Failed to send emoji react: ${error.message}`);
}
}
async onDtpLoad ( ) {
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.');
await this.connect({
mode: 'User',
onSocketConnect: this.onChatSocketConnect.bind(this),
onSocketDisconnect: this.onChatSocketDisconnect.bind(this),
});
if (this.chat.messageList) {
try {
this.notificationPermission = await Notification.requestPermission();
this.log.debug('onDtpLoad', 'Notification permission status', { permission: this.notificationPermission });
} catch (error) {
this.log.error('onDtpLoad', 'failed to request Notification permission', { error });
}
}
}
async onDtpUnload ( ) {
await this.socket.disconnect();
}
async onChatSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events');
socket.on('chat-message', this.onChatMessage.bind(this));
socket.on('chat-control', this.onChatControl.bind(this));
socket.on('system-message', this.onSystemMessage.bind(this));
if (dtp.room) {
await this.joinChatChannel(dtp.room);
}
}
async onChatSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events');
socket.off('chat-message', this.onChatMessage.bind(this));
socket.off('chat-control', this.onChatControl.bind(this));
socket.off('system-message', this.onSystemMessage.bind(this));
}
async onChatMessage (message) {
const isAtBottom = this.chat.isAtBottom;
this.chat.messageList.insertAdjacentHTML('beforeend', message.html);
if (!this.haveFocus) {
this.audio.playSound(ChatApp.SFX_CHAT_MESSAGE);
}
this.scrollChatToBottom(isAtBottom);
if (!this.haveFocus && (this.notificationPermission === 'granted')) {
const chatMessage = message.message;
new Notification(chatMessage.channel.name, {
body: `Message received from ${chatMessage.author.displayName || chatMessage.author.username}`,
});
}
}
async onChatControl (message) {
const isAtBottom = this.chat.isAtBottom;
if (message.audio) {
if (message.audio.playSound) {
this.audio.playSound(message.audio.playSound);
}
}
if (message.displayList) {
this.displayEngine.executeDisplayList(message.displayList);
}
if (Array.isArray(message.systemMessages) && (message.systemMessages.length > 0)) {
for await (const sm of message.systemMessages) {
await this.onSystemMessage(sm);
}
}
if (message.cmd) {
switch (message.cmd) {
case 'call-start':
if (message.mediaServer && !this.call) {
dtp.mediaServer = message.mediaServer;
setTimeout(this.joinWebCall.bind(this), Math.floor(Math.random() * 3000));
}
break;
case 'call-end':
if (this.chat) {
this.chat.closeCall();
}
break;
}
}
this.scrollChatToBottom(isAtBottom);
}
async onSystemMessage (message) {
if (message.displayList) {
this.displayEngine.executeDisplayList(message.displayList);
}
if (!message.created || !message.content) {
return;
}
if (!this.chat || !this.chat.messageList) {
return;
}
const systemMessage = document.createElement('div');
systemMessage.classList.add('chat-message');
systemMessage.classList.add('system-message');
systemMessage.classList.add('no-select');
const grid = document.createElement('div');
grid.toggleAttribute('uk-grid', true);
grid.classList.add('uk-grid-small');
systemMessage.appendChild(grid);
let column = document.createElement('div');
column.classList.add('uk-width-expand');
grid.appendChild(column);
const chatContent = document.createElement('div');
chatContent.classList.add('message-content');
chatContent.classList.add('uk-text-break');
chatContent.innerHTML = message.content;
column.appendChild(chatContent);
column = document.createElement('div');
column.classList.add('uk-width-expand');
grid.appendChild(column);
const chatTimestamp = document.createElement('div');
chatTimestamp.classList.add('message-timestamp');
chatTimestamp.classList.add('uk-text-small');
chatTimestamp.classList.add('uk-text-right');
chatTimestamp.setAttribute('data-dtp-timestamp', message.created);
chatTimestamp.setAttribute('data-dtp-timestamp-format', 'time');
chatTimestamp.innerHTML = dayjs(message.created).format('h:mm:ss a');
column.appendChild(chatTimestamp);
this.chat.messageList.appendChild(systemMessage);
this.chat.messages.push(systemMessage);
while (this.chat.messages.length > 50) {
const message = this.chat.messages.shift();
this.chat.messageList.removeChild(message);
}
if (this.chat.isAtBottom) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000);
}
}
async joinChatChannel (room) {
try {
const response = await fetch(`/chat/room/${dtp.room._id}/join`);
await this.processResponse(response);
await this.socket.joinChannel(dtp.room._id, 'ChatRoom');
} catch (error) {
this.log.error('failed to join chat room', { room, error });
UIkit.modal.alert(`Failed to join chat room: ${error.message}`);
}
}
async confirmNavigation (event) {
const target = event.currentTarget || event.target;
event.preventDefault();
event.stopPropagation();
const href = target.getAttribute('href');
const hrefTarget = target.getAttribute('target');
const text = target.textContent;
const whitelist = [
'digitaltelepresence.com',
'www.digitaltelepresence.com',
'chat.digitaltelepresence.com',
'sites.digitaltelepresence.com',
];
try {
const url = new URL(href);
if (!whitelist.includes(url.hostname)) {
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`);
}
window.open(href, hrefTarget);
} catch (error) {
this.log.info('confirmNavigation', 'navigation canceled', { error });
}
return true;
}
async confirmRoomDelete (event) {
const target = event.currentTarget || event.target;
const roomId = target.getAttribute('data-room-id');
const roomName = target.getAttribute('data-room-name');
try {
await UIkit.modal.confirm(`Are you sure you want to delete "${roomName}"?`);
} catch (error) {
return;
}
try {
const response = await fetch(`/chat/room/${roomId}`, { method: 'DELETE' });
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(error.message);
}
}
async generateOtpQR (canvas, keyURI) {
QRCode.toCanvas(canvas, keyURI);
}
async generateQRCanvas (canvas, uri) {
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri });
QRCode.toCanvas(canvas, uri, { width: 256 });
}
async closeAllDropdowns ( ) {
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open');
for (const dropdown of dropdowns) {
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown });
UIkit.dropdown(dropdown).hide(false);
}
}
async muteChatUser (event) {
const target = (event.currentTarget || event.target);
event.preventDefault();
event.stopPropagation();
this.closeAllDropdowns();
const messageId = target.getAttribute('data-message-id');
const userId = target.getAttribute('data-user-id');
const username = target.getAttribute('data-username');
try {
await UIkit.modal.confirm(`Are you sure you want to mute ${username}?`);
} catch (error) {
// canceled or error
return;
}
this.log.info('muteChatUser', 'muting chat user', { messageId, userId, username });
this.mutedUsers.push({ userId, username });
window.localStorage.mutedUsers = JSON.stringify(this.mutedUsers);
document.querySelectorAll(`.chat-message[data-author-id="${userId}"]`).forEach((message) => {
message.parentElement.removeChild(message);
});
}
async unmuteChatUser (event) {
const target = (event.currentTarget || event.target);
event.preventDefault();
event.stopPropagation();
const userId = target.getAttribute('data-user-id');
const username = target.getAttribute('data-username');
this.log.info('muteChatUser', 'muting chat user', { userId, username });
this.mutedUsers = this.mutedUsers.filter((block) => block.userId !== userId);
window.localStorage.mutedUsers = JSON.stringify(this.mutedUsers);
const entry = document.querySelector(`.chat-muted-user[data-user-id="${userId}"]`);
if (!entry) {
return;
}
entry.parentElement.removeChild(entry);
}
async filterChatView ( ) {
if (!this.mutedUsers || (this.mutedUsers.length === 0)) {
return;
}
this.mutedUsers.forEach((block) => {
document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => {
message.parentElement.removeChild(message);
});
});
}
async initSettingsView ( ) {
this.log.info('initSettingsView', 'settings', { settings: this.settings });
const mutedUserList = document.querySelector('ul#muted-user-list');
for (const block of this.mutedUsers) {
const li = document.createElement(`li`);
li.setAttribute('data-user-id', block.userId);
li.classList.add('chat-muted-user');
mutedUserList.appendChild(li);
const grid = document.createElement('div');
grid.setAttribute('uk-grid', '');
grid.classList.add('uk-grid-small');
grid.classList.add('uk-flex-middle');
li.appendChild(grid);
let column = document.createElement('div');
column.classList.add('uk-width-expand');
column.textContent = block.username;
grid.appendChild(column);
column = document.createElement('div');
column.classList.add('uk-width-auto');
grid.appendChild(column);
const button = document.createElement('button');
button.setAttribute('type', 'button');
button.setAttribute('title', `Remove ${block.username} from your mute list`);
button.setAttribute('data-user-id', block.userId);
button.setAttribute('data-username', block.username);
button.setAttribute('onclick', "return dtp.app.unmuteChatUser(event);");
button.classList.add('uk-button');
button.classList.add('uk-button-default');
button.classList.add('uk-button-small');
button.classList.add('uk-border-rounded');
button.textContent = 'Unmute';
column.appendChild(button);
}
}
loadSettings ( ) {
this.settings = { tutorials: { } };
if (window.localStorage) {
if (window.localStorage.settings) {
this.settings = JSON.parse(window.localStorage.settings);
} else {
this.saveSettings();
}
this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ];
this.filterChatView();
}
this.settings.tutorials = this.settings.tutorials || { };
}
saveSettings ( ) {
if (!window.localStorage) { return; }
window.localStorage.settings = JSON.stringify(this.settings);
}
async submitImageForm (event) {
event.preventDefault();
event.stopPropagation();
const formElement = event.currentTarget || event.target;
const form = new FormData(formElement);
this.cropper.getCroppedCanvas().toBlob(async (imageData) => {
try {
const imageId = formElement.getAttribute('data-image-id');
form.append('imageFile', imageData, imageId);
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action });
const response = await fetch(formElement.action, {
method: formElement.method,
body: form,
});
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to upload image: ${error.message}`);
}
});
return;
}
async selectImageFile (event) {
event.preventDefault();
const imageId = event.target.getAttribute('data-image-id');
//z read the cropper options from the element on the page
let cropperOptions = event.target.getAttribute('data-cropper-options');
if (cropperOptions) {
cropperOptions = JSON.parse(cropperOptions);
}
this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done
const fileSelectContainerId = event.target.getAttribute('data-file-select-container');
if (!fileSelectContainerId) {
UIkit.modal.alert('Missing file select container element ID information');
return;
}
const fileSelectContainer = document.getElementById(fileSelectContainerId);
if (!fileSelectContainer) {
UIkit.modal.alert('Missing file select element');
return;
}
const fileSelect = fileSelectContainer.querySelector('input[type="file"]');
if (!fileSelect.files || (fileSelect.files.length === 0)) {
return;
}
const selectedFile = fileSelect.files[0];
if (!selectedFile) {
return;
}
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile });
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i;
if (!filter.test(selectedFile.type)) {
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`);
return;
}
const fileSizeId = event.target.getAttribute('data-file-size-element');
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10);
const fileSize = document.getElementById(fileSizeId);
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b');
if (selectedFile.size > (FILE_MAX_SIZE)) {
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`);
return;
}
// const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w'));
// const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h'));
const reader = new FileReader();
reader.onload = (e) => {
const img = document.getElementById(imageId);
img.onload = (e) => {
console.log('image loaded', e, img.naturalWidth, img.naturalHeight);
// if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) {
// UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`);
// img.setAttribute('hidden', '');
// img.src = '';
// return;
// }
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name;
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow();
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString();
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString();
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true);
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden');
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden');
};
// set the image as the "src" of the <img> in the DOM.
img.src = e.target.result;
//z create cropper and set options here
this.createImageCropper(img, cropperOptions);
};
// read in the file, which will trigger everything else in the event handler above.
reader.readAsDataURL(selectedFile);
}
async createImageCropper (img, options) {
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
options = Object.assign({
aspectRatio: 1,
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
dragMode: 'move',
autoCropArea: 0.85,
restore: false,
guides: false,
center: false,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
modal: true,
}, options);
this.log.info("createImageCropper", "Creating image cropper", { img });
this.cropper = new Cropper(img, options);
}
async removeImageFile (event) {
const target = event.target || event.currentTarget;
const imageType = target.getAttribute('data-image-type');
const channelId = dtp.channel ? dtp.channel._id : dtp.channel;
try {
this.log.info('removeImageFile', 'request to remove image', event);
let imageUrl;
switch (imageType) {
case 'channel-thumbnail-file':
imageUrl = `/channel/${channelId}/thumbnail`;
break;
case 'profile-picture-file':
imageUrl = `/user/${this.user._id}/profile-photo`;
break;
default:
throw new Error(`Invalid image type: ${imageType}`);
}
const response = await fetch(imageUrl, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Server error');
}
await this.processResponse(response);
} catch (error) {
this.log.error('removeImageFile', 'failed to remove image', { error });
UIkit.modal.alert(`Failed to remove image: ${error.message}`);
}
}
scrollChatToBottom (isAtBottom = true) {
if (this.chat && this.chat.messageList && isAtBottom) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight);
setTimeout(( ) => {
this.chat.isAtBottom = true;
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight);
this.chat.isModifying = false;
}, 25);
}
}
async onChatMessageListScroll (event) {
const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight;
if (!this.chat.isModifying) {
this.chat.isAtBottom = (scrollPos >= (this.chat.messageList.scrollHeight - 10));
this.chat.isAtTop = (scrollPos <= 0);
}
if (event && (this.chat.isAtBottom || this.chat.isAtTop)) {
event.preventDefault();
event.stopPropagation();
}
if (this.chat.isAtBottom) {
this.chat.messageMenu.classList.remove('chat-menu-visible');
} else {
this.chat.messageMenu.classList.add('chat-menu-visible');
}
}
async resumeChatScroll ( ) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000);
this.chat.isAtBottom = true;
this.chat.messageMenu.classList.remove('chat-menu-visible');
}
async onWindowResize ( ) {
if (this.chat.messageList && this.chat.isAtBottom) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000);
}
}
async showEmojiPicker (event) {
const target = event.currentTarget || event.target;
const emojiTargetSelector = target.getAttribute('data-target');
this.emojiPickerTarget = document.querySelector(emojiTargetSelector);
if (!this.emojiPickerTarget) {
UIkit.modal.alert('Invalid emoji picker target');
return;
}
this.emojiPickerDisplay.classList.add('picker-active');
}
async onEmojiPickerClose (event) {
if (!this.emojiPickerDisplay) {
return;
}
if (!event.target.classList.contains('emoji-picker-display')) {
return;
}
this.emojiPickerDisplay.classList.remove('picker-active');
}
async onEmojiPicked (event) {
event = event.detail;
this.log.info('onEmojiPicked', 'An emoji has been selected', { event });
this.emojiPickerTarget.value += event.unicode;
}
async processRoomInvite (event) {
const target = event.currentTarget || event.target;
event.preventDefault();
event.stopPropagation();
const roomId = target.getAttribute('data-room-id');
const inviteId = target.getAttribute('data-invite-id');
const action = target.getAttribute('data-invite-action');
try {
const url = `/chat/room/${roomId}/invite/${inviteId}`;
const payload = JSON.stringify({ action });
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': payload.length,
},
body: payload,
});
return this.processResponse(response);
} catch (error) {
this.log.error('processRoomInvite', 'failed to process room invite', { error });
UIkit.modal.alert(`Failed to process room invite: ${error.message}`);
}
}
}

6
client/js/index.js

@ -7,16 +7,14 @@
const DTP_COMPONENT_NAME = 'DtpChat';
const dtp = window.dtp = window.dtp || { };
import { ChatApp } from './chat-client.js';
import { TimeTrackerApp } from './time-tracker-client.js';
import DtpWebLog from 'lib/dtp-log.js';
window.addEventListener('load', async ( ) => {
await import('emoji-picker-element');
dtp.log = new DtpWebLog(DTP_COMPONENT_NAME);
dtp.env = document.body.getAttribute('data-dtp-env');
dtp.app = new ChatApp(dtp.user);
dtp.app = new TimeTrackerApp(dtp.user);
dtp.log.info('load handler', 'application instance created', { env: dtp.env });
await dtp.app.startAudio();

6
client/js/chat-audio.js → client/js/time-tracker-audio.js

@ -1,16 +1,16 @@
// chat-audio.js
// time-tracker-audio.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'ChatAudio';
const DTP_COMPONENT_NAME = 'TimeTrackerAudio';
import DtpLog from 'lib/dtp-log';
const AudioContext = window.AudioContext || window.webkitAudioContext;
export default class ChatAudio {
export default class TimeTrackerAudio {
constructor ( ) {
this.log = new DtpLog(DTP_COMPONENT_NAME);

557
client/js/time-tracker-client.js

@ -0,0 +1,557 @@
// time-tracker-client.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'TimeTrackerApp';
const dtp = window.dtp = window.dtp || { };
import DtpApp from 'lib/dtp-app.js';
import TimeTrackerAudio from './time-tracker-audio.js';
import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(dayjsRelativeTime);
export class TimeTrackerApp extends DtpApp {
static get SFX_TRACKER_START ( ) { return 'tracker-start'; }
static get SFX_TRACKER_UPDATE ( ) { return 'tracker-update'; }
static get SFX_TRACKER_STOP ( ) { return 'tracker-stop'; }
constructor (user) {
super(DTP_COMPONENT_NAME, user);
this.loadSettings();
this.log.info('constructor', 'TimeTrackerApp client online');
this.notificationPermission = 'default';
this.haveFocus = true; // hard to load the app w/o also being the focused app
this.capturePreview = document.querySelector('video#capture-preview');
this.dragFeedback = document.querySelector('.dtp-drop-feedback');
this.currentSessionStartTime = null;
this.currentSessionDuration = document.querySelector('#current-session-duration');
window.addEventListener('dtp-load', this.onDtpLoad.bind(this));
window.addEventListener('focus', this.onWindowFocus.bind(this));
window.addEventListener('blur', this.onWindowBlur.bind(this));
/*
* Page Visibility API hooks
*/
window.addEventListener('pagehide', this.onWindowPageHide.bind(this));
window.addEventListener('freeze', this.onWindowFreeze.bind(this));
window.addEventListener('resume', this.onWindowResume.bind(this));
this.updateTimestamps();
}
async onDtpLoad ( ) {
this.log.info('onDtpLoad', 'dtp-load event received. Connecting to platform.');
await this.connect({
mode: 'User',
onSocketConnect: this.onChatSocketConnect.bind(this),
onSocketDisconnect: this.onChatSocketDisconnect.bind(this),
});
}
async onWindowFocus (event) {
this.log.debug('onWindowFocus', 'window has received focus', { event });
this.haveFocus = true;
}
async onWindowBlur (event) {
this.log.debug('onWindowBlur', 'window has lost focus', { event });
this.haveFocus = false;
}
async onWindowPageHide (event) {
this.log.debug('onWindowPageHide', 'the page is being hidden', { event });
await this.socket.disconnect();
}
async onWindowFreeze (event) {
this.log.debug('onWindowFreeze', 'the page is being frozen', { event });
}
async onWindowResume (event) {
this.log.debug('onWindowResume', 'the page is being resumed', { event });
}
async startAudio ( ) {
this.log.info('startAudio', 'starting audio');
this.audio = new TimeTrackerAudio();
this.audio.start();
try {
await Promise.all([
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_START, '/static/sfx/tracker-start.mp3'),
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_UPDATE, '/static/sfx/tracker-update.mp3'),
this.audio.loadSound(TimeTrackerApp.SFX_TRACKER_STOP, '/static/sfx/tracker-stop.mp3'),
]);
} catch (error) {
this.log.error('startAudio', 'failed to load sound', { error });
// fall through
}
}
async onDragEnter (event) {
event.preventDefault();
event.stopPropagation();
this.log.info('onDragEnter', 'something being dragged has entered the stage', { event });
this.dragFeedback.classList.add('feedback-active');
}
async onDragLeave (event) {
event.preventDefault();
event.stopPropagation();
this.log.info('onDragLeave', 'something being dragged has left the stage', { event });
this.dragFeedback.classList.remove('feedback-active');
}
async onDragOver (event) {
/*
* Inform that we want "copy" as a drop effect and prevent all default
* processing so we'll actually get the files in the drop event. If this
* isn't done, you simply won't get the files in the drop.
*/
event.preventDefault();
event.stopPropagation(); // this ends now!
event.dataTransfer.dropEffect = 'copy';
// this.log.info('onDragOver', 'something was dragged over the stage', { event });
this.dragFeedback.classList.add('feedback-active');
}
async onDrop (event) {
event.preventDefault();
event.stopPropagation();
for (const file of event.dataTransfer.files) {
this.log.info('onDrop', 'a file has been dropped', { file });
}
this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files });
this.dragFeedback.classList.remove('feedback-active');
}
async onChatSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events');
socket.on('system-message', this.onSystemMessage.bind(this));
if (dtp.task) {
await this.socket.joinChannel(dtp.task._id, 'Task');
}
}
async onChatSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events');
socket.off('system-message', this.onSystemMessage.bind(this));
}
async onSystemMessage (message) {
if (message.displayList) {
this.displayEngine.executeDisplayList(message.displayList);
}
}
async confirmNavigation (event) {
const target = event.currentTarget || event.target;
event.preventDefault();
event.stopPropagation();
const href = target.getAttribute('href');
const hrefTarget = target.getAttribute('target');
const text = target.textContent;
const whitelist = [
'digitaltelepresence.com',
'www.digitaltelepresence.com',
'chat.digitaltelepresence.com',
'sites.digitaltelepresence.com',
];
try {
const url = new URL(href);
if (!whitelist.includes(url.hostname)) {
await UIkit.modal.confirm(`<p>You are navigating to <code>${href}</code>, a link or button that was displayed as:</p><p>${text}</p><div class="uk-text-small uk-text-danger">Please only open links to destinations you trust and want to visit.</div>`);
}
window.open(href, hrefTarget);
} catch (error) {
this.log.info('confirmNavigation', 'navigation canceled', { error });
}
return true;
}
async generateOtpQR (canvas, keyURI) {
QRCode.toCanvas(canvas, keyURI);
}
async generateQRCanvas (canvas, uri) {
this.log.info('generateQRCanvas', 'creating QR code canvas', { uri });
QRCode.toCanvas(canvas, uri, { width: 256 });
}
async closeAllDropdowns ( ) {
const dropdowns = document.querySelectorAll('.uk-dropdown.uk-open');
for (const dropdown of dropdowns) {
this.log.info('closeAllDropdowns', 'closing dropdown', { dropdown });
UIkit.dropdown(dropdown).hide(false);
}
}
async initSettingsView ( ) {
this.log.info('initSettingsView', 'settings', { settings: this.settings });
}
loadSettings ( ) {
this.settings = { };
if (window.localStorage) {
if (window.localStorage.settings) {
this.settings = JSON.parse(window.localStorage.settings);
} else {
this.saveSettings();
}
}
}
saveSettings ( ) {
if (!window.localStorage) { return; }
window.localStorage.settings = JSON.stringify(this.settings);
}
async submitImageForm (event) {
event.preventDefault();
event.stopPropagation();
const formElement = event.currentTarget || event.target;
const form = new FormData(formElement);
this.cropper.getCroppedCanvas().toBlob(async (imageData) => {
try {
const imageId = formElement.getAttribute('data-image-id');
form.append('imageFile', imageData, imageId);
this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action });
const response = await fetch(formElement.action, {
method: formElement.method,
body: form,
});
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to upload image: ${error.message}`);
}
});
return;
}
async selectImageFile (event) {
event.preventDefault();
const imageId = event.target.getAttribute('data-image-id');
//z read the cropper options from the element on the page
let cropperOptions = event.target.getAttribute('data-cropper-options');
if (cropperOptions) {
cropperOptions = JSON.parse(cropperOptions);
}
this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done
const fileSelectContainerId = event.target.getAttribute('data-file-select-container');
if (!fileSelectContainerId) {
UIkit.modal.alert('Missing file select container element ID information');
return;
}
const fileSelectContainer = document.getElementById(fileSelectContainerId);
if (!fileSelectContainer) {
UIkit.modal.alert('Missing file select element');
return;
}
const fileSelect = fileSelectContainer.querySelector('input[type="file"]');
if (!fileSelect.files || (fileSelect.files.length === 0)) {
return;
}
const selectedFile = fileSelect.files[0];
if (!selectedFile) {
return;
}
this.log.debug('selectImageFile', 'thumbnail file select', { event, selectedFile });
const filter = /^(image\/jpg|image\/jpeg|image\/png)$/i;
if (!filter.test(selectedFile.type)) {
UIkit.modal.alert(`Unsupported image file type selected: ${selectedFile.type}`);
return;
}
const fileSizeId = event.target.getAttribute('data-file-size-element');
const FILE_MAX_SIZE = parseInt(fileSelect.getAttribute('data-file-max-size'), 10);
const fileSize = document.getElementById(fileSizeId);
fileSize.textContent = numeral(selectedFile.size).format('0,0.0b');
if (selectedFile.size > (FILE_MAX_SIZE)) {
UIkit.modal.alert(`File is too large: ${fileSize.textContent}. Custom thumbnail images may be up to ${numeral(FILE_MAX_SIZE).format('0,0.00b')} in size.`);
return;
}
// const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w'));
// const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h'));
const reader = new FileReader();
reader.onload = (e) => {
const img = document.getElementById(imageId);
img.onload = (e) => {
console.log('image loaded', e, img.naturalWidth, img.naturalHeight);
// if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) {
// UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`);
// img.setAttribute('hidden', '');
// img.src = '';
// return;
// }
fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name;
fileSelectContainer.querySelector('#file-modified').textContent = dayjs(selectedFile.lastModifiedDate).fromNow();
fileSelectContainer.querySelector('#image-resolution-w').textContent = img.naturalWidth.toString();
fileSelectContainer.querySelector('#image-resolution-h').textContent = img.naturalHeight.toString();
fileSelectContainer.querySelector('#file-select').setAttribute('hidden', true);
fileSelectContainer.querySelector('#file-info').removeAttribute('hidden');
fileSelectContainer.querySelector('#file-save-btn').removeAttribute('hidden');
};
// set the image as the "src" of the <img> in the DOM.
img.src = e.target.result;
//z create cropper and set options here
this.createImageCropper(img, cropperOptions);
};
// read in the file, which will trigger everything else in the event handler above.
reader.readAsDataURL(selectedFile);
}
async createImageCropper (img, options) {
// https://github.com/fengyuanchen/cropperjs/blob/main/README.md#options
options = Object.assign({
aspectRatio: 1,
viewMode: 1, // restrict the crop box not to exceed the size of the canvas
dragMode: 'move',
autoCropArea: 0.85,
restore: false,
guides: false,
center: false,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
modal: true,
}, options);
this.log.info("createImageCropper", "Creating image cropper", { img });
this.cropper = new Cropper(img, options);
}
async removeImageFile (event) {
const target = event.target || event.currentTarget;
const imageType = target.getAttribute('data-image-type');
const channelId = dtp.channel ? dtp.channel._id : dtp.channel;
try {
this.log.info('removeImageFile', 'request to remove image', event);
let imageUrl;
switch (imageType) {
case 'channel-thumbnail-file':
imageUrl = `/channel/${channelId}/thumbnail`;
break;
case 'profile-picture-file':
imageUrl = `/user/${this.user._id}/profile-photo`;
break;
default:
throw new Error(`Invalid image type: ${imageType}`);
}
const response = await fetch(imageUrl, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Server error');
}
await this.processResponse(response);
} catch (error) {
this.log.error('removeImageFile', 'failed to remove image', { error });
UIkit.modal.alert(`Failed to remove image: ${error.message}`);
}
}
async onWindowResize ( ) {
if (this.chat.messageList && this.chat.isAtBottom) {
this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight + 50000);
}
}
async taskActivityToggle (event) {
const target = event.currentTarget || event.target;
event.preventDefault();
event.stopPropagation();
try {
if (target.checked) {
await this.startScreenCapture();
return this.startTaskSession();
}
await this.stopScreenCapture();
this.closeTaskSession();
} catch (error) {
this.log.error('taskActivityToggle', 'failed to start task work session', { error });
UIkit.modal.alert(`Failed to start work session: ${error.message}`);
}
}
async startTaskSession ( ) {
try {
const url = `/task/${dtp.task._id}/session/start`;
const response = await fetch(url, { method: 'POST' });
await this.checkResponse(response);
const json = await response.json();
if (!json.success) {
throw new Error(json.message);
}
this.taskSession = json.session;
this.currentSessionStartTime = new Date();
this.screenshotInterval = setInterval(this.captureScreenshot.bind(this), 1000 * 60 * 10);
this.sessionDisplayUpdateInterval = setInterval(this.updateSessionDisplay.bind(this), 250);
this.currentSessionDuration.classList.add('uk-text-success');
this.log.info('startTaskSession', 'task session started', { session: this.taskSession });
} catch (error) {
this.log.error('startTaskSession', 'failed to start task session', { error });
UIkit.modal.alert(`Failed to start task session: ${error.message}`);
throw new Error('failed to start task session', { cause: error });
}
}
async closeTaskSession ( ) {
try {
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/close`;
const response = await fetch(url , { method: 'POST' });
await this.processResponse(response);
clearInterval(this.sessionDisplayUpdateInterval);
delete this.sessionDisplayUpdateInterval;
clearInterval(this.screenshotInterval);
delete this.screenshotInterval;
this.currentSessionDuration.classList.remove('uk-text-success');
delete this.currentSessionStartTime;
} catch (error) {
this.log.error('closeTaskSession', 'failed to close task session', {
session: this.taskSession,
error,
});
UIkit.modal.alert(`Failed to start task session: ${error.message}`);
throw new Error('failed to close task session', { cause: error });
}
}
async startScreenCapture ( ) {
try {
this.captureStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
this.capturePreview.srcObject = this.captureStream;
this.capturePreview.play();
const tracks = this.captureStream.getVideoTracks();
const constraints = tracks[0].getSettings();
this.log.info('startScreenCapture', 'creating capture canvas', {
width: constraints.width,
height: constraints.height,
});
this.captureCanvas = document.createElement('canvas');
this.captureCanvas.width = constraints.width;
this.captureCanvas.height = constraints.height;
this.captureContext = this.captureCanvas.getContext('2d');
} catch (error) {
this.log.error('startTaskSession', 'failed to start task work session', { error });
UIkit.modal.alert(`Failed to start task work session: ${error.message}`);
throw new Error('failed to start screen capture', { cause: error });
}
}
async stopScreenCapture ( ) {
if (!this.captureStream) {
return;
}
this.capturePreview.pause();
this.capturePreview.srcObject = null;
this.captureStream.getTracks().forEach(track => track.stop());
delete this.captureStream;
if (this.captureContext) {
delete this.captureContext;
}
if (this.captureCanvas) {
delete this.captureCanvas;
}
}
async updateSessionDisplay ( ) {
const NOW = new Date();
const duration = dayjs(NOW).diff(this.currentSessionStartTime, 'second');
this.currentSessionDuration.textContent = numeral(duration).format('HH:MM:SS');
}
async captureScreenshot ( ) {
if (!this.captureStream || !this.taskSession) {
return;
}
try {
/*
* Capture the current preview stream frame to the capture canvas
*/
this.captureContext.drawImage(
this.capturePreview,
0, 0,
this.captureCanvas.width,
this.captureCanvas.height,
);
/*
* Generate a PNG Blob from the capture canvas
*/
this.captureCanvas.toBlob(
async (blob) => {
const formData = new FormData();
formData.append('image', blob, 'screenshot.png');
const url = `/task/${dtp.task._id}/session/${this.taskSession._id}/screenshot`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
await this.processResponse(response);
this.log.info('captureScreenshot', 'screenshot posted to task session');
},
'image/png',
1.0,
);
} catch (error) {
this.log.error('captureScreenshot', 'failed to capture screenshot', { error });
}
}
}

BIN
client/static/sfx/message-deleted.mp3

Binary file not shown.

BIN
client/static/sfx/room-connect.mp3

Binary file not shown.

0
client/static/sfx/chat-message.mp3 → client/static/sfx/tracker-start.mp3

0
client/static/sfx/reaction.mp3 → client/static/sfx/tracker-stop.mp3

0
client/static/sfx/reaction-remove.mp3 → client/static/sfx/tracker-update.mp3

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save