Browse Source

I did many things.

- nodemon and webpack now basically work like my Gulp setup used to in
DTP Base
- Connecting to rooms and sending chat messages
- Chat messages are rendered server-side and HTML is emitted to clients
- Chat room messages are created with POST requests at
/chat/room/:roomId/message
- ChatAudio system added
- SFX_CHAT_MESSAGE added and played when message is received
- more socket management to guarantee pulling out of room when leaving
- person-through-window added as "Leave room" icon to disconnect
develop
Rob Colbert 1 year ago
parent
commit
6cadd9c3a6
  1. 37
      app/controllers/chat.js
  2. 63
      app/services/chat.js
  3. 2
      app/views/chat/components/message-standalone.pug
  4. 12
      app/views/chat/components/message.pug
  5. 40
      app/views/chat/room/view.pug
  6. 6
      app/views/components/navbar.pug
  7. 17
      app/views/home.pug
  8. 42
      client/css/site/stage.less
  9. 151
      client/js/chat-audio.js
  10. 183
      client/js/chat-client.js
  11. 2
      client/js/index.js
  12. BIN
      client/static/sfx/chat-message.mp3
  13. 6
      dtp-chat.js
  14. 7
      nodemon.json
  15. 1
      webpack.config.js

37
app/controllers/chat.js

