42 changed files with 2834 additions and 175 deletions
@ -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); }, |
||||
|
}; |
@ -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); |
@ -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); |
@ -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,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); }, |
||||
|
}; |
@ -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,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 }) |
@ -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 |
@ -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 |
@ -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)}; |
@ -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) |
@ -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,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); |
||||
|
} |
||||
|
})(); |
@ -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; |
||||
|
} |
||||
|
} |
@ -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; |
@ -2197,9 +2197,9 @@ camelcase@^6.2.0: |
|||||
integrity sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA== |
integrity sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA== |
||||
|
|
||||
caniuse-lite@^1.0.30001280: |
caniuse-lite@^1.0.30001280: |
||||
version "1.0.30001284" |
version "1.0.30001373" |
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001284.tgz#d3653929ded898cd0c1f09a56fd8ca6952df4fca" |
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001373.tgz" |
||||
integrity sha512-t28SKa7g6kiIQi6NHeOcKrOrGMzCRrXvlasPwWC26TH2QNdglgzQIRUuJ0cR3NeQPH+5jpuveeeSFDLm2zbkEw== |
integrity sha512-pJYArGHrPp3TUqQzFYRmP/lwJlj8RCbVe3Gd3eJQkAV8SAC6b19XS9BjMvRdvaS8RMkaTN8ZhoHP6S1y8zzwEQ== |
||||
|
|
||||
"[email protected] - 4.1.2", chalk@^4.1.0, chalk@^4.1.1: |
"[email protected] - 4.1.2", chalk@^4.1.0, chalk@^4.1.1: |
||||
version "4.1.2" |
version "4.1.2" |
||||
@ -7875,6 +7875,11 @@ [email protected]: |
|||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" |
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" |
||||
integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= |
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: |
string-width@^1.0.1, string-width@^1.0.2: |
||||
version "1.0.2" |
version "1.0.2" |
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" |
resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" |
||||
|
Loading…
Reference in new issue