Browse Source

brought full-featured chat, stickers, and emoji reacts in from Shing/Soapbox

This is part of the creation of Venue and Radio.
develop^2
Rob Colbert 2 years ago
parent
commit
3283c24c32
  1. 12
      .env.default
  2. 2
      app/controllers/admin/job-queue.js
  3. 127
      app/controllers/chat.js
  4. 16
      app/models/chat-message.js
  5. 19
      app/models/chat-room-invite.js
  6. 44
      app/models/chat-room.js
  7. 39
      app/models/emoji-reaction.js
  8. 46
      app/models/sticker.js
  9. 2
      app/services/announcement.js
  10. 548
      app/services/chat.js
  11. 204
      app/services/sticker.js
  12. 151
      app/views/chat/components/input-form.pug
  13. 0
      app/views/chat/components/menubar.pug
  14. 33
      app/views/chat/components/message.pug
  15. 8
      app/views/chat/components/reaction-button.pug
  16. 54
      app/views/chat/index.pug
  17. 50
      app/views/chat/layouts/room.pug
  18. 57
      app/views/chat/view.pug
  19. 8
      app/views/components/off-canvas.pug
  20. 5
      app/views/layouts/main.pug
  21. 2
      app/views/sticker/components/sticker-standalone.pug
  22. 19
      app/views/sticker/components/sticker.pug
  23. 69
      app/views/sticker/index.pug
  24. 2
      app/views/sticker/menu.pug
  25. 26
      app/views/sticker/view.pug
  26. 2
      app/workers/host-services.js
  27. 2
      app/workers/newsletter.js
  28. 2
      app/workers/reeeper.js
  29. 2
      app/workers/sample-worker.js
  30. 356
      app/workers/stickers.js
  31. 126
      client/js/site-app.js
  32. 347
      client/js/site-chat.js
  33. 149
      client/js/site-reactions.js
  34. 14
      client/less/site/button.less
  35. 221
      client/less/site/chat.less
  36. 26
      config/limiter.js
  37. 1
      config/reserved-names.js
  38. 27
      lib/client/js/dtp-socket.js
  39. 178
      lib/site-ioserver.js
  40. 1
      lib/site-platform.js
  41. 1
      package.json
  42. 11
      yarn.lock

12
.env.default

@ -13,6 +13,16 @@ DTP_CORE_AUTH_SCHEME=http
DTP_CORE_AUTH_HOST=localhost:3000
DTP_CORE_AUTH_PASSWORD_LEN=64
DTP_IMAGE_WORK_PATH=/tmp/yourapp/image-work
DTP_VIDEO_WORK_PATH=/tmp/yourapp/video-work
DTP_STICKER_WORK_PATH=/tmp/yourapp/sticker-work
#
# Set this to "enabled" to use NVIDIA GPU acceleration. Setting this to enabled
# without a properly-configured NVIDIA GPU will cause processing jobs to fail.
#
DTP_GPU_ACCELERATION=disabled
#
# Host Cache configuration
#
@ -98,4 +108,4 @@ DTP_LOG_DEBUG=enabled
DTP_LOG_INFO=enabled
DTP_LOG_WARN=enabled
DTP_LOG_HTTP_FORMAT=combined
DTP_LOG_HTTP_FORMAT=combined

2
app/controllers/admin/job-queue.js

@ -1,6 +1,6 @@
// admin/job-queue.js
// Copyright (C) 2022 DTP Technologies, LLC
// All Rights Reserved
// License: Apache-2.0
'use strict';

127
app/controllers/chat.js

@ -0,0 +1,127 @@
// 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 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.post(
'/:roomId',
limiterService.create(limiterService.config.chat.postRoomUpdate),
this.postRoomUpdate.bind(this),
);
router.post(
'/',
limiterService.create(limiterService.config.chat.postRoomCreate),
this.postRoomCreate.bind(this),
);
router.get(
'/:roomId',
limiterService.create(limiterService.config.chat.getRoomView),
this.getRoomView.bind(this),
);
router.get(
'/',
limiterService.create(limiterService.config.chat.getHome),
this.getHome.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 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/${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/${res.locals.room._id}`);
} catch (error) {
this.log.error('failed to create chat room', { error });
return next(error);
}
}
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/view');
} catch (error) {
this.log.error('failed to render chat room view', { roomId: req.params.roomId, error });
return next(error);
}
}
async getHome (req, res) {
res.locals.pageTitle = 'Chat Home';
res.render('chat/index');
}
}
module.exports = {
slug: 'chat',
name: 'chat',
create: async (dtp) => { return new ChatController(dtp); },
};

16
app/models/chat-message.js

@ -8,10 +8,22 @@ const mongoose = require('mongoose');
const Schema = mongoose.Schema;
/*
* The intent is for forked apps to give meaning to "channel" in their
* apps. Set the channelType to the name of your channel model, and set
* channel to the _id of the channel. The model will then correctly populate.
*/
const ChatMessageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
content: { type: String },
channelType: { type: String },
channel: { type: Schema.ObjectId, refPath: 'channelType' },
authorType: { type: String, enum: ['User', 'CoreUser'], required: true },
author: { type: Schema.ObjectId, required: true, index: 1, refPath: 'authorType' },
content: { type: String, maxlength: 1000 },
analysis: {
similarity: { type: Number },
},
stickers: { type: [String] },
});

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

@ -0,0 +1,19 @@
// 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 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: ['new', 'accepted', 'rejected'], required: true },
});
module.exports = mongoose.model('ChatRoomInvite', ChatRoomInviteSchema);

44
app/models/chat-room.js

@ -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, refPath: '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);

39
app/models/emoji-reaction.js

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

46
app/models/sticker.js

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

2
app/services/announcement.js

@ -1,6 +1,6 @@
// announcement.js
// Copyright (C) 2022 DTP Technologies, LLC
// All Rights Reserved
// License: Apache-2.0
'use strict';

548
app/services/chat.js

@ -4,30 +4,68 @@
'use strict';
const Redis = require('ioredis');
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 ioEmitter = require('socket.io-emitter');
const marked = require('marked');
const hljs = require('highlight.js');
const striptags = require('striptags');
const unzalgo = require('unzalgo');
const stringSimilarity = require('string-similarity');
const { SiteService } = require('../../lib/site-lib');
const { SiteService, SiteError } = require('../../lib/site-lib');
class ChatService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateContentReport = [
const USER_SELECT = '_id username username_lc displayName picture';
this.populateChatMessage = [
{
path: 'channel',
},
{
path: 'user',
select: '_id username username_lc displayName picture',
path: 'author',
select: USER_SELECT,
},
{
path: 'resource',
path: 'stickers',
},
];
this.populateChatRoom = [
{
path: 'owner',
select: USER_SELECT,
},
{
path: 'members.member',
select: USER_SELECT,
},
];
this.populateChatRoomInvite = [
{
path: 'room',
populate: [
{
path: 'author',
select: '_id username username_lc displayName picture',
path: 'owner',
select: USER_SELECT,
},
{
path: 'members.member',
select: USER_SELECT,
},
],
},
@ -35,9 +73,403 @@ class ChatService extends SiteService {
}
async start ( ) {
this.markedRenderer = new marked.Renderer();
this.markedRenderer.link = (href, title, text) => { return text; };
this.markedRenderer.image = (href, title, text) => { return text; };
this.markedConfig = {
renderer: this.markedRenderer,
highlight: function(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class.
pedantic: false,
gfm: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false,
};
/*
* The chat message rate limiter uses Redis to provide accurate atomic
* accounting regardless of which host is currently hosting a user's chat
* connection and session.
*/
const { RateLimiterRedis } = require('rate-limiter-flexible');
const rateLimiterRedisClient = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
keyPrefix: process.env.REDIS_KEY_PREFIX || 'dtp',
lazyConnect: false,
enableOfflineQueue: false,
});
this.chatMessageLimiter = new RateLimiterRedis({
storeClient: rateLimiterRedisClient,
points: 20,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:chatmsg',
});
this.reactionLimiter = new RateLimiterRedis({
storeClient: rateLimiterRedisClient,
points: 60,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:react',
});
/*
* The Redis Emitter is a Socket.io-compatible message emitter that operates
* with greater efficiency than using Socket.io itself.
*/
this.emitter = ioEmitter(this.dtp.redis);
}
middleware (options) {
options = Object.assign({
maxOwnedRooms: 10,
maxJoinedRooms: 10,
});
return async (req, res, next) => {
try {
res.locals.ownedChatRooms = await this.getRoomsForOwner(req.user, {
skip: 0,
cpp: options.maxOwnedRooms,
});
res.locals.joinedChatRooms = await this.getRoomsForMember(req.user, {
skip: 0,
cpp: options.maxJoinedRooms,
});
return next();
} catch (error) {
this.log.error('failed to execute chat middleware', { error });
return next(error);
}
};
}
async createRoom (owner, roomDefinition) {
const NOW = new Date();
const room = new ChatRoom();
room.created = NOW;
room.lastActivity = NOW;
room.ownerType = owner.type;
room.owner = owner._id;
room.name = this.filterText(roomDefinition.name);
if (roomDefinition.description) {
room.description = this.filterText(roomDefinition.description);
}
if (roomDefinition.policy) {
room.policy = this.filterText(roomDefinition.policy);
}
room.visibility = roomDefinition.visibility;
room.membershipPolicy = roomDefinition.membershipPolicy;
room.members = [ ];
await room.save();
return room.toObject();
}
async updateRoom (room, roomDefinition) {
const NOW = new Date();
const updateOp = {
$set: {
lastActivity: NOW,
},
$unset: { },
};
updateOp.$set.name = this.filterText(roomDefinition.name);
if (roomDefinition.description && roomDefinition.description.length > 0) {
updateOp.$set.description = this.filterText(roomDefinition.description);
} else {
updateOp.$unset.description = 1;
}
await ChatRoom.updateOne({ _id: room._id }, updateOp);
}
async getRoomsForOwner (owner, pagination) {
pagination = Object.assign({
skip: 0,
cpp: 50
}, pagination);
const rooms = await ChatRoom
.find({ owner: owner._id })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatRoom)
.lean();
return rooms;
}
async getRoomsForMember (member, pagination) {
pagination = Object.assign({
skip: 0,
cpp: 50
}, pagination);
const rooms = await ChatRoom
.find({ 'members.member': member._id })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatRoom)
.lean();
return rooms;
}
async getPublicRooms (pagination) {
pagination = Object.assign({
skip: 0,
cpp: 50
}, pagination);
const rooms = await ChatRoom
.find({ 'flags.isPublic': true })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatRoom)
.lean();
return rooms;
}
async getRoomById (roomId) {
const room = await ChatRoom
.findById(roomId)
.populate(this.populateChatRoom)
.lean();
return room;
}
async joinRoom (room, member) {
if (!room.flags.isOpen) {
throw new SiteError(403, 'The room is not open');
}
await ChatRoom.updateOne(
{ _id: room._id },
{
$addToSet: {
members: {
memberType: member.type,
member: member._id,
},
},
},
);
}
async leaveRoom (room, memberId) {
await ChatRoom.updateOne(
{ _id: room._id },
{
$pull: { members: { _id: memberId } },
},
);
}
async sendRoomInvite (room, member) {
const NOW = new Date();
/*
* See if there's already an outstanding invite, and return it.
*/
let invite = await ChatRoomInvite
.findOne({ room: room._id, member: member._id })
.populate(this.populateChatRoomInvite)
.lean();
if (invite) {
return invite;
}
/*
* Create new invite
*/
invite = new ChatRoomInvite();
invite.created = NOW;
invite.room = room._id;
invite.memberType = member.type;
invite.member = member._id;
invite.status = 'new';
await invite.save();
this.log.info('chat room invite created', {
roomId: room._id,
memberId: member._id,
inviteId: invite._id,
});
return invite.toObject();
}
async acceptRoomInvite (invite) {
this.log.info('accepting invite to chat room', {
roomId: invite.room._id,
memberId: invite.member._id,
});
await ChatRoom.updateOne(
{ _id: invite.room._id },
{
$addToSet: {
members: {
memberType: invite.memberType,
member: invite.member._id,
},
},
},
);
this.log.info('updating chat invite', { inviteId: invite._id, status: 'accepted' });
await ChatRoomInvite.updateOne(
{ _id: invite._id },
{
$set: { stats: 'accepted' },
},
);
}
async rejectRoomInvite (invite) {
this.log.info('rejecting chat room invite', {
inviteId: invite._id,
roomId: invite.room._id,
memberId: invite.member._id,
});
await ChatRoomInvite.updateOne(
{ _id: invite._id },
{ $set: { status: 'rejected' } },
);
}
async deleteRoomInvite (invite) {
this.log.info('deleting chat room invite', { inviteId: invite._id });
await ChatRoomInvite.deleteOne({ _id: invite._id });
}
async createMessage (author, messageDefinition) {
const { sticker: stickerService, user: userService } = this.dtp.services;
author = await userService.getUserAccount(author._id);
if (!author || !author.permissions || !author.permissions.canChat) {
throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`);
}
const NOW = new Date();
/*
* Record the chat message to the database
*/
let message = new ChatMessage();
message.created = NOW;
message.channelType = messageDefinition.channelType;
message.channel = mongoose.Types.ObjectId(messageDefinition.channel._id || messageDefinition.channel);
message.authorType = author.type;
message.author = author._id;
message.content = this.filterText(messageDefinition.content);
message.analysis = await this.analyzeContent(author, message.content);
const stickerSlugs = this.findStickers(message.content);
stickerSlugs.forEach((sticker) => {
const re = new RegExp(`:${sticker}:`, 'gi');
message.content = message.content.replace(re, '').trim();
});
const stickers = await stickerService.resolveStickerSlugs(stickerSlugs);
message.stickers = stickers.map((sticker) => sticker._id);
await message.save();
message = message.toObject();
/*
* Update room's latest message pointer
*/
await ChatRoom.updateOne(
{ _id: message.channel },
{ $set: { latestMessage: message._id } },
);
/*
* Prepare a message payload that can be transmitted over sockets to clients
* and rendered for display.
*/
const renderedContent = this.renderMessageContent(message.content);
const payload = {
_id: message._id,
user: {
_id: author._id,
displayName: author.displayName,
username: author.username,
picture: {
large: {
_id: author.picture.large._id,
},
small: {
_id: author.picture.small._id,
},
},
},
content: renderedContent,
stickers,
};
/*
* Return both things
*/
return { message, payload };
}
renderMessageContent (content) {
return marked.parse(content, this.markedConfig);
}
findStickers (content) {
const tokens = content.split(' ');
const stickers = [ ];
tokens.forEach((token) => {
if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) {
return;
}
token = token.slice(1, token.length - 1 ).toLowerCase();
if (token.includes('/') || token.includes(':') || token.includes(' ')) {
return; // trimmed token includes invalid characters
}
this.log.debug('found sticker request', { token });
if (!stickers.includes(token)) {
stickers.push(striptags(token));
}
});
return stickers.slice(0, 4);
}
async removeMessage (message) {
await ChatMessage.deleteOne({ _id: message._id });
this.emitter(`site:${this.dtp.config.site.domainKey}:chat`, {
@ -46,6 +478,28 @@ class ChatService extends SiteService {
});
}
async getChannelHistory (channel, pagination) {
const messages = await ChatMessage
.find({ channel: channel._id })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatMessage)
.lean();
return messages.reverse();
}
async getUserHistory (user, pagination) {
const messages = await ChatMessage
.find({ author: user._id })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatMessage)
.lean();
return messages.reverse();
}
/**
* Filters an input string to remove "zalgo" text and to strip all HTML tags.
* This prevents cross-site scripting and the malicious destruction of text
@ -56,6 +510,86 @@ class ChatService extends SiteService {
filterText (content) {
return striptags(unzalgo.clean(content.trim()));
}
/**
* Analyze an input chat message against a user's history for similarity and
* other abusive content. Returns a response object with various scores
* allowing the caller to implement various policies and make various
* decisions.
* @param {User} author The author of the chat message
* @param {*} content The text of the chat message as would be distributed.
* @returns response object with various scores indicating the results of
* analyses performed.
*/
async analyzeContent (author, content) {
const response = { similarity: 0.0 };
/*
* Compare versus their recent chat messages, score for similarity, and
* block based on repetition. Spammers are redundant. This stops them.
*/
const history = await ChatMessage
.find({ author: author._id })
.sort({ created: -1 })
.select('content')
.limit(10)
.lean();
history.forEach((message) => {
const similarity = stringSimilarity.compareTwoStrings(content, message.content);
if (similarity > 0.9) { // 90% or greater match with history entry
response.similarity += similarity;
}
});
return response;
}
async sendMessage (channel, messageName, payload) {
this.emitter.to(channel).emit(messageName, payload);
}
async sendSystemMessage (socket, content, options) {
const NOW = new Date();
options = Object.assign({
type: 'info',
}, options || { });
const payload = {
created: NOW,
type: options.type,
content,
};
if (options.channelId) {
socket.to(options.channelId).emit('system-message', payload);
return;
}
socket.emit('system-message', payload);
}
async createEmojiReaction (user, reactionDefinition) {
const NOW = new Date();
const reaction = new EmojiReaction();
reaction.created = NOW;
reaction.subjectType = reactionDefinition.subjectType;
reaction.subject = mongoose.Types.ObjectId(reactionDefinition.subject);
reaction.userType = user.type;
reaction.user = user._id;
reaction.reaction = reactionDefinition.reaction;
if (reactionDefinition.timestamp) {
reaction.timestamp = reactionDefinition.timestamp;
}
await reaction.save();
return reaction.toObject();
}
}
module.exports = {

204
app/services/sticker.js

@ -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);
this.populateSticker = [
{
path: 'owner',
select: '-email -passwordSalt -password',
},
];
}
async start ( ) {
const { jobQueue: jobQueueService } = this.dtp.services;
this.jobQueue = jobQueueService.getJobQueue('sticker-ingest', {
attempts: 3,
});
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 'Channel':
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.jobQueue.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 setStickerStatus (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.jobQueue.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); },
};

151
app/views/chat/components/input-form.pug

@ -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)
.uk-card.uk-card-secondary.uk-card-body.uk-padding-small(style="border-top: solid 1px #3a3a3a;")
textarea(
id="chat-input-text",
name="content",
rows="2",
hidden,
).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",
uk-tooltip={ title: 'Add Emoji', delay: 500 },
onclick= "return dtp.app.showEmojiPicker(event);",
).uk-button.dtp-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'},
uk-tooltip={ title: 'Add Sticker', delay: 500 },
onclick="return dtp.app.chat.openChatInput();",
).uk-button.dtp-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.attachChatImage(event);",
//- ).uk-button.dtp-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", uk-tooltip={ title: "Chat Home", delay: 500 }).uk-button.dtp-button-secondary.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);",
uk-tooltip={ title: "Toggle Chat Input", delay: 500 },
).uk-button.dtp-button-secondary.uk-button-small
span
i.fas.fa-edit
.uk-width-auto
button(
id="chat-send-btn",
type="submit",
uk-tooltip={ title: "Send Message", delay: 500 },
).uk-button.dtp-button-primary.uk-button-small
span
i.far.fa-paper-plane
div(uk-grid).uk-flex-between.uk-grid-small
.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")
//- .chat-menubar

0
app/views/chat/components/menubar.pug

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

@ -0,0 +1,33 @@
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
div(uk-grid).uk-grid-small.uk-flex-bottom
.uk-width-expand
.uk-text-small.chat-username.uk-text-truncate= authorName
if message.author.picture && message.author.picture.small
.uk-width-auto
img(src=`/image/${message.author.picture.small._id}`, alt= `${authorName}'s profile picture`).chat-author-image
if !options.hideMenu && !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}
.chat-content.uk-text-break!= marked.parse(message.content)
.chat-timestamp(data-created= message.created).uk-text-small
if Array.isArray(message.stickers) && (message.stickers.length > 0)
each sticker in message.stickers
+renderSticker(sticker, { hideSlug: true })

8
app/views/chat/components/reaction-button.pug

@ -0,0 +1,8 @@
mixin renderReactionButton (title, emoji, reaction)
button(
uk-tooltip={ title, delay: 500 },
data-reaction= reaction,
onclick="return dtp.app.chat.sendReaction(event);",
).dtp-button-reaction
span.button-icon= emoji
span(class="uk-visible@l").count-label

54
app/views/chat/index.pug

@ -0,0 +1,54 @@
extends layouts/room
block content
form(method="POST", action="/chat").uk-form
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h1.uk-card-title Create Chat Room
.uk-card-body
.uk-margin
label(for="name").uk-form-label Room name
input(id="name", name="name", type="text", placeholder="Enter room name").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
.uk-margin
label(for="policy").uk-form-label Room policy
textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea
.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).uk-radio
| Public
.ui-width-auto
label
input(id="is-private", name="visibility", type="radio", value="private").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="policy", type="radio", value="open", checked).uk-radio
| Open
.uk-width-auto
label
input(id="membership-closed", name="policy", type="radio", value="closed").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 Create room

50
app/views/chat/layouts/room.pug

@ -0,0 +1,50 @@
extends ../../layouts/main
block content-container
mixin renderRoomList (rooms)
each room in ownedChatRooms
li.uk-active
a(href=`/chat/${room._id}`)= room.name
section.uk-section.uk-section-default.uk-section-small
.uk-container.uk-container-expand
div(uk-grid)
div(class="uk-width-1-1 uk-width-1-5@l uk-flex-first@l").uk-flex-last
.content-block.uk-border-rounded.uk-margin
if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0)
ul#room-list.uk-nav.uk-nav-default
li.uk-nav-header Your Rooms
+renderRoomList(ownedChatRooms)
else
div You don't own any chat rooms.
.content-block.uk-border-rounded
if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0)
ul#room-list.uk-nav.uk-nav-default
li.uk-nav-header Joined Rooms
+renderRoomList(ownedChatRooms)
else
div You haven't joined any chat rooms.
div(class="uk-width-1-1 uk-width-expand@l")
#chat-room
block content
div(class="uk-width-1-1 uk-width-1-5@l")
.content-block.uk-border-rounded
if chatRoom
if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0)
ul#room-member-list.uk-nav.uk-nav-default
li.uk-nav-header Room Members
each member in chatRoom.members
li
a(href="")= member.displayName || member.username
else
div The room has no members
else
div Not in a room
block viewjs
script.
window.dtp.room = !{JSON.stringify(room)};

57
app/views/chat/view.pug

@ -0,0 +1,57 @@
extends layouts/room
block content
include components/input-form
include components/message
.uk-card.uk-card-default.uk-card-small
.uk-card-header
div(uk-grid).uk-flex-middle.chat-menubar
div(uk-tooltip="Room details").uk-width-expand
h1.uk-card-title.uk-margin-remove= room.name
div= room.description
div(uk-tooltip="Active Members").uk-width-auto.no-select
span
i.fas.fa-user
span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0')
div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select
span
i.fas.fa-user
span.uk-margin-small-left= formatCount(room.members.length)
.uk-width-auto
button(
type="button",
data-room-id= room._id,
onclick="return dtp.app.chat.leaveRoom(event);",
).uk-button.uk-button-small.uk-border-pill.uk-text-bold
span
i.fas.fa-user
span.uk-margin-small-left Leave Room
.uk-width-auto
.uk-inline
button(type="button").uk-button.uk-button-link.uk-button-small
i.fas.fa-ellipsis-h
div(uk-dropdown={ pos: 'bottom-right', mode: 'click' })
ul.uk-nav.uk-dropdown-nav
li.uk-nav-heading= room.name
li.uk-nav-divider
li
a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat
.uk-card-body
#site-chat-container.uk-flex.uk-flex-column
#chat-message-list-wrapper
#chat-reactions
#chat-message-list
each message in chatMessages || [ ]
+renderChatMessage(message)
.chat-message-menu
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling
.uk-card-footer
+renderChatInputForm(room)

8
app/views/components/off-canvas.pug

@ -23,6 +23,14 @@ mixin renderMenuItem (iconClass, label)
if user
li.uk-nav-header Member Menu
li(class={ "uk-active": (currentView === 'chat') })
a(href=`/chat`).uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-comment-alt
.uk-width-expand Chat
li(class={ "uk-active": (currentView === 'user-settings') })
a(href=`/user/${user._id}`).uk-display-block
div(uk-grid).uk-grid-collapse

5
app/views/layouts/main.pug

@ -95,8 +95,9 @@ html(lang='en')
if user
script.
window.dtp.user = !{JSON.stringify(safeUser, null, 2)}
window.dtp.domain = !{JSON.stringify(site.domain)}
window.dtp.user = !{JSON.stringify(safeUser, null, 2)};
window.dtp.domain = !{JSON.stringify(site.domain)};
window.dtp.env = !{JSON.stringify(env.NODE_ENV)};
if channel
script.

2
app/views/sticker/components/sticker-standalone.pug

@ -0,0 +1,2 @@
include sticker
+renderSticker(sticker, stickerOptions || { })

19
app/views/sticker/components/sticker.pug

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

69
app/views/sticker/index.pug

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

2
app/views/sticker/menu.pug

@ -0,0 +1,2 @@
h1 This is the sticker menu
//- pre= JSON.stringify(user, null, 2)

26
app/views/sticker/view.pug

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

2
app/workers/host-services.js

@ -32,7 +32,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: module.rootPath,
component: { name: 'DTP Host Services', slug: 'host-services' },
component: { name: 'hostServicesWorker', slug: 'host-services-worker' },
site: require(path.join(module.rootPath, 'config', 'site')),
http: require(path.join(module.rootPath, 'config', 'http')),
};

2
app/workers/newsletter.js

@ -13,7 +13,7 @@ const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib',
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
component: { name: 'Newsletter Worker', slug: 'newsletter' },
component: { name: 'newsletterWorker', slug: 'newsletter-worker' },
root: path.resolve(__dirname, '..', '..'),
};

2
app/workers/reeeper.js

@ -23,7 +23,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: module.rootPath,
component: { name: 'DTP Reeeper', slug: 'reeeper' },
component: { name: 'reeeper', slug: 'reeeper' },
};
module.config.site = require(path.join(module.rootPath, 'config', 'site'));

2
app/workers/sample-worker.js

@ -20,7 +20,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: module.rootPath,
component: { name: 'Sample Worker', slug: 'sample-worker' },
component: { name: 'sampleWorker', slug: 'sample-worker' },
};
module.config.site = require(path.join(module.rootPath, 'config', 'site'));

356
app/workers/stickers.js

@ -0,0 +1,356 @@
// stickers.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_STICKER_HEIGHT = 100;
const path = require('path');
const fs = require('fs');
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
const mongoose = require('mongoose');
const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
const sharp = require('sharp');
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: path.resolve(__dirname, '..', '..'),
component: { name: 'stickersWorker', slug: 'stickers-worker' },
};
class StickerWorker extends SiteWorker {
constructor (dtp) {
super(dtp, dtp.config.component);
this.processors = {
processStickerSharp: this.processStickerSharp.bind(this),
processStickerFFMPEG: this.processStickerFFMPEG.bind(this),
};
}
async start ( ) {
await super.start();
const { jobQueue: jobQueueService } = this.dtp.services;
this.log.info('registering sticker-ingest job processor', {
config: this.dtp.config.jobQueues['sticker-ingest'],
});
this.stickerProcessingQueue = jobQueueService.getJobQueue(
'sticker-ingest',
this.dtp.config.jobQueues['sticker-ingest'],
);
this.stickerProcessingQueue.process('sticker-ingest', 1, this.processStickerIngest.bind(this));
this.stickerProcessingQueue.process('sticker-delete', 1, this.processStickerDelete.bind(this));
}
async stop ( ) {
if (this.stickerProcessingQueue) {
try {
this.log.info('closing sticker-ingest job queue');
await this.stickerProcessingQueue.close();
delete this.stickerProcessingQueue;
} catch (error) {
this.log.error('failed to close sticker ingest job queue', { error });
// fall through
}
}
await super.stop();
}
async processStickerIngest (job) {
try {
this.log.info('received sticker ingest job', { id: job.id, data: job.data });
await this.fetchSticker(job); // defines jobs.data.processor
await this.resetSticker(job);
// call the chosen file processor to render the sticker for distribution
await this.processors[job.data.processor](job);
//TODO: emit a completion event which should cause a refresh of the
// creator's view to display the processed sticker
} catch (error) {
this.log.error('failed to process sticker', { stickerId: job.data.stickerId, error });
throw error;
} finally {
if (job.data.workPath) {
this.log.info('cleaning up sticker work path', { workPath: job.data.workPath });
await fs.promises.rm(job.data.workPath, { recursive: true });
}
}
}
async fetchSticker (job) {
const { minio: minioService, sticker: stickerService } = this.dtp.services;
job.data.sticker = await stickerService.getById(job.data.stickerId, true);
job.data.workPath = path.join(
process.env.DTP_STICKER_WORK_PATH,
this.dtp.config.component.slug,
job.data.sticker._id.toString(),
);
this.jobLog(job, 'creating work directory', { worthPath: job.data.workPath });
await fs.promises.mkdir(job.data.workPath, { recursive: true });
switch (job.data.sticker.original.type) {
case 'image/jpeg':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.jpg`);
job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.jpg`);
job.data.processor = 'processStickerSharp';
job.data.sharpFormat = 'jpeg';
job.data.sharpFormatParameters = { quality: 85 };
break;
case 'image/png':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.png`);
job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`);
job.data.processor = 'processStickerSharp';
job.data.sharpFormat = 'png';
job.data.sharpFormatParameters = { compression: 9 };
break;
case 'image/gif':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.gif`);
job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
job.data.processor = 'processStickerFFMPEG';
break;
case 'image/webp': // process as PNG
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webp`);
job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`);
job.data.processor = 'processStickerSharp';
job.data.sharpFormat = 'png';
job.data.sharpFormatParameters = { compression: 9 };
break;
case 'image/webm': // process as MP4
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webm`);
job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
job.data.processor = 'processStickerFFMPEG';
break;
case 'video/mp4':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.mp4`);
job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
job.data.processor = 'processStickerFFMPEG';
break;
default:
throw new Error(`unsupported sticker type: ${job.data.sticker.original.type}`);
}
this.jobLog(job, 'fetching original media', { // blah 62096adcb69874552a0f87bc
stickerId: job.data.sticker._id,
slug: job.data.sticker.slug,
type: job.data.sticker.original.type,
worthPath: job.data.origFilePath,
});
await minioService.downloadFile({
bucket: job.data.sticker.original.bucket,
key: job.data.sticker.original.key,
filePath: job.data.origFilePath,
});
}
async resetSticker (job) {
const { minio: minioService } = this.dtp.services;
const { sticker } = job.data;
if (!sticker.encoded) {
return;
}
this.log.info('removing existing encoded sticker media', { media: sticker.encoded });
await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key);
// switch sticker back to 'processing' status to prevent use in the app
const Sticker = mongoose.model('Sticker');
await Sticker.updateOne(
{ _id: job.data.sticker._id },
{
$set: {
status: 'processing',
},
$unset: {
encoded: '',
},
},
);
delete sticker.encoded;
}
async processStickerSharp (job) {
const { minio: minioService } = this.dtp.services;
const sharpImage = sharp(job.data.origFilePath);
const metadata = await sharpImage.metadata();
this.log.info('sticker metadata from Sharp', { stickerId: job.data.sticker._id, metadata });
let chain = sharpImage
.clone()
.toColorspace('srgb')
.resize({ height: DTP_STICKER_HEIGHT });
chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters);
await chain.toFile(job.data.outFilePath);
job.data.outFileStat = await fs.promises.stat(job.data.outFilePath);
const bucket = process.env.MINIO_VIDEO_BUCKET;
const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.${job.data.sharpFormat}`;
await minioService.uploadFile({
bucket,
key,
filePath: job.data.outFilePath,
metadata: {
'Content-Type': `image/${job.data.sharpFormat}`,
'Content-Length': job.data.outFileStat.size,
},
});
const Sticker = mongoose.model('Sticker');
await Sticker.updateOne(
{ _id: job.data.sticker._id },
{
$set: {
status: 'live',
encoded: {
bucket,
key,
type: `image/${job.data.sharpFormat}`,
size: job.data.outFileStat.size,
}
},
},
);
}
async processStickerFFMPEG (job) {
const { media: mediaService, minio: minioService } = this.dtp.services;
const codecVideo = (process.env.DTP_GPU_ACCELERATION === 'enabled') ? 'h264_nvenc' : 'libx264';
// generate the encoded sticker
// Output height is 100 lines by [aspect] width with width and height being
// padded to be divisible by 2. The video stream is given a bit rate of
// 128Kbps, and the media is flagged for +faststart. Audio is stripped if
// present.
const ffmpegStickerArgs = [
'-y', '-i', job.data.origFilePath,
'-vf', `scale=-1:${DTP_STICKER_HEIGHT},pad=ceil(iw/2)*2:ceil(ih/2)*2`,
'-pix_fmt', 'yuv420p',
'-c:v', codecVideo,
'-b:v', '128k',
'-movflags', '+faststart',
'-an',
job.data.outFilePath,
];
this.jobLog(job, `transcoding motion sticker: ${job.data.sticker.slug}`);
this.log.debug('transcoding motion sticker', { ffmpegStickerArgs });
await mediaService.ffmpeg(ffmpegStickerArgs);
job.data.outFileStat = await fs.promises.stat(job.data.outFilePath);
const bucket = process.env.MINIO_VIDEO_BUCKET;
const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.mp4`;
this.jobLog(job, 'uploading encoded media file');
await minioService.uploadFile({
bucket, key,
filePath: job.data.outFilePath,
metadata: {
'Content-Type': 'video/mp4',
'Content-Length': job.data.outFileStat.size,
},
});
this.jobLog(job, 'updating Sticker to live status');
const Sticker = mongoose.model('Sticker');
await Sticker.updateOne(
{ _id: job.data.sticker._id },
{
$set: {
status: 'live',
encoded: {
bucket,
key,
type: 'video/mp4',
size: job.data.outFileStat.size,
},
},
},
);
}
async processStickerDelete (job) {
const { minio: minioService, sticker: stickerService } = this.dtp.services;
const Sticker = mongoose.model('Sticker');
try {
const sticker = await stickerService.getById(job.data.stickerId, true);
this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug });
await minioService.removeObject(sticker.original.bucket, sticker.original.key);
if (sticker.encoded) {
this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug });
await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key);
}
this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug });
await Sticker.deleteOne({ _id: sticker._id });
} catch (error) {
this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error });
throw error; // for job report
}
}
async jobLog (job, message, data = { }) {
job.log(message);
this.log.info(message, { jobId: job.id, ...data });
}
}
(async ( ) => {
try {
module.log = new SiteLog(module, module.config.componentName);
/*
* Platform startup
*/
await SitePlatform.startPlatform(module, module.config.component);
module.worker = new StickerWorker(module);
await module.worker.start();
/*
* Worker startup
*/
if (process.argv[2]) {
const stickerId = mongoose.Types.ObjectId(process.argv[2]);
this.log.info('creating sticker processing job', { stickerId });
await module.worker.stickerProcessingQueue.add('sticker-ingest', { stickerId });
}
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);
}
})();

126
client/js/site-app.js

@ -14,6 +14,7 @@ import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import { EmojiButton } from '@joeattardi/emoji-button';
import SiteChat from './site-chat';
const GRID_COLOR = 'rgb(64, 64, 64)';
const GRID_TICK_COLOR = 'rgb(192,192,192)';
@ -27,26 +28,24 @@ export default class DtpSiteApp extends DtpApp {
constructor (user) {
super(DTP_COMPONENT, user);
this.log.debug('constructor', 'app instance created');
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'),
isAtBottom: true,
};
if (dtp.env === 'production') {
const isSafari = !!navigator.userAgent.match(/Version\/[\d\.]+.*Safari/);
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
this.isIOS = isSafari || isIOS;
} else {
this.isIOS = false;
}
this.log.debug('constructor', 'app instance created', {
env: dtp.env,
isIOS: this.isIOS,
});
this.emojiPicker = new EmojiButton({ theme: 'dark' });
this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this));
if (this.chat.messageList) {
this.chat.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this));
}
if (this.chat.input) {
this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this));
}
this.chat = new SiteChat(this);
this.charts = {/* will hold rendered charts */};
@ -71,99 +70,16 @@ export default class DtpSiteApp extends DtpApp {
if (this.user) {
const { socket } = this.socket;
socket.on('user-chat', this.onUserChat.bind(this));
socket.on('user-react', this.onUserReact.bind(this));
}
}
async onChatInputKeyDown (event) {
this.log.info('onChatInputKeyDown', 'chat input received', { event });
if (event.key === 'Enter' && !event.shiftKey) {
return this.sendUserChat(event);
}
}
async sendUserChat (event) {
event.preventDefault();
if (!dtp.channel || !dtp.channel._id) {
UIkit.modal.alert('There is a problem with Chat. Please refresh the page.');
return;
}
const channelId = dtp.channel._id;
this.log.info('chat form', channelId);
const content = this.chat.input.value;
this.chat.input.value = '';
if (content.length === 0) {
return true;
}
this.log.info('sendUserChat', 'sending chat message', { channel: this.user._id, content });
this.socket.sendUserChat(channelId, content);
// set focus back to chat input
this.chat.input.focus();
return true;
}
async onUserChat (message) {
this.log.info('onUserChat', 'message received', { user: message.user, content: message.content });
const chatMessage = document.createElement('div');
chatMessage.classList.add('uk-margin-small');
chatMessage.classList.add('chat-message');
const chatUser = document.createElement('div');
chatUser.classList.add('uk-text-small');
chatUser.classList.add('chat-username');
chatUser.textContent = message.user.username;
chatMessage.appendChild(chatUser);
const chatContent = document.createElement('div');
chatContent.classList.add('chat-content');
chatContent.innerHTML = message.content;
chatMessage.appendChild(chatContent);
if (Array.isArray(message.stickers) && message.stickers.length) {
message.stickers.forEach((sticker) => {
const chatContent = document.createElement('div');
chatContent.classList.add('chat-sticker');
chatContent.innerHTML = `<video playsinline autoplay muted loop><source src="/sticker/${sticker}.mp4"></source></video>`;
chatMessage.appendChild(chatContent);
});
}
this.chat.messageList.appendChild(chatMessage);
this.chat.messages.push(chatMessage);
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);
}
await this.chat.appendUserChat(message);
}
async onChatMessageListScroll (/* event */) {
const prevBottom = this.chat.isAtBottom;
const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight;
this.chat.isAtBottom = scrollPos >= this.chat.messageList.scrollHeight;
if (this.chat.isAtBottom !== prevBottom) {
this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.chat.isAtBottom });
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.scrollTop = this.chat.messageList.scrollHeight;
async onUserReact (message) {
await this.chat.createEmojiReact(message);
}
async goBack ( ) {
@ -547,7 +463,9 @@ export default class DtpSiteApp extends DtpApp {
async showEmojiPicker (event) {
const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element');
this.emojiTargetElement = document.getElementById(targetElementName);
if (!this.emojiTargetElement) {
return UIkit.modal.alert('Emoji picker target element does not exist');
}
this.emojiPicker.togglePicker(this.emojiTargetElement);
}

347
client/js/site-chat.js

@ -0,0 +1,347 @@
// site-chat.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT = { name: 'Site Chat', slug: 'site-chat' };
const dtp = window.dtp = window.dtp || { }; // jshint ignore:line
const EMOJI_EXPLOSION_DURATION = 8000;
const EMOJI_EXPLOSION_INTERVAL = 100;
import DtpLog from 'dtp/dtp-log.js';
import SiteReactions from './site-reactions.js';
export default class SiteChat {
constructor (app) {
this.app = app;
this.log = new DtpLog(DTP_COMPONENT);
this.ui = {
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'),
isAtBottom: true,
isModifying: false,
};
if (this.ui.messageList) {
this.ui.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this));
this.updateTimestamps();
setTimeout(( ) => {
this.log.info('constructor', 'scrolling chat', { top: this.ui.messageList.scrollHeight });
this.ui.messageList.scrollTo({ top: this.ui.messageList.scrollHeight, behavior: 'instant' });
}, 100);
this.ui.reactions = new SiteReactions();
this.lastReaction = new Date();
}
if (this.ui.input) {
this.ui.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this));
}
this.lastReaction = new Date();
if (window.localStorage) {
this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ];
this.filterChatView();
}
}
async filterChatView ( ) {
this.mutedUsers.forEach((block) => {
document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => {
message.parentElement.removeChild(message);
});
});
}
async toggleChatInput (event) {
event.preventDefault();
event.stopPropagation();
this.ui.input.toggleAttribute('hidden');
if (this.ui.input.getAttribute('hidden')) {
this.ui.input.focus();
}
return true;
}
async openChatInput ( ) {
if (this.ui.input.hasAttribute('hidden')) {
this.ui.input.removeAttribute('hidden');
}
return true;
}
async onChatInputKeyDown (event) {
if (event.key === 'Enter' && !event.shiftKey) {
return this.sendUserChat(event);
}
}
async onChatMessageListScroll (/* event */) {
const prevBottom = this.ui.isAtBottom;
const scrollPos = this.ui.messageList.scrollTop + this.ui.messageList.clientHeight;
this.ui.isAtBottom = scrollPos >= (this.ui.messageList.scrollHeight - 8);
if (this.ui.isAtBottom !== prevBottom) {
this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.ui.isAtBottom });
if (this.ui.isAtBottom) {
this.ui.messageMenu.classList.remove('chat-menu-visible');
} else {
this.ui.messageMenu.classList.add('chat-menu-visible');
}
}
}
async resumeChatScroll ( ) {
this.ui.messageList.scrollTop = this.ui.messageList.scrollHeight;
}
async sendUserChat (event) {
event.preventDefault();
if (!dtp.room || !dtp.room._id) {
UIkit.modal.alert('There is a problem with Chat. Please refresh the page.');
return;
}
const roomId = dtp.room._id;
const content = this.ui.input.value;
this.ui.input.value = '';
if (content.length === 0) {
return true;
}
this.log.info('sendUserChat', 'sending chat message', { roomId, content });
this.app.socket.emit('user-chat', {
channelType: 'ChatRoom',
channel: roomId,
content,
});
// set focus back to chat input
this.ui.input.focus();
return true;
}
async sendReaction (event) {
const NOW = new Date();
if (NOW - this.lastReaction < 1000) {
return;
}
this.lastReaction = NOW;
const target = event.currentTarget || event.target;
if (!target) {
return;
}
const reaction = target.getAttribute('data-reaction');
this.log.info('sendReaction', 'sending user reaction', { reaction });
this.app.socket.emit('user-react', {
subjectType: 'ChatRoom',
subject: dtp.room._id,
reaction,
});
}
async appendUserChat (message) {
const isAtBottom = this.ui.isAtBottom;
this.log.info('appendUserChat', 'message received', { user: message.user, content: message.content });
if (this.mutedUsers.find((block) => block.userId === message.user._id)) {
this.log.info('appendUserChat', 'message is from blocked user', {
_id: message.user._id,
username: message.user.username,
});
return; // sender is blocked by local user on this device
}
const chatMessage = document.createElement('div');
chatMessage.setAttribute('data-message-id', message._id);
chatMessage.setAttribute('data-author-id', message.user._id);
chatMessage.classList.add('uk-margin-small');
chatMessage.classList.add('chat-message');
const userGrid = document.createElement('div');
userGrid.setAttribute('uk-grid', '');
userGrid.classList.add('uk-grid-small');
userGrid.classList.add('uk-flex-middle');
chatMessage.appendChild(userGrid);
const usernameColumn = document.createElement('div');
usernameColumn.classList.add('uk-width-expand');
userGrid.appendChild(usernameColumn);
const chatUser = document.createElement('div');
const authorName = message.user.displayName || message.user.username;
chatUser.classList.add('uk-text-small');
chatUser.classList.add('chat-username');
chatUser.textContent = authorName;
usernameColumn.appendChild(chatUser);
if (message.user.picture && message.user.picture.small) {
const chatUserPictureColumn = document.createElement('div');
chatUserPictureColumn.classList.add('uk-width-auto');
userGrid.appendChild(chatUserPictureColumn);
const chatUserPicture = document.createElement('img');
chatUserPicture.classList.add('chat-author-image');
chatUserPicture.setAttribute('src', `/image/${message.user.picture.small._id}`);
chatUserPicture.setAttribute('alt', `${authorName}'s profile picture`);
chatUserPictureColumn.appendChild(chatUserPicture);
}
if (dtp.user && (dtp.user._id !== message.user._id)) {
const menuColumn = document.createElement('div');
menuColumn.classList.add('uk-width-auto');
menuColumn.classList.add('chat-user-menu');
userGrid.appendChild(menuColumn);
const menuButton = document.createElement('button');
menuButton.setAttribute('type', 'button');
menuButton.classList.add('uk-button');
menuButton.classList.add('uk-button-link');
menuButton.classList.add('uk-button-small');
menuColumn.appendChild(menuButton);
const menuIcon = document.createElement('i');
menuIcon.classList.add('fas');
menuIcon.classList.add('fa-ellipsis-h');
menuButton.appendChild(menuIcon);
const menuDropdown = document.createElement('div');
menuDropdown.setAttribute('data-message-id', message._id);
menuDropdown.setAttribute('uk-dropdown', 'mode: click');
menuColumn.appendChild(menuDropdown);
const dropdownList = document.createElement('ul');
dropdownList.classList.add('uk-nav');
dropdownList.classList.add('uk-dropdown-nav');
menuDropdown.appendChild(dropdownList);
let dropdownListItem = document.createElement('li');
dropdownList.appendChild(dropdownListItem);
let link = document.createElement('a');
link.setAttribute('href', '');
link.setAttribute('data-message-id', message._id);
link.setAttribute('data-user-id', message.user._id);
link.setAttribute('data-username', message.user.username);
link.setAttribute('onclick', "return dtp.app.muteChatUser(event);");
link.textContent = `Mute ${message.user.displayName || message.user.username}`;
dropdownListItem.appendChild(link);
}
const chatContent = document.createElement('div');
chatContent.classList.add('chat-content');
chatContent.classList.add('uk-text-break');
chatContent.innerHTML = message.content;
chatMessage.appendChild(chatContent);
const chatTimestamp = document.createElement('div');
chatTimestamp.classList.add('chat-timestamp');
chatTimestamp.classList.add('uk-text-small');
chatTimestamp.textContent = moment(message.created).format('hh:mm:ss a');
chatMessage.appendChild(chatTimestamp);
if (Array.isArray(message.stickers) && message.stickers.length) {
message.stickers.forEach((sticker) => {
const chatContent = document.createElement('div');
chatContent.classList.add('chat-sticker');
chatContent.setAttribute('title', `:${sticker.slug}:`);
chatContent.setAttribute('data-sticker-id', sticker._id);
switch (sticker.encoded.type) {
case 'video/mp4':
chatContent.innerHTML = `<video playsinline autoplay muted loop preload="auto"><source src="/sticker/${sticker._id}/media"></source></video>`;
break;
case 'image/png':
chatContent.innerHTML = `<img src="/sticker/${sticker._id}/media" height="100" />`;
break;
case 'image/jpeg':
chatContent.innerHTML = `<img src="/sticker/${sticker._id}/media" height="100" />`;
break;
}
chatMessage.appendChild(chatContent);
});
}
this.ui.isModifying = true;
this.ui.messageList.appendChild(chatMessage);
this.ui.messages.push(chatMessage);
while (this.ui.messages.length > 50) {
const message = this.ui.messages.shift();
this.ui.messageList.removeChild(message);
}
if (isAtBottom) {
/*
* This is jank. I don't know why I had to add this jank, but it is jank.
* The browser started emitting a scroll event *after* I issue this scroll
* command to return to the bottom of the view. So, I have to issue the
* scroll, let it fuck up, and issue the scroll again. I don't care why.
*/
this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight);
setTimeout(( ) => {
this.ui.isAtBottom = true;
this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight);
this.ui.isModifying = false;
}, 25);
}
}
updateTimestamps ( ) {
const timestamps = document.querySelectorAll('div.chat-timestamp[data-created]');
timestamps.forEach((timestamp) => {
const created = timestamp.getAttribute('data-created');
timestamp.textContent = moment(created).format('hh:mm:ss a');
});
}
createEmojiReact (message) {
this.ui.reactions.create(message.reaction);
}
triggerEmojiExplosion ( ) {
const reactions = ['happy', 'angry', 'honk', 'clap', 'fire', 'laugh'];
const stopHandler = this.stopEmojiExplosion.bind(this);
if (this.emojiExplosionTimeout && this.emojiExplosionInterval) {
clearTimeout(this.emojiExplosionTimeout);
this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION);
return;
}
// spawn 10 emoji reacts per second until told to stop
this.emojiExplosionInterval = setInterval(( ) => {
// choose a random reaction from the list of available reactions and
// spawn it.
const reaction = reactions[Math.floor(Math.random() * reactions.length)];
this.ui.reactions.create({ reaction });
}, EMOJI_EXPLOSION_INTERVAL);
// set a timeout to stop the explosion
this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION);
}
stopEmojiExplosion ( ) {
if (!this.emojiExplosionTimeout || !this.emojiExplosionInterval) {
return;
}
clearTimeout(this.emojiExplosionTimeout);
delete this.emojiExplosionTimeout;
clearInterval(this.emojiExplosionInterval);
delete this.emojiExplosionInterval;
}
}

149
client/js/site-reactions.js

@ -0,0 +1,149 @@
// site-reactions.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT = { name: 'Site Reactions', slug: 'site-reactions' };
const dtp = window.dtp = window.dtp || { }; // jshint ignore:line
import DtpLog from 'dtp/dtp-log';
class Reaction {
constructor (container, reaction) {
this.container = container;
this.reaction = reaction;
this.element = document.createElement('span');
this.element.classList.add('reaction-icon');
switch (this.reaction.reaction) {
case 'clap':
this.element.textContent = '👏';
break;
case 'fire':
this.element.textContent = '🔥';
break;
case 'happy':
this.element.textContent = '🤗';
break;
case 'laugh':
this.element.textContent = '🤣';
break;
case 'angry':
this.element.textContent = '🤬';
break;
case 'honk':
this.element.textContent = '🤡';
break;
}
this.position = {
x: Math.random() * (this.container.offsetWidth - 32.0),
y: this.container.clientHeight,
};
this.opacity = 1.0;
this.moveSpeed = 150.0 + (Math.random() * 50.0);
this.rotation = 0.0;
this.rotationDelta = 60.0 + (Math.random() * 15.0);
this.container.appendChild(this.element);
}
update (elapsed) {
const scale = elapsed / 1000.0;
this.position.y -= this.moveSpeed * scale;
this.rotation += this.rotationDelta * scale;
if (this.rotation > 30 || this.rotation < -30) {
this.rotationDelta = -this.rotationDelta;
}
const adjustedY = this.position.y + this.element.offsetHeight;
if (adjustedY > 100) {
return;
}
if (adjustedY === 0) {
this.opacity = 0.0;
return;
}
this.opacity = adjustedY / 100.0;
}
render ( ) {
this.element.style.left = `${this.position.x}px`;
this.element.style.top = `${this.position.y}px`;
if (this.opacity > 0.8) { this.opacity = 0.8; }
this.element.style.opacity = this.opacity;
const transform = `rotate(${this.rotation}deg)`;
this.element.style.transform = transform;
}
destroy ( ) {
this.container.removeChild(this.element);
}
}
export default class SiteReactions {
constructor ( ) {
this.log = new DtpLog(DTP_COMPONENT);
this.container = document.querySelector('#chat-reactions');
this.reactions = [ ];
this.updateHandler = this.onUpdate.bind(this);
}
create (reaction) {
const react = new Reaction(this.container, reaction);
this.reactions.push(react);
if (this.reactions.length === 1) {
this.lastUpdate = new Date();
requestAnimationFrame(this.updateHandler);
}
}
onUpdate ( ) {
const NOW = new Date();
const elapsed = NOW - this.lastUpdate;
const expired = [ ];
for (const reaction of this.reactions) {
reaction.update(elapsed);
if (reaction.position.y <= -(reaction.element.offsetHeight)) {
expired.push(reaction);
} else {
reaction.render();
}
}
expired.forEach((react) => {
const idx = this.reactions.indexOf(react);
if (idx === -1) {
return;
}
react.destroy();
this.reactions.splice(idx, 1);
});
if (this.reactions.length > 0) {
requestAnimationFrame(this.updateHandler);
}
this.lastUpdate = NOW;
}
}
dtp.SiteReactions = SiteReactions;

14
client/less/site/button.less

@ -150,3 +150,17 @@ button.uk-button.dtp-button-danger {
}
}
.dtp-button-reaction {
background: none;
border: none;
outline: none;
cursor: pointer;
span.button-icon {
font-size: 18px;
}
span.count-label {
color: #e8e8e8;
}
}

221
client/less/site/chat.less

@ -1,24 +1,235 @@
#site-chat-container {
align-self: stretch;
.chat-menubar {
padding: 4px 16px;
}
#chat-input-form {
textarea.uk-textarea {
padding: 2px 6px;
resize: none;
}
}
.fundraising-progress-overlay {
position: absolute;
top: 40px; right: 0; left: 0;
width: 100%;
overflow: hidden;
background: black;
&.hidden {
display: none;
}
}
#chat-message-list-wrapper {
position: relative;
flex: 1;
#chat-reactions {
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
background: transparent;
overflow: hidden;
span.reaction-icon {
display: block;
position: absolute;
font-size: 24px;
opacity: 0.6;
transform: rotate(0deg);
}
}
#chat-message-list {
position: relative;
display: flex;
flex-direction: column;
height: 410px;
resize: vertical;
overflow: auto;
box-shadow: var(--dtp-chat-shadow);
scroll-behavior: auto;
&::-webkit-scrollbar {
display: none; /* Chrome */
}
scrollbar-width: none; /* Firefox */
.chat-message {
.chat-user-menu {
button.chat-menu-button {
padding: 0;
margin: 0;
background: transparent;
outline: none;
border: none;
line-height: 1;
}
}
}
}
.chat-message-menu {
position: absolute;
display: none;
right: 4px; bottom: 20px; left: 4px;
text-align: center;
&.chat-menu-visible {
display: block;
}
button.chat-scroll-return {
padding: 4px 8px;
background: rgba(0,0,0, 0.6);
color: #c8c8c8;
border: solid 2px @site-brand-color;
border-radius: 8px;
outline: none;
cursor: pointer;
&:hover {
color: white;
}
&:active {
background: rgba(160, 0, 0, 0.9);
color: white;
}
}
}
}
}
/*
* OBS Chat Widget specializations
*/
body[data-obs-widget="chat"] {
.site-player-view {
#site-chat-container {
#chat-message-list-wrapper {
background-color: transparent;
#chat-reactions {
background-color: transparent;
}
}
}
}
}
/*
* Mobile view layout
*/
@media screen and (max-width: 959px) {
body[data-current-view="channel-broadcast"],
body[data-current-view="dvr-player"] {
position: fixed;
top: 0; right: 0; bottom: 0; left: 0;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
.site-player-view {
position: absolute;
top: 64px; right: 0; bottom: 0; left: 0;
overflow: hidden;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
#site-video-container {
flex: 0;
}
#site-chat-container {
position: relative;
flex: 1;
#chat-message-list {
flex: 1;
}
#chat-input-form {
flex: 0;
}
}
}
}
}
.chat-message {
color: var(--dtp-chat-color);
background: var(--dtp-chat-background);
border-radius: 8px;
font-size: var(--dtp-chat-font-size);
margin-bottom: 5px;
&.system-message {
background: rgba(255,255,255, 0.1);
}
.chat-username {
color: #c8c8c8;
margin-right: 4px;
font-weight: bold;
font-size: var(--dtp-chat-font-size);
line-height: 1;
color: var(--dtp-chat-username-color);
}
img.chat-author-image {
width: auto;
height: 2em;
border-radius: 4px;
}
.chat-content {
color: #a8a8a8;
em { color: inherit; }
strong { color: #c8c8c8; }
line-height: 1.2em;
font-size: var(--dtp-chat-font-size);
color: inherit;
overflow-wrap: break-word;
p:last-child {
margin-bottom: 0;
}
}
.chat-timestamp {
color: var(--dtp-chat-timestamp-color);
}
.chat-sticker {
display: inline-block;
margin-top: 4px;
margin-right: 8px;
color: inherit;
video {
width: auto;
height: 100px;
}
}
}
body[data-obs-widget="chat"] {
.chat-message {
.chat-user-menu {
display: none;
}
}
}

26
config/limiter.js

@ -51,6 +51,32 @@ module.exports = {
},
},
/*
* ChatController
*/
chat: {
postRoomUpdate: {
total: 10,
expire: ONE_MINUTE,
message: 'You are updating chat rooms too quickly',
},
postRoomCreate: {
total: 1,
expire: ONE_MINUTE * 5,
message: 'You are creating chat rooms too quickly',
},
getRoomView: {
total: 15,
expire: ONE_MINUTE,
message: 'You are loading chat rooms too quickly',
},
getHome: {
total: 30,
expire: ONE_MINUTE,
message: 'You are loading chat home too quickly',
},
},
comment: {
deleteComment: {
total: 1,

1
config/reserved-names.js

@ -10,6 +10,7 @@ module.exports = [
'about',
'admin',
'auth',
'chat',
'digitaltelepresence',
'dist',
'dtp',

27
lib/client/js/dtp-socket.js

@ -64,6 +64,10 @@ export default class DtpWebSocket {
async onSocketConnect ( ) {
this.log.info('onSocketConnect', 'WebSocket connected');
this.isConnected = true;
if (this.disconnectDialog) {
this.disconnectDialog.hide();
delete this.disconnectDialog;
}
}
async onSocketDisconnect (reason) {
@ -85,7 +89,13 @@ export default class DtpWebSocket {
};
this.log.warn('onSocketDisconnect', 'WebSocket disconnected', { reason });
UIkit.modal.alert(`Disconnected: ${REASONS[reason]}`);
const modal = UIkit.modal.alert(`Disconnected: ${REASONS[reason]}`);
this.disconnectDialog = modal.dialog;
UIkit.util.on(modal.dialog.$el, 'hidden', ( ) => {
this.log.info('onSocketDisconnect', 'disconnect dialog closed');
delete this.disconnectDialog;
});
this.retryConnect();
}
@ -115,25 +125,18 @@ export default class DtpWebSocket {
this.socket.emit('join', { channelId, channelType });
}
async isChannelJoined (channelId) {
return !!this.joinedChannels[channelId];
}
async onJoinResult (message) {
this.log.info('onJoinResult', 'channel joined', { message });
this.joinedChannels[message.channelId] = message;
document.dispatchEvent(new Event('socketChannelJoined', { channelId: message.channelId }));
}
async leaveChannel (channelId) {
this.log.info('leaveChannel', 'leaving channel', { channelId });
this.socket.emit('leave', { channelId });
if (this.joinedChannels[channelId]) {
delete this.joinedChannels[channelId];
}
}
async sendUserChat (channelId, content) {
this.log.info('sendUserChat', 'sending message to channel', { channelId, content });
this.socket.emit('user-chat', { channelId, content });
async emit (messageName, payload) {
this.log.info('emit', 'sending message', { messageName, payload });
this.socket.emit(messageName, payload);
}
}

178
lib/site-ioserver.js

@ -11,7 +11,6 @@ const Redis = require('ioredis');
const mongoose = require('mongoose');
const ConnectToken = mongoose.model('ConnectToken');
const ChatMessage = mongoose.model('ChatMessage');
const striptags = require('striptags');
const marked = require('marked');
@ -31,6 +30,8 @@ class SiteIoServer extends Events {
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
this.createRateLimiters();
this.markedRenderer = new marked.Renderer();
this.markedRenderer.link = (href, title, text) => { return text; };
this.markedRenderer.image = (href, title, text) => { return text; };
@ -53,8 +54,6 @@ class SiteIoServer extends Events {
xhtml: false,
};
const transports = ['websocket'/*, 'polling'*/];
const pubClient = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
@ -66,12 +65,44 @@ class SiteIoServer extends Events {
const subClient = pubClient.duplicate();
subClient.on('error', this.onRedisError.bind(this));
const transports = ['websocket'/*, 'polling'*/];
const adapter = createAdapter(pubClient, subClient);
this.io = new Server(httpServer, { adapter, transports });
this.io.on('connection', this.onSocketConnect.bind(this));
}
createRateLimiters ( ) {
const { RateLimiterRedis } = require('rate-limiter-flexible');
const rateLimiterRedisClient = new Redis({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
key: process.env.REDIS_KEY_PREFIX || 'dtp',
enableOfflineQueue: false,
lazyConnect: false,
});
this.chatMessageLimiter = new RateLimiterRedis({
storeClient: rateLimiterRedisClient,
points: 20,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:chatmsg',
});
this.reactionLimiter = new RateLimiterRedis({
storeClient: rateLimiterRedisClient,
points: 60,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:react',
});
}
async onRedisError (error) {
this.log.error('Redis error', { error });
}
@ -114,6 +145,7 @@ class SiteIoServer extends Events {
const session = {
user: {
_id: token.user._id,
type: token.userType,
created: token.user.created,
username: token.user.username,
displayName: token.user.displayName,
@ -125,11 +157,13 @@ class SiteIoServer extends Events {
session.onJoinChannel = this.onJoinChannel.bind(this, session);
session.onLeaveChannel = this.onLeaveChannel.bind(this, session);
session.onUserChat = this.onUserChat.bind(this, session);
session.onUserReact = this.onUserReact.bind(this, session);
socket.on('disconnect', session.onSocketDisconnect);
socket.on('join', session.onJoinChannel);
socket.on('leave', session.onLeaveChannel);
socket.on('user-chat', session.onUserChat);
socket.on('user-react', session.onUserReact);
socket.emit('authenticated', {
message: 'token verified',
@ -157,47 +191,92 @@ class SiteIoServer extends Events {
session.socket.leave(channelId);
}
async onUserChat (session, message) {
const { channel: channelService } = this.dtp.services;
const { channelId } = message;
async onUserChat (session, messageDefinition) {
const { chat: chatService, user: userService } = this.dtp.services;
const channelId = messageDefinition.channel;
if (!message.content || (message.content.length === 0)) {
if (!messageDefinition.content || (messageDefinition.content.length === 0)) {
this.log.info('dropping empty chat message');
return;
}
const channel = await channelService.getChannelById(channelId);
if (!channel) {
/*
* First, implement the rate limiter check. If rate-limited, abort all
* further processing. Store nothing in the database. Send nothing to the
* chat room.
*/
try {
const userKey = session.user._id.toString();
await this.chatMessageLimiter.consume(userKey, 1);
} catch (rateLimiter) {
const NOW = new Date();
if (!session.notifySpamMuzzle) {
this.log.alert('preventing chat spam', { userId: session.user._id, rateLimiter });
session.socket.to(channelId).emit('system-message', {
created: NOW,
content: `${session.user.displayName || session.user.username} has been muted for a while.`,
});
session.notifySpamMuzzle = true;
}
session.socket.emit('system-message', {
created: NOW,
content: `You are rate limited for ${numeral(rateLimiter.msBeforeNext / 1000.0).format('0,0.0')} seconds.`,
rateLimiter,
});
return;
}
const stickers = this.findStickers(message.content);
stickers.forEach((sticker) => {
const re = new RegExp(`:${sticker}:`, 'gi');
message.content = message.content.replace(re, '').trim();
});
message.content = striptags(message.content);
/*
* Pull the author's current User record from the db and verify that they
* have permission to chat. This read must happen with every chat message
* until permission update notifications are implemented on Redis pub/sub.
*/
try {
const userCheck = await userService.getUserAccount(session.user._id);
if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) {
session.socket.emit('system-message', {
created: new Date(),
content: `You are not allowed to chat on ${this.dtp.config.site.name}.`,
});
return; // permission denied
}
await ChatMessage.create({
created: new Date(),
author: session.user._id,
content: message.content,
stickers,
});
//TODO: Forked apps may want to implement channel-level moderation, and
// this is where to implement those checks.
const renderedContent = marked(message.content, this.markedConfig);
} catch (error) {
this.log.error('failed to implement user permissions check', { userId: session.user._id, error });
return; // can't verify permissions? No chat for you.
}
const payload = {
user: {
_id: session.user._id,
username: session.user.username,
},
content: renderedContent,
stickers,
};
try {
const { message, payload } = await chatService.createMessage(session.user, messageDefinition);
if (message.analysis.similarity > 0.9) {
await chatService.sendSystemMessage(
session.socket,
"Your flow feels a little spammy, so that one didn't go through.",
{ type: 'warning' },
);
return;
}
session.socket.to(channelId).emit('user-chat', payload);
session.socket.emit('user-chat', payload);
// use chat service emitter to deliver to channel (more efficient)
// than socket.io API
await chatService.sendMessage(message.channel, 'user-chat', payload);
// use the socket itself to emit back to the sender
session.socket.emit('user-chat', payload);
session.notifySpamMuzzle = false;
} catch (error) {
this.log.error('failed to process user chat message', { error });
await chatService.sendSystemMessage(
session.socket,
`Failed to send chat: ${error.message}`,
{ type: 'error' },
);
return;
}
}
findStickers (content) {
@ -220,6 +299,39 @@ class SiteIoServer extends Events {
});
return stickers;
}
async onUserReact (session, message) {
const { chat: chatService, user: userService } = this.dtp.services;
try {
const userCheck = await userService.getUserAccount(session.user._id);
if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) {
session.socket.emit('system-message', {
created: new Date(),
content: `You are not allowed to chat on ${this.dtp.config.site.name}.`,
});
return; // permission denied
}
try {
const userKey = session.user._id.toString();
await this.reactionLimiter.consume(userKey, 1);
} catch (error) {
return; // rate-limited
}
const reaction = await chatService.createEmojiReaction(session.user, message);
reaction.user = session.user;
const payload = { reaction };
const channelId = reaction.subject.toString();
await chatService.sendMessage(channelId, 'user-react', payload);
session.socket.emit('user-react', payload);
} catch (error) {
this.log.error('failed to process reaction', { message, error });
return;
}
}
}
module.exports.SiteIoServer = SiteIoServer;

1
lib/site-platform.js

@ -211,6 +211,7 @@ module.exports.startWebServer = async (dtp) => {
* Expose useful modules and information
*/
module.app.locals.DTP_SCRIPT_DEBUG = (process.env.NODE_ENV === 'local');
module.app.locals.env = process.env;
module.app.locals.dtp = dtp;
module.app.locals.pkg = require(path.join(dtp.config.root, 'package.json'));
module.app.locals.mongoose = require('mongoose');

1
package.json

@ -72,6 +72,7 @@
"slug": "^5.2.0",
"socket.io": "^4.4.1",
"socket.io-emitter": "^3.2.0",
"string-similarity": "^4.0.4",
"striptags": "^3.2.0",
"svg-captcha": "^1.4.0",
"systeminformation": "^5.11.6",

11
yarn.lock

@ -2197,9 +2197,9 @@ camelcase@^6.2.0:
integrity sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA==
caniuse-lite@^1.0.30001280:
version "1.0.30001284"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001284.tgz#d3653929ded898cd0c1f09a56fd8ca6952df4fca"
integrity sha512-t28SKa7g6kiIQi6NHeOcKrOrGMzCRrXvlasPwWC26TH2QNdglgzQIRUuJ0cR3NeQPH+5jpuveeeSFDLm2zbkEw==
version "1.0.30001373"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001373.tgz"
integrity sha512-pJYArGHrPp3TUqQzFYRmP/lwJlj8RCbVe3Gd3eJQkAV8SAC6b19XS9BjMvRdvaS8RMkaTN8ZhoHP6S1y8zzwEQ==
"[email protected] - 4.1.2", chalk@^4.1.0, chalk@^4.1.1:
version "4.1.2"
@ -7875,6 +7875,11 @@ [email protected]:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
string-similarity@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
string-width@^1.0.1, string-width@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"

Loading…
Cancel
Save