@ -25,6 +25,7 @@ export default class ChatController extends SiteController {
} = this.dtp.services;
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const multer = this.createMulter(ChatController.slug);
const router = express.Router();
this.dtp.app.use('/chat', router);
@ -39,6 +40,12 @@ export default class ChatController extends SiteController {
router.param('roomId', this.populateRoomId.bind(this));
router.post(
'/room/:roomId/message',
multer.none(),
this.postRoomMessage.bind(this),
);
router.post(
'/room',
// limiterService.create(limiterService.config.chat.postCreateRoom),
@ -78,6 +85,20 @@ export default class ChatController extends SiteController {
}
}
async postRoomMessage (req, res) {
const { chat: chatService } = this.dtp.services;
try {
await chatService.sendRoomMessage(res.locals.room, req.user, req.body);
return res.status(200).json({ success: true });
} catch (error) {
this.log.error('failed to send chat room message', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postCreateRoom (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
@ -103,8 +124,18 @@ export default class ChatController extends SiteController {
}
}
async getRoomView (req, res) {
res.locals.currentView = 'chat-room';
res.render('chat/room/view');
async getRoomView (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.currentView = 'chat-room';
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.messages = await chatService.getRoomMessages(res.locals.room, res.locals.pagination);
res.render('chat/room/view');
} catch (error) {
this.log.error('failed to present the chat room view', { error });
return next(error);
}
}
}

63
app/services/chat.js

@ -5,7 +5,6 @@
'use strict';
import mongoose from 'mongoose';
// const AuthToken = mongoose.model('AuthToken');
const ChatRoom = mongoose.model('ChatRoom');
const ChatMessage = mongoose.model('ChatMessage');
// const ChatRoomInvite = mongoose.model('ChatRoomInvite');
@ -28,6 +27,7 @@ export default class ChatService extends SiteService {
const { user: userService } = this.dtp.services;
this.templates = {
message: this.loadViewTemplate('chat/components/message-standalone.pug'),
memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'),
};
@ -37,6 +37,19 @@ export default class ChatService extends SiteService {
select: userService.USER_SELECT,
},
];
this.populateChatMessage = [
{
path: 'channel',
},
{
path: 'author',
select: userService.USER_SELECT,
},
{
path: 'mentions',
select: userService.USER_SELECT,
},
];
}
async createRoom (owner, roomDefinition) {
@ -130,11 +143,15 @@ export default class ChatService extends SiteService {
const systemMessage = {
created: NOW.toISOString(),
content: `<a href="/member/${member.username}", uk-tooltip="Visit ${member.username}">@${member.username}</a> has connected to the room.`,
content: `${member.displayName || member.username} has entered the room.`,
};
this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList, systemMessages: [systemMessage] });
this.dtp.emitter
.to(room._id.toString())
.emit('chat-control', {
displayList,
systemMessages: [systemMessage],
});
}
async chatRoomCheckOut (room, member) {
@ -182,6 +199,44 @@ export default class ChatService extends SiteService {
this.dtp.emitter.to(room._id.toString()).emit('chat-control', { displayList, systemMessages: [systemMessage] });
}
async sendRoomMessage (room, author, messageDefinition) {
const { text: textService, user: userService } = this.dtp.services;
const NOW = new Date();
const message = new ChatMessage();
message.created = NOW;
message.channelType = 'ChatRoom';
message.channel = room._id;
message.author = author._id;
message.content = textService.filter(messageDefinition.content);
await message.save();
const messageObj = message.toObject();
let viewModel = Object.assign({ }, this.dtp.app.locals);
messageObj.author = userService.filterUserObject(author);
viewModel = Object.assign(viewModel, { message: messageObj });
const html = this.templates.message(viewModel);
this.dtp.emitter
.to(room._id.toString())
.emit('chat-message', { message: messageObj, html });
return messageObj;
}
async getRoomMessages (room, pagination) {
const messages = await ChatMessage
.find({ channel: room._id })
.sort({ created: 1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatMessage)
.lean();
return messages;
}
async checkRoomMember (room, member) {
if (room.owner._id.equals(member._id)) {
return true;

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

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

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

@ -11,6 +11,12 @@ mixin renderChatMessage (message)
.author-display-name= message.author.displayName
.author-username @#{message.author.username}
.uk-width-auto
.message-timestamp= dayjs(message.created).format('h:mm a')
.message-content
div!= marked.parse(message.content)
.message-timestamp(
data-dtp-timestamp= message.created,
data-dtp-timestamp-format= "time",
uk-tooltip={ title: dayjs(message.created).format('MMM D, YYYY') }
)= dayjs(message.created).format('h:mm a')
if message.content && (message.content.length > 0)
.message-content
div!= marked.parse(message.content, { renderer: fullMarkedRenderer })

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

@ -1,4 +1,6 @@
extends ../../layout/main
block vendorcss
link(rel='stylesheet', href=`/highlight.js/styles/qtcreator-light.min.css?v=${pkg.version}`)
block view-content
include ../components/message
@ -37,6 +39,8 @@ block view-content
.uk-margin-small-left
i.fas.fa-cog
block view-navbar
.dtp-chat-stage
#room-member-panel.chat-sidebar
.chat-stage-header
@ -55,13 +59,21 @@ block view-content
.chat-container
.chat-stage-header
div(uk-grid)
.uk-width-expand= room.name
div(uk-grid).uk-grid-small
.uk-width-expand
.uk-text-truncate= room.name
if room.owner._id.equals(user._id)
.uk-width-auto
a(href=`/chat/room/${room._id}/settings`, uk-tooltip={ title: 'Configure room settings' }).uk-link-reset
i.fas.fa-cog
.uk-width-auto
a(href="/", uk-tooltip={ title: 'Leave room' }).uk-link-reset
i.fas.fa-person-through-window
.chat-content-panel
.live-content
.chat-media
div(uk-grid)
div(uk-grid).uk-flex-center
div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@l uk-width-1-4@xl")
+renderLiveMember(user)
@ -73,24 +85,26 @@ block view-content
content: "This is the chat content panel. It should word-wrap and scroll correctly, and will be where individual chat messages will render as they arrive and are sent.",
};
+renderChatMessage(testMessage)
+renderChatMessage(testMessage)
+renderChatMessage(testMessage)
each message of messages
+renderChatMessage(message)
.chat-input-panel
form(
method="POST",
action=`/chat/room/${room._id}/message`,
id="chat-input-form",
data-room-id= room._id,
onsubmit="return window.dtp.app.sendUserChat(event);",
hidden= user && user.flags && user.flags.isCloaked,
enctype="multipart/form-data"
).uk-form
textarea(id="chat-input-text", name="chatInput", rows=2).uk-textarea.uk-resize-none.uk-border-rounded
.uk-margin-small
.uk-flex
.uk-width-expand
.uk-width-auto
button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-default.uk-button-small.uk-border-rounded
i.fas.fa-paper-plane
textarea(id="chat-input-text", name="content", rows=2).uk-textarea.uk-resize-none.uk-border-rounded
.uk-margin-small
.uk-flex
.uk-width-expand
.uk-width-auto
button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-primary.uk-button-small.uk-border-rounded.uk-light
i.fas.fa-paper-plane
block viewjs
script.

6
app/views/components/navbar.pug

@ -5,12 +5,6 @@ nav(style="background: #000000;").uk-navbar-container.uk-light
a(href="/", aria-label="Back to Home").uk-navbar-item.uk-logo.uk-padding-remove-left
img(src="/img/nav-icon.png").navbar-logo
ul.uk-navbar-nav
li.uk-active
a(href="/")
span
i.fas.fa-home
span HOME
.uk-navbar-right
if !user
ul.uk-navbar-nav

17
app/views/home.pug

@ -2,9 +2,20 @@ extends layout/main
block view-content
mixin renderRoomListEntry (room)
a(href=`/chat/room/${room._id}`).uk-link-reset
.uk-text-bold= room.name
.uk-text-small= room.topic || '(no topic assigned)'
div(uk-grid)
.uk-width-expand
a(href=`/chat/room/${room._id}`).uk-link-reset
.uk-text-bold= room.name
.uk-text-small= room.topic || '(no topic assigned)'
.uk-width-auto
.uk-text-small Active
.uk-text-bold(class={
'uk-text-success': (room.stats.presentCount > 0),
'uk-text-muted': (room.stats.presentCount === 0),
})= room.stats.presentCount
.uk-width-auto
.uk-text-small Members
.uk-text-bold= room.stats.memberCount
section.uk-section.uk-section-default
.uk-container

42
client/css/site/stage.less

@ -1,9 +1,9 @@
@stage-panel-padding: 4px 10px;
@stage-panel-padding: 5px;
@stage-border-color: #4a4a4a;
.dtp-chat-stage {
position: absolute;
top: @site-navbar-height; right: 0; bottom: 0; left: 0;
position: fixed;
top: 0; right: 0; bottom: 0; left: 0;
display: flex;
@ -13,7 +13,7 @@
padding: @stage-panel-padding;
font-weight: bold;
font-size: 0.9em;
background-color: #2a2a2a;
color: #e8e8e8;
@ -95,15 +95,15 @@
video {
display: block;
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
border-radius: 3px;
}
.live-meta {
color: #e8e8e8;
color: #a8a8a8;
.live-username {
color: inherit;
@ -118,14 +118,14 @@
flex-grow: 0;
flex-shrink: 0;
width: 320px;
width: 400px;
padding: @stage-panel-padding;
overflow-y: scroll;
.chat-message {
padding: 5px;
margin-bottom: 10px;
margin-bottom: 5px;
line-height: 1;
border-radius: 4px;
@ -134,8 +134,20 @@
color: #1a1a1a;
&.system-message {
background-color: #d1f3db;
font-size: 0.8em;
background-color: transparent;
color: #4a4a4a;
border-radius: 4px;
.message-content {
margin-bottom: 2px;
}
.message-timestamp {
border-top: solid 1px #4a4a4a;
font-size: 0.9em;
color: #808080;
}
}
.message-attribution {
@ -150,7 +162,7 @@
}
.message-timestamp {
font-size: 0.9em;
font-size: 0.8em;
}
}
@ -161,6 +173,16 @@
margin-bottom: 10px;
color: #2a2a2a;
}
pre {
padding: 0;
background: transparent;
border: none;
code {
padding: 5px;
}
}
}
}
}

151
client/js/chat-audio.js

@ -0,0 +1,151 @@
// chat-audio.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'ChatAudio';
import DtpLog from 'lib/dtp-log';
const AudioContext = window.AudioContext || window.webkitAudioContext;
export default class ChatAudio {
constructor ( ) {
this.log = new DtpLog(DTP_COMPONENT_NAME);
}
start ( ) {
this.log.info('start', 'starting Web Audio API main context');
this.ctx = new AudioContext();
this.masterVolume = this.ctx.createGain();
this.masterVolume.connect(this.ctx.destination);
this.musicGain = this.ctx.createGain();
this.musicGain.value = 0.7;
this.musicGain.connect(this.masterVolume);
this.sounds = { };
}
async loadSound (soundId, url) {
if (this.sounds[soundId]) {
throw new Error('Already have sound registered for soundId');
}
const audioBuffer = await this.loadAudioBuffer(url);
const sound = { soundId, url, audioBuffer };
this.sounds[soundId] = sound;
return sound;
}
hasSound (soundId) {
return !!this.sounds[soundId];
}
playSound (soundId, options) {
const sound = this.sounds[soundId];
if (!sound) {
throw new Error(`Invalid soundId: ${soundId}`);
}
this.log.info('playSound', 'playing sound', { soundId });
return { soundId, ...this.playAudioBuffer(sound.audioBuffer, options) };
}
loadAudioBuffer (url) {
return new Promise(async (resolve, reject) => {
const response = await fetch(url);
const audioData = await response.arrayBuffer();
this.ctx.decodeAudioData(audioData, resolve, reject);
});
}
playAudioBuffer (buffer, options) {
options = Object.assign({
gain: 0.4,
loop: false,
}, options);
const source = this.ctx.createBufferSource();
source.buffer = buffer;
source.loop = options.loop;
const gainNode = this.ctx.createGain();
gainNode.gain.value = options.gain;
source.connect(gainNode);
gainNode.connect(this.masterVolume);
source.start();
return { gain: gainNode, source };
}
setMusicStream (url) {
let source;
this.stopMusicStream();
this.log.debug('setMusicStream', 'setting new music stream', { url });
this.music = new Audio();
this.music.setAttribute('loop', 'loop');
source = document.createElement('source');
source.setAttribute('src', `${url}.ogg`);
source.setAttribute('type', 'audio/ogg');
this.music.appendChild(source);
source = document.createElement('source');
source.setAttribute('src', `${url}.mp3`);
source.setAttribute('type', 'audio/mp3');
this.music.appendChild(source);
}
playMusicStream ( ) {
if (this.musicSource) {
return;
}
this.musicSource = this.ctx.createMediaElementSource(this.music);
this.musicSource.connect(this.musicGain);
this.log.debug('playMusicStream', 'starting music stream playback');
this.music.play();
}
stopMusicStream ( ) {
if (!this.musicSource) {
return;
}
this.log.debug('pauseMusicStream', 'stopping music stream playback');
this.music.pause();
this.musicGain.value = 0;
this.musicSource.disconnect(this.musicGain);
delete this.musicSource;
}
get musicVolume ( ) { return this.musicGain.gain.value; }
set musicVolume (volume) {
this.musicGain.gain.value = volume;
}
get haveMusicStream ( ) {
return !!this.music &&
!!this.musicSource &&
!!this.musicGain
;
}
get isMusicPaused ( ) {
if (!this.music) {
return true;
}
return this.music.paused;
}
}

183
client/js/chat-client.js

@ -8,14 +8,18 @@ const DTP_COMPONENT_NAME = 'DtpChatApp';
const dtp = window.dtp = window.dtp || { };
import DtpApp from 'lib/dtp-app.js';
import ChatAudio from './chat-audio.js';
import QRCode from 'qrcode';
import Cropper from 'cropperjs';
import dayjs from 'dayjs';
import hljs from 'highlight.js';
export class ChatApp extends DtpApp {
static get SFX_CHAT_MESSAGE ( ) { return 'chat-message'; }
constructor (user) {
super(DTP_COMPONENT_NAME, user);
this.loadSettings();
@ -31,8 +35,170 @@ export class ChatApp extends DtpApp {
isAtBottom: true,
};
if (this.chat.input) {
this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this));
this.observer = new MutationObserver(this.onChatMessageListChanged.bind(this));
this.observer.observe(this.chat.messageList, { childList: true });
hljs.highlightAll();
}
window.addEventListener('dtp-load', this.onDtpLoad.bind(this));
window.addEventListener('unload', this.onDtpUnload.bind(this));
this.updateTimestamps();
}
async startAudio ( ) {
this.log.info('startAudio', 'starting ChtaAudio');
this.audio = new ChatAudio();
this.audio.start();
try {
await Promise.all([
this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE, '/static/sfx/chat-message.mp3'),
]);
} catch (error) {
this.log.error('startAudio', 'failed to load sound', { error });
// fall through
}
}
async onChatInputKeyDown (event) {
if (event.key === 'Enter' && !event.shiftKey) {
if (dtp.room) {
return this.sendChatRoomMessage(event);
}
if (dtp.thread) {
return this.sendPrivateMessage(event);
}
return this.sendUserChat(event);
}
}
async onChatMessageListChanged (mutationList) {
this.log.info('onMutation', 'DOM mutation received', { mutationList });
if (!Array.isArray(mutationList) || (mutationList.length === 0)) {
return;
}
for (const mutation of mutationList) {
for (const node of mutation.addedNodes) {
if (typeof node.querySelectorAll !== 'function') {
continue;
}
const timestamps = node.querySelectorAll("[data-dtp-timestamp]");
this.updateTimestamps(timestamps);
}
}
hljs.highlightAll();
}
updateTimestamps ( ) {
const nodeList = document.querySelectorAll("[data-dtp-timestamp]");
this.log.debug('updateTimestamps', 'updating timestamps', { count: nodeList.length });
for (const ts of nodeList) {
const date = ts.getAttribute('data-dtp-timestamp');
const format = ts.getAttribute('data-dtp-timestamp-format');
if (!date) { continue; }
switch (format) {
case 'date':
ts.textContent = dayjs(date).format('MMM DD, YYYY');
break;
case 'time':
ts.textContent = dayjs(date).format('h:mm a');
break;
case 'datetime':
ts.textContent = dayjs(date).format('MMM D [at] h:mm a');
break;
case 'fuzzy':
ts.textContent = dayjs(date).fromNow();
break;
case 'timestamp':
default:
ts.textContent = dayjs(date).format('hh:mm:ss a');
break;
}
}
}
async sendUserChat (event) {
event.preventDefault();
if (!dtp.user) {
UIkit.modal.alert('There is a problem with Chat. Please refresh the page.');
return;
}
if (this.chatTimeout) {
return;
}
const channelId = dtp.user._id;
const content = this.chat.input.value;
this.chat.input.value = '';
if (content.length === 0) {
return true;
}
this.log.debug('sendUserChat', 'sending chat message', { channel: this.user._id, content });
this.socket.sendUserChat(channelId, content);
// set focus back to chat input
this.chat.input.focus();
const isFreeMember = false;
this.chat.sendButton.setAttribute('disabled', '');
this.chat.sendButton.setAttribute('uk-tooltip', isFreeMember ? 'Waiting 30 seconds' : 'Waiting 5 seconds');
this.chatTimeout = setTimeout(( ) => {
delete this.chatTimeout;
this.chat.sendButton.removeAttribute('disabled');
this.chat.sendButton.setAttribute('uk-tooltip', 'Send message');
}, isFreeMember ? 30000 : 5000);
return true;
}
async sendChatRoomMessage (event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (this.chatTimeout) {
return;
}
const form = new FormData(this.chat.form);
const roomId = this.chat.form.getAttribute('data-room-id');
const content = this.chat.input.value;
this.chat.input.value = '';
if (content.length === 0) {
return true;
}
try {
this.log.info('sendChatRoomMessage', 'sending chat message', { room: roomId, content });
const response = await fetch(this.chat.form.action, {
method: this.chat.form.method,
body: form,
});
await this.processResponse(response);
} catch (error) {
UIkit.modal.alert(`Failed to send chat message: ${error.message}`);
}
// set focus back to chat input
this.chat.input.focus();
this.chat.sendButton.setAttribute('disabled', '');
this.chatTimeout = setTimeout(( ) => {
delete this.chatTimeout;
this.chat.sendButton.removeAttribute('disabled');
}, 5000);
return true;
}
async onDtpLoad ( ) {
@ -50,6 +216,7 @@ export class ChatApp extends DtpApp {
async onChatSocketConnect (socket) {
this.log.debug('onSocketConnect', 'attaching socket events');
socket.on('chat-message', this.onChatMessage.bind(this));
socket.on('chat-control', this.onChatControl.bind(this));
socket.on('system-message', this.onSystemMessage.bind(this));
@ -60,10 +227,17 @@ export class ChatApp extends DtpApp {
async onChatSocketDisconnect (socket) {
this.log.debug('onSocketDisconnect', 'detaching socket events');
socket.off('chat-message', this.onChatMessage.bind(this));
socket.off('chat-control', this.onChatControl.bind(this));
socket.off('system-message', this.onSystemMessage.bind(this));
}
async onChatMessage (message) {
this.log.info('onChatMessage', 'chat message received', { message });
this.chat.messageList.insertAdjacentHTML('beforeend', message.html);
this.audio.playSound(ChatApp.SFX_CHAT_MESSAGE);
}
async onChatControl (message) {
const isAtBottom = this.chat.isAtBottom;
@ -113,18 +287,21 @@ export class ChatApp extends DtpApp {
const systemMessage = document.createElement('div');
systemMessage.classList.add('chat-message');
systemMessage.classList.add('system-message');
systemMessage.classList.add('no-select');
const chatContent = document.createElement('div');
chatContent.classList.add('chat-content');
chatContent.classList.add('message-content');
chatContent.classList.add('uk-text-break');
chatContent.innerHTML = message.content;
systemMessage.appendChild(chatContent);
const chatTimestamp = document.createElement('div');
chatTimestamp.classList.add('chat-timestamp');
chatTimestamp.classList.add('message-timestamp');
chatTimestamp.classList.add('uk-text-small');
chatTimestamp.classList.add('uk-text-right');
chatTimestamp.setAttribute('data-dtp-timestamp', message.created);
chatTimestamp.innerHTML = dayjs(message.created).format('hh:mm:ss a');
chatTimestamp.setAttribute('data-dtp-timestamp-format', 'time');
chatTimestamp.innerHTML = dayjs(message.created).format('h:mm:ss a');
systemMessage.appendChild(chatTimestamp);
this.chat.messageList.appendChild(systemMessage);

2
client/js/index.js

@ -17,6 +17,8 @@ window.addEventListener('load', async ( ) => {
dtp.app = new ChatApp(dtp.user);
dtp.log.info('load handler', 'application instance created', { env: dtp.env });
await dtp.app.startAudio();
dtp.log.debug('load', 'dispatching load event');
window.dispatchEvent(new Event('dtp-load'));
});

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

Binary file not shown.

6
dtp-chat.js

@ -53,8 +53,9 @@ class Harness {
constructor ( ) {
this.config = { root: __dirname };
this.log = new SiteLog(this, Harness);
this.models = [ ];
this.log = new SiteLog(this, Harness);
}
async start ( ) {
@ -297,6 +298,7 @@ class Harness {
this.app.use('/dayjs', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'dayjs')));
this.app.use('/numeral', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'numeral', 'min')));
this.app.use('/highlight.js', cacheOneDay, express.static(path.join(this.config.root, 'node_modules', 'highlight.js')));
/*
* Webpack integration
@ -482,8 +484,8 @@ class Harness {
viewModel.fullMarkedRenderer.link = confirmedLinkRenderer;
viewModel.safeMarkedRenderer = new Marked.Renderer();
viewModel.safeMarkedRenderer.link = safeLinkRenderer;
viewModel.safeMarkedRenderer.image = safeImageRenderer;
viewModel.safeMarkedRenderer.link = safeLinkRenderer;
viewModel.markedConfigChat = {
renderer: this.safeMarkedRenderer,

7
nodemon.json

@ -1,4 +1,9 @@
{
"verbose": true,
"ignore": ["dist"]
"ignore": [
"dist",
"client/**/*",
"lib/client/**/*",
"node_modules/**/*"
]
}

1
webpack.config.js

@ -41,6 +41,7 @@ if (webpackMode === 'development') {
files: [
'./dist/*.js',
'./dist/*.css',
'./app/views/**/*',
],
}),
);

Loading…
Cancel
Save