Rob Colbert
2 years ago
146 changed files with 6861 additions and 1091 deletions
@ -0,0 +1,413 @@ |
|||
// email.js
|
|||
// Copyright (C) 2021 Digital Telepresence, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const express = require('express'); |
|||
|
|||
const { SiteController,/*, SiteError*/ |
|||
SiteError} = require('../../lib/site-lib'); |
|||
|
|||
class ChatController extends SiteController { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { |
|||
chat: chatService, |
|||
limiter: limiterService, |
|||
session: sessionService, |
|||
} = this.dtp.services; |
|||
|
|||
const upload = this.createMulter(); |
|||
|
|||
const router = express.Router(); |
|||
this.dtp.app.use('/chat', router); |
|||
|
|||
router.use( |
|||
sessionService.authCheckMiddleware({ requireLogin: true }), |
|||
chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), |
|||
async (req, res, next) => { |
|||
res.locals.currentView = 'chat'; |
|||
return next(); |
|||
}, |
|||
); |
|||
|
|||
router.param('roomId', this.populateRoomId.bind(this)); |
|||
router.param('inviteId', this.populateInviteId.bind(this)); |
|||
|
|||
router.post( |
|||
'/room/:roomId/invite/:inviteId/action', |
|||
limiterService.createMiddleware(limiterService.config.chat.postRoomInviteAction), |
|||
upload.none(), |
|||
this.postRoomInviteAction.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room/:roomId/invite', |
|||
limiterService.createMiddleware(limiterService.config.chat.postRoomInvite), |
|||
upload.none(), |
|||
this.postRoomInvite.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room/:roomId', |
|||
limiterService.createMiddleware(limiterService.config.chat.postRoomUpdate), |
|||
this.postRoomUpdate.bind(this), |
|||
); |
|||
|
|||
router.post( |
|||
'/room', |
|||
limiterService.createMiddleware(limiterService.config.chat.postRoomCreate), |
|||
this.postRoomCreate.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/create', |
|||
this.getRoomEditor.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/form/:formName', |
|||
limiterService.createMiddleware(limiterService.config.chat.getRoomForm), |
|||
this.getRoomForm.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/invite/:inviteId', |
|||
limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView), |
|||
this.getRoomInviteView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/invite', |
|||
limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView), |
|||
this.getRoomInviteHome.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId/settings', |
|||
limiterService.createMiddleware(limiterService.config.chat.getRoomSettings), |
|||
this.getRoomSettings.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room/:roomId', |
|||
limiterService.createMiddleware(limiterService.config.chat.getRoomView), |
|||
this.getRoomView.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/room', |
|||
limiterService.createMiddleware(limiterService.config.chat.getRoomHome), |
|||
this.getRoomHome.bind(this), |
|||
); |
|||
|
|||
router.get( |
|||
'/', |
|||
limiterService.createMiddleware(limiterService.config.chat.getHome), |
|||
this.getHome.bind(this), |
|||
); |
|||
|
|||
/* |
|||
* DELETE operations |
|||
*/ |
|||
|
|||
router.delete( |
|||
'/room/:roomId/invite/:inviteId', |
|||
limiterService.createMiddleware(limiterService.config.chat.deleteInvite), |
|||
this.deleteInvite.bind(this), |
|||
); |
|||
|
|||
router.delete( |
|||
'/room/:roomId', |
|||
limiterService.createMiddleware(limiterService.config.chat.deleteRoom), |
|||
this.deleteInvite.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, 'Room not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate roomId', { roomId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async populateInviteId (req, res, next, inviteId) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.invite = await chatService.getRoomInviteById(inviteId); |
|||
if (!res.locals.invite) { |
|||
throw new SiteError(404, 'Invite not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate inviteId', { inviteId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postRoomInviteAction (req, res) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
const { response } = req.body; |
|||
const displayList = this.createDisplayList('room-invite-action'); |
|||
this.log.debug('room invite action', { message: req.body }); |
|||
switch (response) { |
|||
case 'accept': |
|||
await chatService.acceptRoomInvite(res.locals.invite); |
|||
displayList.showNotification( |
|||
`Chat room invite accepted`, |
|||
'success', |
|||
'top-center', |
|||
5000, |
|||
); |
|||
break; |
|||
|
|||
case 'reject': |
|||
await chatService.acceptRoomInvite(res.locals.invite); |
|||
displayList.showNotification( |
|||
`Chat room invite rejected`, |
|||
'success', |
|||
'top-center', |
|||
5000, |
|||
); |
|||
break; |
|||
|
|||
default: |
|||
throw new SiteError(400, 'Must specify invite action'); |
|||
} |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to execute room invite action', { |
|||
inviteId: res.locals.invite._id, |
|||
response: req.body.response, |
|||
error, |
|||
}); |
|||
return res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postRoomInvite (req, res) { |
|||
const { chat: chatService, user: userService } = this.dtp.services; |
|||
this.log.debug('room invite received', { invite: req.body }); |
|||
if (!req.body.username || !req.body.username.length) { |
|||
return res.status(400).json({ success: false, message: 'Please provide a username' }); |
|||
} |
|||
try { |
|||
req.body.username = req.body.username.trim().toLowerCase(); |
|||
while (req.body.username[0] === '@') { |
|||
req.body.username = req.body.username.slice(1); |
|||
} |
|||
|
|||
if (!req.body.username || !req.body.username.length) { |
|||
throw new SiteError(400, 'Please provide a username'); |
|||
} |
|||
|
|||
const member = await userService.getPublicProfile(req.body.username); |
|||
if (!member) { |
|||
throw new SiteError(404, `There is no account with username @${req.body.username}`); |
|||
} |
|||
if (member._id.equals(res.locals.room.owner._id)) { |
|||
throw new SiteError(400, "You can't invite yourself."); |
|||
} |
|||
|
|||
await chatService.sendRoomInvite(res.locals.room, member, req.body); |
|||
|
|||
const displayList = this.createDisplayList('invite create'); |
|||
displayList.showNotification( |
|||
`Chat room invite sent to ${member.displayName || member.username}!`, |
|||
'success', |
|||
'top-left', |
|||
5000, |
|||
); |
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to create room invitation', { error }); |
|||
return res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async postRoomUpdate (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.room = await chatService.updateRoom(res.locals.room, req.body); |
|||
res.redirect(`/chat/room/${res.locals.room._id}`); |
|||
} catch (error) { |
|||
this.log.error('failed to update chat room', { |
|||
// roomId: res.locals.room._id,
|
|||
error, |
|||
}); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postRoomCreate (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) { |
|||
this.log.error('failed to create chat room', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomEditor (req, res) { |
|||
res.render('chat/room/editor'); |
|||
} |
|||
|
|||
async getRoomForm (req, res, next) { |
|||
const validFormNames = [ |
|||
'invite-member', |
|||
]; |
|||
const formName = req.params.formName; |
|||
if (validFormNames.indexOf(formName) === -1) { |
|||
return next(new SiteError(404, 'Form not found')); |
|||
} |
|||
try { |
|||
res.render(`chat/room/form/${formName}`); |
|||
} catch (error) { |
|||
this.log.error('failed to render form', { formName, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomInviteView (req, res) { |
|||
res.render('chat/room/invite/view'); |
|||
} |
|||
|
|||
async getRoomInviteHome (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.invites = { |
|||
new: await chatService.getRoomInvites(res.locals.room, 'new'), |
|||
accepted: await chatService.getRoomInvites(res.locals.room, 'accepted'), |
|||
rejected: await chatService.getRoomInvites(res.locals.room, 'rejected'), |
|||
}; |
|||
res.render('chat/room/invite'); |
|||
} catch (error) { |
|||
this.log.error('failed to render the room invites view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomSettings (req, res) { |
|||
res.render('chat/room/editor'); |
|||
} |
|||
|
|||
async getRoomView (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.pageTitle = res.locals.room.name; |
|||
|
|||
const pagination = { skip: 0, cpp: 20 }; |
|||
res.locals.chatMessages = await chatService.getChannelHistory(res.locals.room, pagination); |
|||
|
|||
res.render('chat/room/view'); |
|||
} catch (error) { |
|||
this.log.error('failed to render chat room view', { roomId: req.params.roomId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getRoomHome (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.pagination = this.getPaginationParameters(req, 20); |
|||
res.locals.publicRooms = await chatService.getPublicRooms(req.user, res.locals.pagination); |
|||
res.render('chat/room/index'); |
|||
} catch (error) { |
|||
this.log.error('failed to render room home', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getHome (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
res.locals.pageTitle = 'Chat Home'; |
|||
|
|||
res.locals.pagination = this.getPaginationParameters(req, 20); |
|||
|
|||
const roomIds = [ ]; |
|||
res.locals.ownedChatRooms.forEach((room) => roomIds.push(room._id)); |
|||
res.locals.joinedChatRooms.forEach((room) => roomIds.push(room._id)); |
|||
res.locals.timeline = await chatService.getMultiRoomTimeline(roomIds, res.locals.pagination); |
|||
|
|||
res.render('chat/index'); |
|||
} catch (error) { |
|||
this.log.error('failed to render chat home', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async deleteInvite (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
if (res.locals.room.owner._id.equals(req.user._id)) { |
|||
throw new SiteError(403, 'This is not your invitiation'); |
|||
} |
|||
|
|||
await chatService.deleteRoomInvite(res.locals.invite); |
|||
|
|||
const displayList = this.createDisplayList('delete chat invite'); |
|||
displayList.removeElement(`li[data-invite-id="${res.locals.invite._id}"]`); |
|||
displayList.showNotification( |
|||
`Invitation to ${res.locals.invite.member.displayName || res.locals.invite.member.username} deleted successfully`, |
|||
'success', |
|||
'top-left', |
|||
5000, |
|||
); |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete chat room invite', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async deleteRoom (req, res, next) { |
|||
const { chat: chatService } = this.dtp.services; |
|||
try { |
|||
if (res.locals.room.owner._id.equals(req.user._id)) { |
|||
throw new SiteError(403, 'This is not your chat room'); |
|||
} |
|||
|
|||
await chatService.deleteRoom(res.locals.room); |
|||
|
|||
const displayList = this.createDisplayList('delete chat invite'); |
|||
displayList.navigateTo('/chat'); |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete chat room invite', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'chat', |
|||
name: 'chat', |
|||
create: async (dtp) => { return new ChatController(dtp); }, |
|||
}; |
@ -0,0 +1,157 @@ |
|||
// comment.js
|
|||
// Copyright (C) 2021 Digital Telepresence, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const express = require('express'); |
|||
const numeral = require('numeral'); |
|||
|
|||
const { SiteController, SiteError } = require('../../lib/site-lib'); |
|||
|
|||
class CommentController extends SiteController { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
const { limiter: limiterService, session: sessionService } = dtp.services; |
|||
|
|||
const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/comment', router); |
|||
|
|||
router.use(async (req, res, next) => { |
|||
res.locals.currentView = module.exports.slug; |
|||
return next(); |
|||
}); |
|||
|
|||
router.param('commentId', this.populateCommentId.bind(this)); |
|||
|
|||
router.post('/:commentId/vote', authRequired, this.postVote.bind(this)); |
|||
|
|||
router.get('/:commentId/replies', this.getCommentReplies.bind(this)); |
|||
|
|||
router.delete('/:commentId', |
|||
authRequired, |
|||
limiterService.createMiddleware(limiterService.config.comment.deleteComment), |
|||
this.deleteComment.bind(this), |
|||
); |
|||
} |
|||
|
|||
async populateCommentId (req, res, next, commentId) { |
|||
const { comment: commentService } = this.dtp.services; |
|||
try { |
|||
res.locals.comment = await commentService.getById(commentId); |
|||
if (!res.locals.comment) { |
|||
return next(new SiteError(404, 'Comment not found')); |
|||
} |
|||
res.locals.post = res.locals.comment.resource; |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate commentId', { commentId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async postVote (req, res) { |
|||
const { contentVote: contentVoteService } = this.dtp.services; |
|||
try { |
|||
const displayList = this.createDisplayList('comment-vote'); |
|||
const { message, resourceStats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote); |
|||
displayList.setTextContent( |
|||
`button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`, |
|||
numeral(resourceStats.upvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), |
|||
); |
|||
displayList.setTextContent( |
|||
`button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`, |
|||
numeral(resourceStats.downvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), |
|||
); |
|||
displayList.showNotification(message, 'success', 'bottom-center', 3000); |
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to process comment vote', { error }); |
|||
return res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
async getCommentReplies (req, res) { |
|||
const { comment: commentService } = this.dtp.services; |
|||
try { |
|||
const displayList = this.createDisplayList('get-replies'); |
|||
|
|||
if (req.query.buttonId) { |
|||
displayList.removeElement(`li.dtp-load-more[data-button-id="${req.query.buttonId}"]`); |
|||
} |
|||
|
|||
Object.assign(res.locals, req.app.locals); |
|||
|
|||
res.locals.countPerPage = parseInt(req.query.cpp || "20", 10); |
|||
if (res.locals.countPerPage < 1) { |
|||
res.locals.countPerPage = 1; |
|||
} |
|||
if (res.locals.countPerPage > 20) { |
|||
res.locals.countPerPage = 20; |
|||
} |
|||
|
|||
res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage); |
|||
res.locals.comments = await commentService.getReplies(res.locals.comment, res.locals.pagination); |
|||
|
|||
const html = await commentService.renderTemplate('replyList', res.locals); |
|||
|
|||
const replyList = `ul.dtp-reply-list[data-comment-id="${res.locals.comment._id}"]`; |
|||
displayList.addElement(replyList, 'beforeEnd', html); |
|||
|
|||
const replyListContainer = `.dtp-reply-list-container[data-comment-id="${res.locals.comment._id}"]`; |
|||
displayList.removeAttribute(replyListContainer, 'hidden'); |
|||
|
|||
if (Array.isArray(res.locals.comments) && (res.locals.comments.length > 0)) { |
|||
displayList.removeElement(`p#empty-comments-label[data-comment-id="${res.locals.comment._id}"]`); |
|||
} |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to display comment replies', { error }); |
|||
res.status(error.statusCode || 500).json({ success: false, message: error.message }); |
|||
} |
|||
} |
|||
|
|||
async deleteComment (req, res) { |
|||
const { comment: commentService } = this.dtp.services; |
|||
try { |
|||
const displayList = this.createDisplayList('add-recipient'); |
|||
|
|||
await commentService.remove(res.locals.comment, 'removed'); |
|||
|
|||
let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`; |
|||
displayList.setTextContent(selector, 'Comment removed'); |
|||
|
|||
displayList.showNotification( |
|||
'Comment removed successfully', |
|||
'success', |
|||
'bottom-center', |
|||
5000, |
|||
); |
|||
|
|||
res.status(200).json({ success: true, displayList }); |
|||
} catch (error) { |
|||
this.log.error('failed to remove comment', { error }); |
|||
return res.status(error.statusCode || 500).json({ |
|||
success: false, |
|||
message: error.message |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'comment', |
|||
name: 'comment', |
|||
create: async (dtp) => { return new CommentController(dtp); }, |
|||
}; |
@ -0,0 +1,70 @@ |
|||
// email.js
|
|||
// Copyright (C) 2021 Digital Telepresence, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
const glob = require('glob'); |
|||
|
|||
const express = require('express'); |
|||
|
|||
const { SiteController,/*, SiteError*/ |
|||
SiteError} = require('../../lib/site-lib'); |
|||
|
|||
class FormController extends SiteController { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { |
|||
chat: chatService, |
|||
limiter: limiterService, |
|||
session: sessionService, |
|||
} = this.dtp.services; |
|||
|
|||
try { |
|||
this.forms = glob.sync(path.join(this.dtp.config.root, 'app', 'views', 'form', '*pug')) || [ ]; |
|||
this.forms = this.forms.map((filename) => path.parse(filename)); |
|||
} catch (error) { |
|||
this.log.error('failed to detect requestable forms', { error }); |
|||
this.forms = [ ]; |
|||
// fall through
|
|||
} |
|||
|
|||
const router = express.Router(); |
|||
this.dtp.app.use('/form', router); |
|||
|
|||
router.use( |
|||
sessionService.authCheckMiddleware({ requireLogin: true }), |
|||
chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), |
|||
async (req, res, next) => { |
|||
res.locals.currentView = module.exports.slug; |
|||
return next(); |
|||
}, |
|||
); |
|||
|
|||
router.get( |
|||
'/:formSlug', |
|||
limiterService.createMiddleware(limiterService.config.form.getForm), |
|||
this.getForm.bind(this), |
|||
); |
|||
} |
|||
|
|||
async getForm (req, res, next) { |
|||
const { formSlug } = req.params; |
|||
const form = this.forms.find((form) => form.name === formSlug); |
|||
if (!form) { |
|||
return next(new SiteError(400, 'Invalid form')); |
|||
} |
|||
res.render(`form/${form.name}`); |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'form', |
|||
name: 'form', |
|||
create: async (dtp) => { return new FormController(dtp); }, |
|||
}; |
@ -0,0 +1,78 @@ |
|||
// notification.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const express = require('express'); |
|||
|
|||
const { SiteController, SiteError } = require('../../lib/site-lib'); |
|||
|
|||
class NotificationController extends SiteController { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
const { limiter: limiterService } = dtp.services; |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/notification', router); |
|||
|
|||
router.use(async (req, res, next) => { |
|||
res.locals.currentView = 'notification'; |
|||
return next(); |
|||
}); |
|||
|
|||
router.param('notificationId', this.populateNotificationId.bind(this)); |
|||
|
|||
router.get( |
|||
'/:notificationId', |
|||
limiterService.createMiddleware(limiterService.config.notification.getNotificationView), |
|||
this.getNotificationView.bind(this), |
|||
); |
|||
|
|||
router.get('/', |
|||
limiterService.createMiddleware(limiterService.config.notification.getNotificationHome), |
|||
this.getNotificationHome.bind(this), |
|||
); |
|||
} |
|||
|
|||
async populateNotificationId (req, res, next, notificationId) { |
|||
const { userNotification: userNotificationService } = this.dtp.services; |
|||
try { |
|||
res.locals.notification = await userNotificationService.getById(notificationId); |
|||
if (!res.locals.notification) { |
|||
throw new SiteError(404, 'Notification not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate notificationId', { notificationId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getNotificationView (req, res) { |
|||
res.render('notification/view'); |
|||
} |
|||
|
|||
async getNotificationHome (req, res, next) { |
|||
const { userNotification: userNotificationService } = this.dtp.services; |
|||
try { |
|||
res.locals.pagination = this.getPaginationParameters(req, 20); |
|||
res.locals.notifications = await userNotificationService.getForUser(req.user, res.locals.pagination); |
|||
res.render('notification/index'); |
|||
} catch (error) { |
|||
this.log.error('failed to render notification home view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'notification', |
|||
name: 'notification', |
|||
create: async (dtp) => { return new NotificationController(dtp); }, |
|||
}; |
@ -0,0 +1,64 @@ |
|||
// attachment.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ATTACHMENT_STATUS_LIST = [ |
|||
'processing', // the attachment is in the processing queue
|
|||
'live', // the attachment is available for use
|
|||
'rejected', // the attachment was rejected (by proccessing queue)
|
|||
'retired', // the attachment has been retired
|
|||
]; |
|||
|
|||
const AttachmentFileSchema = new Schema({ |
|||
bucket: { type: String, required: true }, |
|||
key: { type: String, required: true }, |
|||
mime: { type: String, required: true }, |
|||
size: { type: Number, required: true }, |
|||
etag: { type: String, required: true }, |
|||
}, { |
|||
_id: false, |
|||
}); |
|||
|
|||
/* |
|||
* Attachments are simply files. They can really be any kind of file, but will |
|||
* mostly be image, video, and audio files. |
|||
* |
|||
* owner is the User or CoreUser that uploaded the attachment. |
|||
* |
|||
* item is the item to which the attachment is attached. |
|||
*/ |
|||
const AttachmentSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: 1 }, |
|||
status: { type: String, enum: ATTACHMENT_STATUS_LIST, default: 'processing', required: true }, |
|||
ownerType: { type: String, required: true }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, |
|||
itemType: { type: String, required: true }, |
|||
item: { type: Schema.ObjectId, required: true, index: 1, refPath: 'itemType' }, |
|||
original: { type: AttachmentFileSchema, required: true, select: false }, |
|||
encoded: { type: AttachmentFileSchema, required: true }, |
|||
flags: { |
|||
isSensitive: { type: Boolean, default: false, required: true }, |
|||
}, |
|||
}); |
|||
|
|||
AttachmentSchema.index({ |
|||
ownerType: 1, |
|||
owner: 1, |
|||
}, { |
|||
name: 'attachment_owner_idx', |
|||
}); |
|||
|
|||
AttachmentSchema.index({ |
|||
itemType: 1, |
|||
item: 1, |
|||
}, { |
|||
name: 'attachment_item_idx', |
|||
}); |
|||
|
|||
module.exports = mongoose.model('Attachment', AttachmentSchema); |
@ -0,0 +1,30 @@ |
|||
// chat-room-invite.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Schema = mongoose.Schema; |
|||
|
|||
const INVITE_STATUS_LIST = ['new', 'accepted', 'rejected', 'deleted']; |
|||
|
|||
const ChatRoomInviteSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, |
|||
room: { type: Schema.ObjectId, required: true, ref: 'ChatRoom' }, |
|||
memberType: { type: String, required: true }, |
|||
member: { type: Schema.ObjectId, required: true, index: 1, refPath: 'memberType' }, |
|||
status: { type: String, enum: INVITE_STATUS_LIST, required: true }, |
|||
message: { type: String }, |
|||
}); |
|||
|
|||
ChatRoomInviteSchema.index({ |
|||
room: 1, |
|||
member: 1, |
|||
}, { |
|||
unique: true, |
|||
name: 'chatroom_invite_unique_idx', |
|||
}); |
|||
|
|||
module.exports = mongoose.model('ChatRoomInvite', ChatRoomInviteSchema); |
@ -0,0 +1,44 @@ |
|||
// chat-room.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Schema = mongoose.Schema; |
|||
|
|||
const RoomMemberSchema = new Schema({ |
|||
memberType: { type: String, required: true }, |
|||
member: { type: Schema.ObjectId, required: true, refPath: 'members.memberType' }, |
|||
}); |
|||
|
|||
const ROOM_VISIBILITY_LIST = ['public', 'private']; |
|||
const ROOM_MEMBERSHIP_POICY_LIST = ['open', 'closed']; |
|||
|
|||
const ChatRoomSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: -1 }, |
|||
lastActivity: { type: Date, default: Date.now, required: true, index: -1 }, |
|||
ownerType: { type: String, required: true }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, |
|||
name: { type: String, required: true, maxlength: 100 }, |
|||
description: { type: String, maxlength: 500 }, |
|||
policy: { type: String }, |
|||
latestMessage: { type: Schema.ObjectId, ref: 'ChatMessage' }, |
|||
visibility: { type: String, enum: ROOM_VISIBILITY_LIST, default: 'public', required: true, index: 1 }, |
|||
membershipPolicy: { type: String, enum: ROOM_MEMBERSHIP_POICY_LIST, default: 'open', required: true, index: 1 }, |
|||
members: { type: [RoomMemberSchema], default: [ ], required: true }, |
|||
}); |
|||
|
|||
ChatRoomSchema.index({ |
|||
visibility: 1, |
|||
membershipPolicy: 1, |
|||
}, { |
|||
partialFilterExpression: { |
|||
visibility: 'public', |
|||
membershipPolicy: 'open', |
|||
}, |
|||
name: 'chatroom_public_open_idx', |
|||
}); |
|||
|
|||
module.exports = mongoose.model('ChatRoom', ChatRoomSchema); |
@ -0,0 +1,39 @@ |
|||
// emoji-reaction.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Schema = mongoose.Schema; |
|||
|
|||
const REACTION_LIST = [ |
|||
'clap', |
|||
'fire', |
|||
'happy', |
|||
'laugh', |
|||
'angry', |
|||
'honk', |
|||
]; |
|||
|
|||
const EmojiReactionSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, |
|||
|
|||
/* |
|||
* The thing for which a reaction is being filed. |
|||
*/ |
|||
subjectType: { type: String, required: true }, |
|||
subject: { type: Schema.ObjectId, required: true, index: 1, refPath: 'subjectType' }, |
|||
|
|||
/* |
|||
* The user creating the reaction |
|||
*/ |
|||
userType: { type: String, required: true }, |
|||
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, |
|||
|
|||
reaction: { type: String, enum: REACTION_LIST, required: true }, |
|||
timestamp: { type: Number }, |
|||
}); |
|||
|
|||
module.exports = mongoose.model('EmojiReaction', EmojiReactionSchema); |
@ -0,0 +1,46 @@ |
|||
// sticker.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Schema = mongoose.Schema; |
|||
|
|||
const STICKER_STATUS_LIST = [ |
|||
'processing', // the sticker is in the processing queue
|
|||
'live', // the sticker is available for use
|
|||
'rejected', // the sticker was rejected (by proccessing queue)
|
|||
'retired', // the sticker has been retired
|
|||
]; |
|||
|
|||
const StickerMediaSchema = new Schema( |
|||
{ |
|||
bucket: { type: String, required: true }, |
|||
key: { type: String, required: true }, |
|||
type: { type: String, required: true }, |
|||
size: { type: Number, required: true }, |
|||
}, |
|||
{ |
|||
_id: false, |
|||
}, |
|||
); |
|||
|
|||
/* |
|||
* The intention is for sticker ownership to be defined by forked applications |
|||
* and implemented by things like User, CoreUser, Channel, or whatever can |
|||
* "own" a sticker in your app. |
|||
*/ |
|||
const StickerSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: -1 }, |
|||
status: { type: String, enum: STICKER_STATUS_LIST, default: 'processing', required: true, index: 1 }, |
|||
rejectedReason: { type: String }, |
|||
ownerType: { type: String, required: true, index: 1 }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, |
|||
slug: { type: String, required: true, maxlength: 20, unique: true, index: 1 }, |
|||
original: { type: StickerMediaSchema, required: true, select: false }, |
|||
encoded: { type: StickerMediaSchema }, |
|||
}); |
|||
|
|||
module.exports = mongoose.model('Sticker', StickerSchema); |
@ -0,0 +1,186 @@ |
|||
// cache.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const Attachment = mongoose.model('Attachment'); |
|||
|
|||
const { SiteService } = require('../../lib/site-lib'); |
|||
|
|||
class AttachmentService extends SiteService { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const { user: userService } = this.dtp.services; |
|||
this.populateAttachment = [ |
|||
{ |
|||
path: 'item', |
|||
}, |
|||
{ |
|||
path: 'owner', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
|
|||
this.queue = this.getJobQueue('media'); |
|||
// this.template = this.loadViewTemplate('attachment/components/attachment-standalone.pug');
|
|||
} |
|||
|
|||
async create (owner, attachmentDefinition, file) { |
|||
const { minio: minioService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
/* |
|||
* Fill in as much of the attachment as we can prior to uploading |
|||
*/ |
|||
|
|||
let attachment = new Attachment(); |
|||
attachment.created = NOW; |
|||
|
|||
attachment.ownerType = owner.type; |
|||
attachment.owner = owner._id; |
|||
|
|||
attachment.itemType = attachmentDefinition.itemType; |
|||
attachment.item = mongoose.Types.ObjectId(attachmentDefinition.item._id || attachmentDefinition.item); |
|||
|
|||
attachment.flags.isSensitive = attachmentDefinition.isSensitive === 'on'; |
|||
|
|||
/* |
|||
* Upload the original file to storage |
|||
*/ |
|||
|
|||
const attachmentId = attachment._id.toString(); |
|||
|
|||
attachment.original.bucket = process.env.MINIO_ATTACHMENT_BUCKET || 'dtp-attachments'; |
|||
attachment.original.key = this.getAttachmentKey(attachment, 'original'); |
|||
attachment.original.mime = file.mimetype; |
|||
attachment.original.size = file.size; |
|||
|
|||
const response = await minioService.uploadFile({ |
|||
bucket: attachment.file.bucket, |
|||
key: attachment.file.key, |
|||
filePath: file.path, |
|||
metadata: { |
|||
'X-DTP-Attachment-ID': attachmentId, |
|||
'Content-Type': attachment.metadata.mime, |
|||
'Content-Length': file.size, |
|||
}, |
|||
}); |
|||
|
|||
/* |
|||
* Complete the attachment definition, and save it. |
|||
*/ |
|||
|
|||
attachment.original.etag = response.etag; |
|||
await attachment.save(); |
|||
|
|||
attachment = await this.getById(attachment._id); |
|||
|
|||
await this.queue.add('attachment-ingest', { attachmentId: attachment._id }); |
|||
|
|||
return attachment; |
|||
} |
|||
|
|||
getAttachmentKey (attachment, slug) { |
|||
const attachmentId = attachment._id.toString(); |
|||
const prefix = attachmentId.slice(-4); // last 4 for best entropy
|
|||
return `/attachment/${prefix}/${attachmentId}/${attachmentId}-${slug}}`; |
|||
} |
|||
|
|||
/** |
|||
* Retrieves populated Attachment documents attached to an item. |
|||
* @param {String} itemType The type of item (ex: 'ChatMessage') |
|||
* @param {*} itemId The _id of the item (ex: message._id) |
|||
* @returns Array of attachments associated with the item. |
|||
*/ |
|||
async getForItem (itemType, itemId) { |
|||
const attachments = await Attachment |
|||
.find({ itemType, item: itemId }) |
|||
.sort({ order: 1, created: 1 }) |
|||
.populate(this.populateAttachment) |
|||
.lean(); |
|||
return attachments; |
|||
} |
|||
|
|||
/** |
|||
* Retrieves populated Attachment documents created by a specific owner. |
|||
* @param {User} owner The owner for which Attachments are being fetched. |
|||
* @param {*} pagination Optional pagination of data set |
|||
* @returns Array of attachments owned by the specified owner. |
|||
*/ |
|||
async getForOwner (owner, pagination) { |
|||
const attachments = await Attachment |
|||
.find({ ownerType: owner.type, owner: owner._id }) |
|||
.sort({ order: 1, created: 1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateAttachment) |
|||
.lean(); |
|||
return attachments; |
|||
} |
|||
|
|||
/** |
|||
* |
|||
* @param {mongoose.Types.ObjectId} attachmentId The ID of the attachment |
|||
* @param {Object} options `withOriginal` true|false |
|||
* @returns A populated Attachment document configured per options. |
|||
*/ |
|||
async getById (attachmentId, options) { |
|||
options = Object.assign({ |
|||
withOriginal: false, |
|||
}, options || { }); |
|||
|
|||
let q = Attachment.findById(attachmentId); |
|||
if (options.withOriginal) { |
|||
q = q.select('+original'); |
|||
} |
|||
|
|||
const attachment = await q.populate(this.populateAttachment).lean(); |
|||
return attachment; |
|||
} |
|||
|
|||
/** |
|||
* Updates the status of an Attachment. |
|||
* @param {Attachment} attachment The attachment being modified. |
|||
* @param {*} status The new status of the attachment |
|||
*/ |
|||
async setStatus (attachment, status) { |
|||
await Attachment.updateOne({ _id: attachment._id }, { $set: { status } }); |
|||
} |
|||
|
|||
/** |
|||
* Passes an attachment and options through a Pug template to generate HTML |
|||
* output ready to be inserted into a DOM to present the attachment in the UI. |
|||
* @param {Attachment} attachment |
|||
* @param {Object} attachmentOptions Additional options passed to the template |
|||
* @returns HTML output of the template |
|||
*/ |
|||
async render (attachment, attachmentOptions) { |
|||
return this.attachmentTemplate({ attachment, attachmentOptions }); |
|||
} |
|||
|
|||
/** |
|||
* Creates a Bull Queue job to delete an Attachment including it's processed |
|||
* and original media files. |
|||
* @param {Attachment} attachment The attachment to be deleted. |
|||
* @returns Bull Queue job handle for the newly created job to delete the |
|||
* attachment. |
|||
*/ |
|||
async remove (attachment) { |
|||
this.log.info('creating job to delete attachment', { attachmentId: attachment._id }); |
|||
return await this.queue.add('attachment-delete', { attachmentId: attachment._id }); |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'attachment', |
|||
name: 'attachment', |
|||
create: (dtp) => { return new AttachmentService(dtp); }, |
|||
}; |
@ -0,0 +1,204 @@ |
|||
// sticker.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const slug = require('slug'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Sticker = mongoose.model('Sticker'); |
|||
const User = mongoose.model('User'); |
|||
|
|||
const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); |
|||
|
|||
const MAX_CHANNEL_STICKERS = 50; |
|||
const MAX_USER_STICKERS = 10; |
|||
|
|||
class StickerService extends SiteService { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const { user: userService } = this.dtp.services; |
|||
this.populateSticker = [ |
|||
{ |
|||
path: 'owner', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
|
|||
this.queue = this.getJobQueue('media'); |
|||
this.stickerTemplate = this.loadViewTemplate('sticker/components/sticker-standalone.pug'); |
|||
} |
|||
|
|||
async createSticker (ownerType, owner, file, stickerDefinition) { |
|||
const { minio: minioService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
this.log.debug('received sticker', { file, stickerDefinition }); |
|||
|
|||
// this is faster than Model.countDocuments()
|
|||
const currentStickers = await Sticker.find({ owner: owner._id }).select('_id').lean(); |
|||
|
|||
switch (ownerType) { |
|||
case 'ChatRoom': |
|||
if (currentStickers.length >= MAX_CHANNEL_STICKERS) { |
|||
throw new SiteError(508, `You have ${MAX_CHANNEL_STICKERS} stickers. Please remove a sticker before adding a new one.`); |
|||
} |
|||
break; |
|||
|
|||
case 'User': |
|||
if (currentStickers.length >= MAX_USER_STICKERS) { |
|||
throw new SiteError(508, `You have ${MAX_USER_STICKERS} stickers. Please remove a sticker before adding a new one one.`); |
|||
} |
|||
break; |
|||
} |
|||
|
|||
const sticker = new Sticker(); |
|||
sticker.created = NOW; |
|||
sticker.status = 'processing'; |
|||
sticker.ownerType = ownerType; |
|||
sticker.owner = owner._id; |
|||
sticker.slug = slug(stickerDefinition.slug.toLowerCase().trim()); |
|||
|
|||
const bucket = process.env.MINIO_VIDEO_BUCKET; |
|||
const key = `/stickers/${sticker._id.toString().slice(0, 3)}/${sticker._id}`; |
|||
|
|||
sticker.original = { |
|||
bucket, key, |
|||
type: file.mimetype, |
|||
size: file.size, |
|||
}; |
|||
|
|||
await minioService.uploadFile({ |
|||
bucket, key, |
|||
filePath: file.path, |
|||
metadata: { |
|||
'Content-Type': file.mimetype, |
|||
'Content-Length': file.size, |
|||
}, |
|||
}); |
|||
|
|||
await sticker.save(); |
|||
|
|||
await this.queue.add('sticker-ingest', { stickerId: sticker._id }); |
|||
|
|||
return sticker.toObject(); |
|||
} |
|||
|
|||
async getForChannel (channel) { |
|||
const stickers = await Sticker |
|||
.find({ status: 'live', ownerType: 'Channel', owner: channel._id }) |
|||
.sort({ created: -1 }) |
|||
.populate(this.populateSticker) |
|||
.lean(); |
|||
return stickers; |
|||
} |
|||
|
|||
async getForUser (user) { |
|||
const stickers = await Sticker |
|||
.find({ status: 'live', ownerType: 'User', owner: user._id }) |
|||
.sort({ created: -1 }) |
|||
.populate(this.populateSticker) |
|||
.lean(); |
|||
return stickers; |
|||
} |
|||
|
|||
async getStickers (pagination) { |
|||
const stickers = await Sticker |
|||
.find() |
|||
.sort({ created: -1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateSticker) |
|||
.lean(); |
|||
return stickers; |
|||
} |
|||
|
|||
async getBySlug (slug) { |
|||
const sticker = await Sticker |
|||
.findOne({ slug }) |
|||
.populate(this.populateSticker) |
|||
.lean(); |
|||
return sticker; |
|||
} |
|||
|
|||
async getById (stickerId, includeOriginal = false) { |
|||
let query = Sticker.findOne({ _id: stickerId }); |
|||
if (includeOriginal) { |
|||
query = query.select('+original'); |
|||
} |
|||
const sticker = await query |
|||
.populate(this.populateSticker) |
|||
.lean(); |
|||
return sticker; |
|||
} |
|||
|
|||
async setStatus (sticker, status) { |
|||
await Sticker.updateOne({ _id: sticker._id }, { $set: { status } }); |
|||
} |
|||
|
|||
async resolveStickerSlugs (slugs) { |
|||
const stickers = [ ]; |
|||
await SiteAsync.each(slugs, async (slug) => { |
|||
const sticker = await Sticker.findOne({ slug: slug }); |
|||
if (!sticker) { |
|||
return; |
|||
} |
|||
stickers.push(sticker); |
|||
}); |
|||
return stickers; |
|||
} |
|||
|
|||
async removeSticker (sticker) { |
|||
const stickerId = sticker._id; |
|||
this.log.info('creating sticker delete job', { stickerId }); |
|||
await this.queue.add('sticker-delete', { stickerId }); |
|||
} |
|||
|
|||
async render (sticker, stickerOptions) { |
|||
return this.stickerTemplate({ sticker, stickerOptions }); |
|||
} |
|||
|
|||
async addFavorite (user, stickerId) { |
|||
stickerId = mongoose.Types.ObjectId(stickerId); |
|||
const sticker = await Sticker.findById(stickerId); |
|||
if (!sticker) { |
|||
throw new SiteError(404, 'Sticker not found'); |
|||
} |
|||
await User.updateOne( |
|||
{ _id: user._id }, |
|||
{ |
|||
$addToSet: { favoriteStickers: sticker._id }, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async removeFavorite (user, stickerId) { |
|||
stickerId = mongoose.Types.ObjectId(stickerId); |
|||
await User.updateOne( |
|||
{ _id: user._id }, |
|||
{ |
|||
$pull: { favoriteStickers: stickerId }, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async getFavorites (user) { |
|||
const stickers = await Sticker |
|||
.populate(user.favoriteStickers, this.populateSticker); |
|||
return stickers; |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'sticker', |
|||
name: 'sticker', |
|||
create: (dtp) => { return new StickerService(dtp); }, |
|||
}; |
@ -0,0 +1,11 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
include ../comment/components/section |
|||
include components/announcement |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
+renderAnnouncement(announcement) |
|||
|
|||
+renderCommentSection({ name: `announcement-${announcement._id}`, rootUrl: `/announcement/${announcement._id}/comment` }) |
@ -0,0 +1,151 @@ |
|||
include reaction-button |
|||
mixin renderChatInputForm (room, options = { }) |
|||
form( |
|||
id="chat-input-form", |
|||
data-room-id= room._id, |
|||
onsubmit="return window.dtp.app.chat.sendUserChat(event);", |
|||
).uk-form |
|||
|
|||
input(type="hidden", name="roomType", value= "ChatRoom") |
|||
input(type="hidden", name="room", value= room._id) |
|||
|
|||
#site-emoji-picker |
|||
div THIS IS THE EMOJI PICKER |
|||
|
|||
.uk-padding-small.uk-padding-remove-bottom |
|||
textarea( |
|||
id="chat-input-text", |
|||
name="content", |
|||
rows="2", |
|||
hidden= options.inputHidden, |
|||
).uk-textarea.uk-margin-small |
|||
|
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
button( |
|||
type= "button", |
|||
title= "Insert emoji", |
|||
data-target-element= "chat-input-text", |
|||
onclick= "return dtp.app.chat.toggleEmojiPicker(event);", |
|||
).uk-button.uk-button-default.uk-button-small |
|||
span |
|||
i.far.fa-laugh-beam |
|||
|
|||
.uk-width-auto |
|||
button( |
|||
type= "button", |
|||
title= "Sticker Picker", |
|||
uk-toggle={ target: '#sticker-picker'}, |
|||
onclick="return dtp.app.chat.openChatInput();", |
|||
).uk-button.uk-button-default.uk-button-small |
|||
span |
|||
i.far.fa-image |
|||
#sticker-picker(uk-modal) |
|||
.uk-modal-dialog.uk-modal-body |
|||
button(type="button", uk-close).uk-modal-close-default |
|||
h4.uk-text-center Sticker Picker 9000™ |
|||
ul(uk-tab).uk-flex-center |
|||
li.uk-active |
|||
a(href="")= user.displayName || user.username |
|||
li |
|||
a(href="")= room.name |
|||
li |
|||
a(href="") Favorites |
|||
ul.uk-switcher.chat-sticker-picker |
|||
//- Personal stickers |
|||
li |
|||
if Array.isArray(userStickers) && (userStickers.length > 0) |
|||
div(uk-grid).uk-grid-small.uk-flex-center |
|||
each sticker in userStickers |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-sticker-id= sticker._id, |
|||
data-sticker-slug= sticker.slug, |
|||
onclick="return dtp.app.chat.insertChatSticker(event);", |
|||
).uk-button.uk-button-text |
|||
+renderSticker(sticker) |
|||
else |
|||
.uk-text-center You haven't uploaded any #[a(href="/sticker") Stickers] yet |
|||
|
|||
//- Channel stickers |
|||
li |
|||
if Array.isArray(roomStickers) && (roomStickers.length > 0) |
|||
div(uk-grid).uk-grid-small.uk-flex-center |
|||
each sticker in roomStickers |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-sticker-id= sticker._id, |
|||
data-sticker-slug= sticker.slug, |
|||
onclick="return dtp.app.chat.insertChatSticker(event);", |
|||
).uk-button.uk-button-text |
|||
+renderSticker(sticker) |
|||
else |
|||
.uk-text-center This room hasn't uploaded any #[a(href="/sticker") Stickers] yet |
|||
|
|||
//- Favorite/Saved stickers |
|||
li |
|||
if Array.isArray(favoriteStickers) && (favoriteStickers.length > 0) |
|||
div(uk-grid).uk-grid-small.uk-flex-center |
|||
each sticker in favoriteStickers |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-sticker-id= sticker._id, |
|||
data-sticker-slug= sticker.slug, |
|||
onclick="return dtp.app.chat.insertChatSticker(event);", |
|||
).uk-button.uk-button-text |
|||
+renderSticker(sticker) |
|||
else |
|||
.uk-text-center You haven't saved any Favorite stickers |
|||
|
|||
.uk-width-auto |
|||
button( |
|||
type= "button", |
|||
title= "Attach image", |
|||
onclick="return dtp.app.chat.createAttachment(event);", |
|||
).uk-button.uk-button-default.uk-button-small |
|||
span |
|||
i.fas.fa-file-image |
|||
|
|||
.uk-width-expand |
|||
if !options.hideHomeNav |
|||
.uk-text-small.uk-text-center.uk-text-truncate |
|||
a(href="/chat", title= "Chat Home").uk-button.uk-button-default.uk-button-small |
|||
span |
|||
i.fas.fa-home |
|||
|
|||
.uk-width-auto |
|||
button( |
|||
id="chat-input-btn", |
|||
type="button", |
|||
onclick="return dtp.app.chat.toggleChatInput(event);", |
|||
title= "Toggle Chat Input", |
|||
).uk-button.uk-button-default.uk-button-small |
|||
span |
|||
i.fas.fa-edit |
|||
|
|||
.uk-width-auto |
|||
button( |
|||
id="chat-send-btn", |
|||
type="submit", |
|||
title= "Send Message", |
|||
).uk-button.uk-button-primary.uk-button-small |
|||
span |
|||
i.far.fa-paper-plane |
|||
|
|||
div(style="margin-top: 4px;") |
|||
.uk-flex.uk-flex-between |
|||
.uk-width-auto |
|||
+renderReactionButton('Applaud/clap', '👏', 'clap') |
|||
.uk-width-auto |
|||
+renderReactionButton("On Fire!", '🔥', 'fire') |
|||
.uk-width-auto |
|||
+renderReactionButton("Happy", "🤗", "happy") |
|||
.uk-width-auto |
|||
+renderReactionButton("Laugh", "🤣", "laugh") |
|||
.uk-width-auto |
|||
+renderReactionButton("Angry", "🤬", "angry") |
|||
.uk-width-auto |
|||
+renderReactionButton("Honk", "🤡", "honk") |
@ -0,0 +1,4 @@ |
|||
include ../../user/components/profile-icon |
|||
include message |
|||
|
|||
+renderChatMessage(message) |
@ -0,0 +1,41 @@ |
|||
include ../../sticker/components/sticker |
|||
mixin renderChatMessage (message, options = { }) |
|||
- var authorName = message.author.displayName || message.author.username; |
|||
div( |
|||
data-message-id= message._id, data-author-id= message.author._id |
|||
).chat-message |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfileIcon(message.author, message.author.displayName || message.author.username, 'xsmall') |
|||
|
|||
.uk-width-expand |
|||
.chat-username.uk-text-truncate= message.author.displayName || message.author.username |
|||
.uk-text-small.uk-text-muted.uk-text-truncate= message.author.username |
|||
|
|||
if !options.hideMenu && (user && !message.author._id.equals(user._id)) |
|||
.uk-width-auto.chat-user-menu |
|||
button(type="button").uk-button.uk-button-link.chat-menu-button |
|||
i.fas.fa-ellipsis-h |
|||
div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu |
|||
ul.uk-nav.uk-dropdown-nav |
|||
li |
|||
a( |
|||
href="", |
|||
data-message-id= message._id, |
|||
data-user-id= message.author._id, |
|||
data-username= message.author.username, |
|||
onclick="return dtp.app.muteChatUser(event);", |
|||
) Mute #{authorName} |
|||
|
|||
//- we're gonna go ahead and force long lines to break, and the content was |
|||
//- filtered at ingest for zalgo and HTML/XSS |
|||
.chat-content.uk-text-break!= marked.parse(message.content) |
|||
|
|||
//- "time" is filled in by the JavaScript client using the browser's locale |
|||
//- information so that "time" is always in the user's display timezone. |
|||
.chat-timestamp(data-dtp-timestamp= message.created).uk-text-small |
|||
|
|||
if Array.isArray(message.stickers) && (message.stickers.length > 0) |
|||
each sticker in message.stickers |
|||
+renderSticker(sticker, { hideSlug: true }) |
@ -0,0 +1,8 @@ |
|||
mixin renderReactionButton (title, emoji, reaction) |
|||
button( |
|||
title= title, |
|||
data-reaction= reaction, |
|||
onclick="return dtp.app.chat.sendReaction(event);", |
|||
).dtp-button-reaction |
|||
span.button-icon= emoji |
|||
span(class="uk-visible@l").count-label |
@ -0,0 +1,17 @@ |
|||
mixin renderRoomList (rooms, options) |
|||
ul#room-list.uk-nav.uk-nav-default |
|||
|
|||
li.uk-nav-header |
|||
div(uk-grid).uk-grid-small |
|||
.uk-text-bold.uk-width-expand= options.title |
|||
if !options.hideCreate |
|||
.uk-width-auto |
|||
a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset |
|||
i.fas.fa-plus |
|||
|
|||
if Array.isArray(rooms) && (rooms.length > 0) |
|||
each room in rooms |
|||
li.uk-active |
|||
a(href=`/chat/room/${room._id}`)= room.name |
|||
else |
|||
li= options.emptyPrompt |
@ -0,0 +1,12 @@ |
|||
mixin renderUserListEntry (user, label) |
|||
div(uk-grid).uk-grid-small |
|||
div(class="uk-width-1-1 uk-width-auto@m") |
|||
a(href= getUserProfileUrl(user)) |
|||
+renderProfileIcon(user, user.displayName || user.username, 'small') |
|||
|
|||
div(class="uk-width-1-1 uk-width-expand@m").no-select |
|||
.uk-margin-small |
|||
.uk-text-bold.dtp-text-tight= user.displayName || user.username |
|||
.uk-text-small.dtp-text-tight @#{user.username} |
|||
if label |
|||
.uk-label= label |
@ -0,0 +1,22 @@ |
|||
extends layouts/room |
|||
block content |
|||
|
|||
include components/message |
|||
|
|||
#site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.chat-menubar.uk-padding-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
img(src=`/img/icon/${site.domainKey}/icon-48x48.png`, alt=`${site.name} icon`) |
|||
.uk-width-expand |
|||
h1.uk-margin-remove #{site.name} Chat Timeline |
|||
|
|||
.chat-content-wrapper |
|||
#chat-message-list-wrapper.uk-height-1-1 |
|||
#chat-message-list |
|||
each message in timeline |
|||
+renderChatMessage(message, { includeRoomInfo: true }) |
|||
.chat-message-menu |
|||
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling |
|||
|
|||
//- pre= JSON.stringify(userTimeline, null, 2) |
@ -0,0 +1,39 @@ |
|||
extends ../../layouts/main |
|||
block page-footer |
|||
block content-container |
|||
|
|||
include ../components/user-list-entry |
|||
include ../components/room-list |
|||
|
|||
section.site-chat-section |
|||
div(uk-grid).uk-height-1-1 |
|||
div(class="uk-width-1-1 uk-width-1-5@l uk-flex-last uk-flex-first@l").uk-height-1-1.uk-overflow-auto |
|||
.site-chat-sidebar-widget.uk-border-rounded.uk-margin |
|||
+renderRoomList(ownedChatRooms, { title: "Your Rooms", emptyPrompt: "You don't own any chat rooms" }) |
|||
|
|||
.site-chat-sidebar-widget.uk-border-rounded |
|||
+renderRoomList(joinedChatRooms, { title: "Joined Rooms", emptyPrompt: "You haven't joined any chat rooms", hideCreate: true }) |
|||
|
|||
div(class="uk-width-1-1 uk-width-expand@l").uk-height-1-1 |
|||
#chat-room.uk-height-1-1 |
|||
block content |
|||
|
|||
div(class="uk-width-1-1 uk-width-1-5@l").uk-height-1-1.uk-overflow-auto |
|||
if room |
|||
.site-chat-sidebar-widget.uk-border-rounded |
|||
ul#room-member-list.uk-nav.uk-nav-default |
|||
li.uk-nav-header Room Owner |
|||
li |
|||
+renderUserListEntry(room.owner, 'owner') |
|||
|
|||
if room.moderators && (room.moderators.length > 0) |
|||
li.uk-nav-header Moderators |
|||
each membership in room.moderators |
|||
li |
|||
+renderUserListEntry(membership.member, 'moderator') |
|||
|
|||
if Array.isArray(room.members) && (room.members.length > 0) |
|||
li.uk-nav-header Members |
|||
each membership in room.members |
|||
li |
|||
+renderUserListEntry(membership.member, 'member') |
@ -0,0 +1,66 @@ |
|||
extends ../layouts/room |
|||
block content |
|||
|
|||
- var actionUrl = room ? `/chat/room/${room._id}` : '/chat/room'; |
|||
|
|||
.content-block.uk-height-1-1.uk-overflow-auto |
|||
form(method="POST", action= actionUrl).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.uk-text-truncate #{room ? room.name : 'Create Chat Room'} |
|||
if room |
|||
.uk-width-auto |
|||
button(type="button", onclick="return dtp.app.chat.deleteChatRoom(event);").uk-button.uk-button-danger.uk-border-rounded |
|||
span |
|||
i.fas.fa-trash |
|||
span.uk-margin-small-left Delete |
|||
|
|||
.uk-card-body |
|||
.uk-margin |
|||
label(for="name").uk-form-label Room name |
|||
input(id="name", name="name", type="text", placeholder="Enter room name", value= room ? room.name : undefined).uk-input |
|||
|
|||
.uk-margin |
|||
label(for="description").uk-form-label Room description |
|||
textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea= room ? room.description : undefined |
|||
|
|||
.uk-margin |
|||
label(for="policy").uk-form-label Room policy |
|||
textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea= room ? room.policy : undefined |
|||
|
|||
.uk-margin |
|||
div(uk-grid) |
|||
.uk-width-auto |
|||
fieldset |
|||
legend Room Visibility |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
label |
|||
input(id="is-public", name="visibility", type="radio", value="public", checked= room ? room.visibility === 'public' : true).uk-radio |
|||
| Public |
|||
.ui-width-auto |
|||
label |
|||
input(id="is-private", name="visibility", type="radio", value="private", checked= room ? room.visibility === 'private' : false).uk-radio |
|||
| Private |
|||
|
|||
.uk-width-auto |
|||
fieldset |
|||
legend Membership Policy |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
label |
|||
input(id="membership-open", name="membershipPolicy", type="radio", value="open", checked= room ? room.membershipPolicy === 'open' : true).uk-radio |
|||
| Open |
|||
.uk-width-auto |
|||
label |
|||
input(id="membership-closed", name="membershipPolicy", type="radio", value="closed", checked= room ? room.membershipPolicy === 'closed' : false).uk-radio |
|||
| Closed |
|||
|
|||
.uk-card-footer |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
+renderBackButton() |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-primary.uk-border-rounded #{room ? 'Update' : 'Create'} room |
@ -0,0 +1,22 @@ |
|||
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 chat member');" |
|||
).uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title Invite Member |
|||
|
|||
.uk-card-body |
|||
p You are inviting a new member to #{room.name} |
|||
.uk-margin |
|||
label(for="username").uk-form-label Username |
|||
input(id="username", name="username", type="text", maxlength="100", required).uk-input |
|||
.uk-margin |
|||
label(for="message").uk-form-label Message |
|||
textarea(id="message", name="message", rows="2", placeholder="Enter message for recipient").uk-textarea |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Send |
@ -0,0 +1,29 @@ |
|||
extends ../layouts/room |
|||
block content |
|||
|
|||
mixin renderRoomTile (room) |
|||
div(data-room-id= room._id, data-room-name= room.name).uk-tile.uk-tile-default.uk-tile-small |
|||
.uk-tile-body |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
.uk-width-expand |
|||
.uk-margin-small |
|||
div(title= room.name).uk-text-bold.uk-text-truncate= room.name |
|||
.uk-text-small.uk-text-truncate= room.description |
|||
div(uk-grid).uk-grid-small.uk-text-small.uk-text-muted.no-select |
|||
.uk-width-expand |
|||
a(href= getUserProfileUrl(room.owner))= room.owner.username |
|||
.uk-width-auto |
|||
span |
|||
i.fas.fa-users |
|||
span.uk-margin-small-left= formatCount(room.members.length) |
|||
|
|||
.uk-height-1-1.uk-overflow-auto |
|||
|
|||
h1 Public Rooms |
|||
div(uk-grid) |
|||
each room in publicRooms |
|||
.uk-width-1-3 |
|||
+renderRoomTile(room) |
|||
|
|||
pre= JSON.stringify(publicRooms, null, 2) |
@ -0,0 +1,25 @@ |
|||
mixin renderInviteListItem (invite) |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfileIcon(invite.member, 'Invited member') |
|||
.uk-width-expand |
|||
.uk-text-bold.uk-text-truncate= invite.member.displayName || invite.member.username |
|||
.uk-text-small.uk-text-muted |
|||
.uk-text-truncate= invite.member.username |
|||
.uk-text-truncate= moment(invite.created).fromNow() |
|||
if invite.status === 'new' |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-room-id= invite.room._id, |
|||
data-invite-id= invite._id, |
|||
onclick='return dtp.app.chat.deleteInvite(event);', |
|||
).uk-button.uk-button-danger.uk-button-small.uk-border-rounded |
|||
span |
|||
i.fas.fa-trash |
|||
span.uk-margin-small-left DELETE |
|||
|
|||
if invite.message |
|||
label.uk-form-label Message to @#{invite.member.username}: |
|||
div= invite.message |
@ -0,0 +1,6 @@ |
|||
include invite-list-item |
|||
mixin renderInviteList (invites) |
|||
ul.uk-list |
|||
each invite in invites |
|||
li(data-invite-id= invite._id) |
|||
+renderInviteListItem(invite) |
@ -0,0 +1,34 @@ |
|||
extends ../../layouts/room |
|||
block content |
|||
|
|||
include components/invite-list |
|||
|
|||
.uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.uk-card-header |
|||
h1.uk-card-title.uk-margin-remove #{room.name} |
|||
div Membership invitation manager |
|||
|
|||
.uk-card-body.uk-flex-1.uk-overflow-auto |
|||
+renderSectionTitle('Sent') |
|||
.uk-margin |
|||
if (Array.isArray(invites.new) && (invites.new.length > 0)) |
|||
+renderInviteList(invites.new) |
|||
else |
|||
div No unresolved invitations. |
|||
|
|||
+renderSectionTitle('Accepted') |
|||
.uk-margin |
|||
if (Array.isArray(invites.accepted) && (invites.accepted.length > 0)) |
|||
+renderInviteList(invites.accepted) |
|||
else |
|||
div No accepted invitations. |
|||
|
|||
+renderSectionTitle('Rejected') |
|||
.uk-margin |
|||
if (Array.isArray(invites.rejected) && (invites.rejected.length > 0)) |
|||
+renderInviteList(invites.rejected) |
|||
else |
|||
div No outstanding rejected invitations. |
|||
|
|||
.uk-card-footer |
|||
+renderBackButton() |
@ -0,0 +1,59 @@ |
|||
extends ../../layouts/room |
|||
block content |
|||
|
|||
include ../../../kaleidoscope/components/event |
|||
include ../../../user/components/attribution-header |
|||
|
|||
form( |
|||
method="POST", |
|||
action=`/chat/room/${invite.room._id}/invite/${invite._id}/action`, |
|||
onsubmit='return dtp.app.submitForm(event, "chat-invite-action");' |
|||
).uk-height-1-1 |
|||
.uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.uk-card-header |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
a(href= getUserProfileUrl(invite.room.owner)) |
|||
+renderProfileIcon(invite.room.owner, invite.room.owner.displayName || invite.room.owner.username, 'small') |
|||
.uk-width-expand |
|||
h1.uk-card-title.uk-margin-remove.dtp-text-tight #{invite.room.name} |
|||
if invite.room.description && (invite.room.description.length > 0) |
|||
.uk-text-small.dtp-text-tight |
|||
div!= marked.parse(invite.room.description) |
|||
div(uk-grid).uk-text-small.uk-text-muted |
|||
.uk-width-auto |
|||
div(title= "Member count").no-select |
|||
span |
|||
i.fas.fa-users |
|||
span.uk-margin-small-left= formatCount(invite.room.members.length) |
|||
|
|||
.uk-card-body.uk-flex-1.uk-overflow-auto |
|||
.uk-margin |
|||
.uk-text-bold Status |
|||
div(class={ |
|||
'uk-text-info': (invite.status === 'new'), |
|||
'uk-text-success': (invite.status === 'accepted'), |
|||
'uk-text-error': (invite.status === 'rejected'), |
|||
'uk-text-muted': (invite.status === 'deleted'), |
|||
})= invite.status |
|||
|
|||
.uk-margin |
|||
.uk-text-bold Invite message |
|||
div!= marked.parse(invite.message) |
|||
|
|||
if invite.room.policy && (invite.room.policy.length > 0) |
|||
.uk-margin |
|||
.uk-text-bold Room policy |
|||
div!= marked.parse(invite.room.policy) |
|||
|
|||
.uk-card-footer |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
+renderBackButton() |
|||
|
|||
.uk-width-auto(hidden= invite.status !== 'new') |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
button(type="submit", name="response", value="reject").uk-button.uk-button-danger.uk-border-rounded Reject |
|||
.uk-width-auto |
|||
button(type="submit", name="response", value="accept").uk-button.uk-button-primary.uk-border-rounded Accept |
@ -0,0 +1,93 @@ |
|||
extends ../layouts/room |
|||
block content |
|||
|
|||
include ../../user/components/profile-icon |
|||
|
|||
include ../components/input-form |
|||
include ../components/message |
|||
|
|||
#site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.chat-menubar.uk-padding-small |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
div(class="uk-width-expand").no-select |
|||
div(title= room.name).chat-room-name.uk-margin-remove.uk-text-truncate= room.name |
|||
.uk-text-small.uk-text-muted.uk-text-truncate= room.description |
|||
|
|||
div(title="Total Members", class="uk-visible@m").uk-width-auto.no-select |
|||
span |
|||
i.fas.fa-users |
|||
span.uk-margin-small-left= formatCount(room.members.length) |
|||
|
|||
if !user || !room.owner._id.equals(user._id) |
|||
|
|||
.uk-width-auto |
|||
.uk-inline |
|||
button(type="button").uk-button.uk-button-link.uk-button-small |
|||
i.fas.fa-ellipsis-h |
|||
#chat-room-menu(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) |
|||
ul.uk-nav.uk-nav-default.uk-dropdown-nav |
|||
li |
|||
a(href=`/chat/room/${room._id}/widget`, target="_blank") |
|||
span.nav-item-icon |
|||
i.fas.fa-comment-dots |
|||
span Pop-Out Chat |
|||
|
|||
|
|||
if user && !room.owner._id.equals(user._id) |
|||
a( |
|||
href="" |
|||
data-room-id= room._id, |
|||
onclick="return dtp.app.chat.leaveRoom(event);", |
|||
) |
|||
span.nav-item-icon |
|||
i.fas.fa-sign-out-alt |
|||
span Leave Room |
|||
|
|||
if user && room.owner._id.equals(user._id) |
|||
li.uk-nav-divider |
|||
|
|||
li |
|||
a( |
|||
href="", |
|||
data-room-id= room._id, |
|||
onclick=`return dtp.app.chat.showForm(event, '${room._id}', 'invite-member');` |
|||
) |
|||
span.nav-item-icon |
|||
i.fas.fa-user-plus |
|||
span Invite New Member |
|||
|
|||
li |
|||
a( |
|||
href=`/chat/room/${room._id}/invite`, |
|||
data-room-id= room._id, |
|||
) |
|||
span.nav-item-icon |
|||
i.fas.fa-mail-bulk |
|||
span Manage Invites |
|||
|
|||
li.uk-nav-divider |
|||
|
|||
li |
|||
a(href=`/chat/room/${room._id}/settings`) |
|||
span.nav-item-icon |
|||
i.fas.fa-cog |
|||
span Settings |
|||
|
|||
|
|||
.chat-content-wrapper |
|||
div(uk-grid).uk-grid-small.uk-height-1-1 |
|||
#chat-webcam-container(hidden).uk-width-auto |
|||
ul#chat-webcam-list.uk-list |
|||
|
|||
.uk-width-expand |
|||
#chat-message-list-wrapper.uk-height-1-1 |
|||
#chat-message-list |
|||
each message in chatMessages || [ ] |
|||
+renderChatMessage(message) |
|||
|
|||
#chat-reactions.no-select |
|||
|
|||
.chat-message-menu |
|||
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling |
|||
|
|||
+renderChatInputForm(room) |
@ -1,4 +1,4 @@ |
|||
include ../../components/library |
|||
include comment-list |
|||
include composer |
|||
+renderCommentList(comments, { rootUrl: `/post/${post.slug}/comment`, countPerPage }) |
|||
+renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/post/${post.slug}/comment`, countPerPage }) |
@ -1,4 +1,4 @@ |
|||
include ../../components/library |
|||
include comment |
|||
include composer |
|||
+renderComment(comment) |
|||
+renderComment(comment, appletOptions || { }) |
@ -1,4 +1,4 @@ |
|||
include ../../components/library |
|||
include comment-list |
|||
include composer |
|||
+renderCommentList(comments, { rootUrl: `/comment/${comment._id}/replies`, countPerPage }) |
|||
+renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/comment/${comment._id}/replies`, countPerPage }) |
@ -0,0 +1,38 @@ |
|||
include composer |
|||
include comment-list |
|||
|
|||
mixin renderCommentSection (options = { }) |
|||
section |
|||
.uk-container |
|||
if user && user.permissions.canComment |
|||
- |
|||
const composerOptions = Object.assign({ }, options); |
|||
composerOptions.name = `${options.name}-composer`; |
|||
.content-block(dtp-comments= composerOptions.name, data-root-url= options.rootUrl) |
|||
.uk-margin |
|||
+renderSectionTitle('Add a comment') |
|||
.uk-margin-small |
|||
+renderCommentComposer(composerOptions.name, composerOptions) |
|||
|
|||
if featuredComment |
|||
.content-block(dtp-comments= `${options.name}-feature`, data-root-url= options.rootUrl) |
|||
#featured-comment.uk-margin-large |
|||
.uk-margin |
|||
+renderSectionTitle('Linked Comment') |
|||
- |
|||
const featureOptions = Object.assign({ }, options); |
|||
featureOptions.name = `${options.name}-feature`; |
|||
+renderComment(featuredComment, featureOptions) |
|||
|
|||
.content-block(dtp-comments= options.name, data-root-url= options.rootUrl) |
|||
+renderSectionTitle('Comments') |
|||
|
|||
if Array.isArray(comments) && (comments.length > 0) |
|||
ul#post-comment-list.uk-list.uk-list-divider.uk-list-large |
|||
+renderCommentList(comments, Object.assign({ |
|||
countPerPage: countPerPage || 10, |
|||
rootUrl: options.rootUrl, |
|||
}, options)) |
|||
else |
|||
ul#post-comment-list.uk-list.uk-list-divider.uk-list-large |
|||
div There are no comments at this time. Please check back later. |
@ -1,4 +1,5 @@ |
|||
mixin renderButtonIcon (buttonClass, buttonLabel) |
|||
span |
|||
i(class=`fas ${buttonClass}`) |
|||
span(class="uk-visible@m").uk-margin-small-left= buttonLabel |
|||
if buttonLabel |
|||
span(class="uk-visible@m").uk-margin-small-left= buttonLabel |
@ -0,0 +1,43 @@ |
|||
mixin renderKaleidoscopeEvent (event) |
|||
div( |
|||
data-event-id= event._id, |
|||
data-event-source= event.source.pkg.name, |
|||
data-event-action= event.action, |
|||
).kaleidoscope-event |
|||
if event.thumbnail |
|||
img(src= event.thumbnail).event-feature-img |
|||
|
|||
header.event-header |
|||
if event.label |
|||
h4.uk-comment-title.uk-margin-small= event.label |
|||
|
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
if event.source.emitter |
|||
.uk-width-auto |
|||
a(href= event.source.emitter.href, uk-title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`) |
|||
img(src=`//${event.source.site.domain}/hive/user/${event.source.emitter.emitterId}/picture`).site-profile-picture.sb-xsmall |
|||
.uk-width-expand |
|||
if event.source.emitter |
|||
.uk-text-bold= event.source.emitter.displayName |
|||
.uk-text-small |
|||
a( |
|||
href= event.source.emitter.href, |
|||
title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`, |
|||
) #{event.source.emitter.username}@#{event.source.site.domainKey} |
|||
|
|||
.event-content!= marked.parse(event.content) |
|||
|
|||
.event-footer |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
.uk-text-small.uk-text-muted |
|||
a(href= event.href, title= "Open destination")= moment(event.created).fromNow() |
|||
.uk-width-auto |
|||
.uk-text-small.uk-text-muted #[span= event.source.pkg.name] |
|||
.uk-width-expand |
|||
.uk-text-small.uk-text-muted= event.action |
|||
.uk-width-auto |
|||
a(href=`//${event.source.site.domain}`, title= event.source.site.name) |
|||
img( |
|||
src=`//${event.source.site.domain}/img/icon/${event.source.site.domainKey}/icon-16x16.png`, |
|||
).site-favicon |
@ -0,0 +1,14 @@ |
|||
extends ../layouts/main-sidebar |
|||
block content |
|||
|
|||
include ../kaleidoscope/components/event |
|||
|
|||
+renderSectionTitle('Notifications') |
|||
|
|||
if Array.isArray(notifications) && (notifications.length > 0) |
|||
ul.uk-list |
|||
each notification in notifications |
|||
li |
|||
+renderKaleidoscopeEvent(notification.event) |
|||
else |
|||
div No notifications |
@ -0,0 +1,2 @@ |
|||
include sticker |
|||
+renderSticker(sticker, stickerOptions || { }) |
@ -0,0 +1,19 @@ |
|||
mixin renderSticker (sticker, options = { }) |
|||
if sticker && sticker.encoded |
|||
div( |
|||
title= `:${sticker.slug}:`, |
|||
data-sticker-id= sticker._id, |
|||
data-sticker-slug= sticker.slug, |
|||
onclick= 'return dtp.app.showStickerMenu(event);', |
|||
).chat-sticker.uk-text-center |
|||
case sticker.encoded.type |
|||
when 'video/mp4' |
|||
video(playsinline, autoplay, muted, loop) |
|||
source(src=`/sticker/${sticker._id}/media`) |
|||
when 'image/png' |
|||
img(src=`/sticker/${sticker._id}/media`) |
|||
when 'image/jpg' |
|||
img(src=`/sticker/${sticker._id}/media`) |
|||
|
|||
if !options.hideSlug |
|||
.uk-text-small.uk-text-muted :#{sticker.slug}: |
@ -0,0 +1,69 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
include ../sticker/components/sticker |
|||
|
|||
mixin renderStickerList (stickers) |
|||
div(uk-grid).uk-grid-small |
|||
each sticker in stickers |
|||
div(class="uk-width-1-1 uk-width-auto@s") |
|||
a(href=`/sticker/${sticker._id}`).uk-display-block.uk-text-center |
|||
+renderSticker(sticker) |
|||
|
|||
mixin renderStickerUploadForm (actionUrl, channel) |
|||
form(method="POST", action= actionUrl, enctype="multipart/form-data").uk-form |
|||
if channel |
|||
input(id="channel-id", type="hidden", name="channel", value= channel._id) |
|||
|
|||
.uk-margin |
|||
input(id="sticker-slug", name="slug", type="text", placeholder= "Enter sticker name").uk-input |
|||
|
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
.uk-form-custom |
|||
input(id="sticker-file", name="stickerFile", type="file") |
|||
button(type="button").uk-button.dtp-button-default |
|||
span |
|||
i.far.fa-image |
|||
span.uk-margin-small-left Select File |
|||
|
|||
.uk-width-auto |
|||
button(type="submit").uk-button.dtp-button-primary |
|||
span |
|||
i.fas.fa-plus |
|||
span.uk-margin-small-left Add sticker |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
div(uk-grid) |
|||
div(class="uk-width-1-1 uk-width-2-3@l") |
|||
h2 #{user.displayName || user.username}'s stickers |
|||
|
|||
.uk-margin |
|||
+renderStickerUploadForm('/sticker') |
|||
|
|||
.uk-margin |
|||
if Array.isArray(userStickers) && (userStickers.length > 0) |
|||
+renderStickerList(userStickers) |
|||
else |
|||
div #{user.displayName || user.username} has no stickers. |
|||
|
|||
if channel |
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
h2 #{channel.name}'s stickers |
|||
|
|||
.uk-margin |
|||
+renderStickerUploadForm('/sticker', channel) |
|||
|
|||
.uk-margin |
|||
if Array.isArray(channelStickers) && (channelStickers.length > 0) |
|||
+renderStickerList(channelStickers) |
|||
else |
|||
div #{channel.name} has no stickers. |
|||
|
|||
div(class="uk-width-1-1 uk-width-1-3@l") |
|||
h1 Stickers |
|||
p Stickers accepts PNG, JPEG, GIF and MP4 files. Transparency/alpha is supported in the PNG format. Animations will be transcoded to MP4. |
|||
p Stickers can be up to 2MB, and animated stickers are limited to 10 seconds in duration regardless of format. Resolutions from 100x100 to 320x100 are accepted. Larger Stickers will be scaled to cover an aspect-correct reduction, if possible. Some cropping may occur. |
|||
p Stickers may not contain pornography. Animated stickers with audio will have the audio data removed as part of their conversion, all Stickers are silent loops. |
@ -0,0 +1,2 @@ |
|||
h1 This is the sticker menu |
|||
//- pre= JSON.stringify(user, null, 2) |
@ -0,0 +1,26 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
include ../sticker/components/sticker |
|||
|
|||
section.uk-section.uk-section-default.uk-section-small |
|||
.uk-container |
|||
.uk-card.uk-card-default |
|||
.uk-card-header.uk-text-center |
|||
h1.uk-card-title :#{sticker.slug}: |
|||
.uk-card-body |
|||
.uk-margin-small |
|||
+renderSticker(sticker) |
|||
.uk-text-small.uk-text-center |
|||
div #{sticker.encoded.type}, orig: #{numeral(sticker.original.size).format('0,0.0a')}, encoded: #{numeral(sticker.encoded.size).format('0,0.0a')} |
|||
.uk-card-footer |
|||
div(uk-grid).uk-flex-center |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-sticker-id= sticker._id, |
|||
data-sticker-slug= sticker.slug, |
|||
onclick="return dtp.app.deleteSticker(event);", |
|||
).uk-button.dtp-button-danger Remove Sticker |
|||
.uk-width-auto |
|||
include ../components/back-button |
@ -0,0 +1,8 @@ |
|||
mixin renderUserAttributionHeader (user) |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfileIcon(user) |
|||
.uk-width-expand |
|||
.uk-text-bold(style="line-height: 1;")= user.displayName || user.username |
|||
.uk-text-small.uk-text-muted |
|||
a(href= getUserProfileUrl(user))= user.username |
@ -1,17 +1,35 @@ |
|||
mixin renderProfileIcon (user, title, size) |
|||
- |
|||
var sizeMap = { |
|||
"xxsmall": "small", |
|||
"xsmall": "small", |
|||
"list-item": "small", |
|||
"navbar": "small", |
|||
"small": "small", |
|||
"medium": "large", |
|||
"large": "large", |
|||
"full": "full", |
|||
}; |
|||
|
|||
mixin renderProfileIcon (user, title, size = "small") |
|||
if user.coreUserId |
|||
img( |
|||
src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${size || 'small'}`, |
|||
src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${sizeMap[size]}`, |
|||
class= "site-profile-picture", |
|||
class= `sb-${size}`, |
|||
title= title, |
|||
).site-profile-picture.sb-navbar |
|||
) |
|||
else |
|||
if user.picture && user.picture.small |
|||
img( |
|||
src= `/image/${user.picture.small._id}`, |
|||
src= `/image/${user.picture[sizeMap[size]]._id}`, |
|||
class= "site-profile-picture", |
|||
class= `sb-${size}`, |
|||
title= title, |
|||
).site-profile-picture.sb-navbar |
|||
) |
|||
else |
|||
img( |
|||
src= "/img/default-member.png", |
|||
class= "site-profile-picture", |
|||
class= `sb-${size}`, |
|||
title= title, |
|||
).site-profile-picture.sb-navbar |
|||
) |
|||
|
@ -0,0 +1,66 @@ |
|||
// chat.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const { SiteLog, SiteWorker, SiteAsync } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); |
|||
|
|||
module.rootPath = path.resolve(__dirname, '..', '..'); |
|||
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); |
|||
|
|||
module.config = { |
|||
environment: process.env.NODE_ENV, |
|||
root: module.rootPath, |
|||
component: { name: 'chatWorker', slug: 'chat-worker' }, |
|||
}; |
|||
|
|||
module.config.site = require(path.join(module.rootPath, 'config', 'site')); |
|||
|
|||
class ChatWorker extends SiteWorker { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, dtp.config.component); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-clear.js')); |
|||
await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-delete.js')); |
|||
|
|||
await this.startProcessors(); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async deleteChatMessage (message) { |
|||
const { attachment: attachmentService } = this.dtp.services; |
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
|
|||
await SiteAsync.each(message.attachments, attachmentService.remove.bind(attachmentService), 2); |
|||
await ChatMessage.deleteOne({ _id: message._id }); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
try { |
|||
module.log = new SiteLog(module, module.config.component); |
|||
|
|||
module.worker = new ChatWorker(module); |
|||
await module.worker.start(); |
|||
|
|||
module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); |
|||
} catch (error) { |
|||
module.log.error('failed to start worker', { component: module.config.component, error }); |
|||
process.exit(-1); |
|||
} |
|||
|
|||
})(); |
@ -0,0 +1,51 @@ |
|||
// chat/job/chat-room-clear.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
class ChatRoomClearJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'charRoomClearJob', |
|||
slug: 'chat-room-clear-job', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, ChatRoomClearJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.queue = await this.getJobQueue('chat'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' }); |
|||
this.queue.process('chat-room-clear', this.processChatRoomClear.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processChatRoomClear (job) { |
|||
const { roomId } = job.data; |
|||
this.log.info('received chat room clear job', { id: job.id, roomId }); |
|||
await ChatMessage |
|||
.find({ room: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.worker.deleteChatMessage.bind(this), 4); |
|||
} |
|||
} |
|||
|
|||
module.exports = ChatRoomClearJob; |
@ -0,0 +1,102 @@ |
|||
// chat/job/chat-room-delete.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const ChatRoom = mongoose.model('ChatRoom'); |
|||
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
const EmojiReaction = mongoose.model('EmojiReaction'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
/** |
|||
* DTP Core Chat sticker processor can receive requests to ingest and delete |
|||
* stickers to be executed as background jobs in a queue. This processor |
|||
* attaches to the `media` queue and registers processors for `sticker-ingest` |
|||
* and `sticker-delete`. |
|||
*/ |
|||
class ChatRoomDeleteJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'chatRoomProcessor', |
|||
slug: 'chat-room-processor', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, ChatRoomDeleteJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.queue = await this.getJobQueue('chat'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' }); |
|||
this.queue.process('chat-room-delete', this.processChatRoomDelete.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processChatRoomDelete (job) { |
|||
const { roomId } = job.data; |
|||
this.log.info('received chat room delete job', { id: job.id, roomId }); |
|||
|
|||
await EmojiReaction |
|||
.find({ subject: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.deleteEmojiReaction.bind(this)); |
|||
|
|||
await ChatMessage |
|||
.find({ room: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.worker.deleteChatMessage.bind(this), 4); |
|||
|
|||
await ChatRoomInvite |
|||
.find({ room: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.deleteChatRoomInvite.bind(this), 4); |
|||
|
|||
await ChatRoom.deleteOne({ _id: roomId }); |
|||
} |
|||
|
|||
async deleteEmojiReaction (reaction) { |
|||
if (!reaction || !reaction._id) { |
|||
this.log.error('skipping invalid emoji reaction for delete'); |
|||
return; |
|||
} |
|||
|
|||
const EmojiReaction = mongoose.model('EmojiReaction'); |
|||
try { |
|||
await EmojiReaction.deleteOne({ _id: reaction._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete chat message', { reactionId: reaction._id, error }); |
|||
} |
|||
} |
|||
|
|||
async deleteChatRoomInvite (invite) { |
|||
if (!invite || !invite._id) { |
|||
this.log.error('skipping invalid invite for delete'); |
|||
return; |
|||
} |
|||
|
|||
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
|||
try { |
|||
await ChatRoomInvite.deleteOne({ _id: invite._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete chat room invite', { inviteId: invite._id, error }); |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
module.exports = ChatRoomDeleteJob; |
@ -0,0 +1,85 @@ |
|||
// media.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); |
|||
|
|||
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); |
|||
module.config = { |
|||
environment: process.env.NODE_ENV, |
|||
root: path.resolve(__dirname, '..', '..'), |
|||
component: { name: 'mediaWorker', slug: 'media-worker' }, |
|||
}; |
|||
|
|||
/** |
|||
* Provides background media processing for the DTP ecosystem. |
|||
* |
|||
* Background media processing is simply a way of life for scalable Web |
|||
* architectures. You don't want to force your site member to sit there and |
|||
* watch a process run. You want to accept their file, toss it to storage, and |
|||
* create a job to have whatever work needs done performed. |
|||
* |
|||
* This obviously induces a variable amount of time from when the site member |
|||
* uploads the file until it's ready for online distribution. The system |
|||
* therefore facilitates ways to query the status of the job and to receive a |
|||
* notification when the work is complete. |
|||
* |
|||
* This worker serves as a starting point or demonstration of how to do |
|||
* background media processing at scale and in production. This is the exact |
|||
* code we use to run the Digital Telepresence Platform every day. |
|||
*/ |
|||
class MediaWorker extends SiteWorker { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, dtp.config.component); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
if (process.argv[2]) { |
|||
const stickerId = mongoose.Types.ObjectId(process.argv[2]); |
|||
this.log.info('creating sticker processing job', { stickerId }); |
|||
|
|||
const queue = this.getJobQueue('media'); |
|||
await queue.add('sticker-ingest', { stickerId }); |
|||
} |
|||
|
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-ingest.js')); |
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-delete.js')); |
|||
|
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-ingest.js')); |
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-delete.js')); |
|||
|
|||
await this.startProcessors(); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
try { |
|||
module.log = new SiteLog(module, module.config.component); |
|||
await SitePlatform.startPlatform(module, module.config.component); |
|||
|
|||
module.worker = new MediaWorker(module); |
|||
await module.worker.start(); |
|||
|
|||
module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); |
|||
} catch (error) { |
|||
module.log.error('failed to start worker', { |
|||
component: module.config.component, |
|||
error, |
|||
}); |
|||
process.exit(-1); |
|||
} |
|||
})(); |
@ -0,0 +1,72 @@ |
|||
// media/job/attachment-delete.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const Attachment = mongoose.model('Attachment'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
class AttachmentDeleteJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'attachmentDeleteJob', |
|||
slug: 'attachment-delete-job', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, AttachmentDeleteJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.queue = await this.getJobQueue('media'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-delete' }); |
|||
this.queue.process('attachment-delete', 1, this.processAttachmentDelete.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processAttachmentDelete (job) { |
|||
try { |
|||
const { attachment: attachmentService } = this.dtp.services; |
|||
const attachment = job.data.attachment = await attachmentService.getById( |
|||
job.data.attachmentId, |
|||
{ withOriginal: true }, |
|||
); |
|||
|
|||
await this.deleteAttachmentFile(attachment, 'processed'); |
|||
await this.deleteAttachmentFile(attachment, 'original'); |
|||
|
|||
this.log.info('deleting attachment', { _id: attachment._id }); |
|||
await Attachment.deleteOne({ _id: attachment._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete attachment', { attachmentId: job.data.attachmentId, error }); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async deleteAttachmentFile (attachment, which) { |
|||
this.log.info('removing attachment file', { _id: attachment._id, which }); |
|||
|
|||
const file = attachment[which]; |
|||
if (!file || !file.bucket || !file.key) { |
|||
return; |
|||
} |
|||
|
|||
const { minio: minioService } = this.dtp.services; |
|||
await minioService.removeObject(file.bucket, file.key); |
|||
} |
|||
} |
|||
|
|||
module.exports = AttachmentDeleteJob; |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue