Node.js: Inter-user chat and commenting for Notes

This is cool! We now have real-time updates in Notes as we edit delete or create notes. Let’s now take it to the next level and implement something akin to inter-user chatting.

Earlier, we named three things we could do with Socket.IO on /notes/view pages. We’ve already implemented live updating when a Note is changed and a redirect to the home page if a Note is deleted; the remaining task is to allow users to make comments on Notes.

It’s possible to pivot our Notes application concept and take it in the direction of a social network. In the majority of such networks, users post things (notes, pictures, videos, and so on), and other users comment on those things. Done well, these basic elements can develop a large community of people sharing notes with each other.

While the Notes application is kind of a toy, it’s not too terribly far from being a basic social network. Commenting the way we will do now is a tiny step in that direction.

On each note page, we’ll have an area to display messages from Notes users. Each message will show the username, a timestamp, and their message. We’ll also need a method for users to post a message, and we’ll also allow users to delete messages.

Each of those operations will be performed without refreshing the screen. Instead, code running inside the web page will send commands to/from the server and take action dynamically. By doing this, we’ll learn about Bootstrap modal dialogs, as well as more about sending and receiving Socket.IO messages. Let’s get started.

1. Data model for storing messages

We need to start by implementing a data model for storing messages. The basic fields required are a unique ID, the username of the person sending the message, the namespace and the room associated with the message, the message, and finally a timestamp for when the message was sent. As messages are received or deleted, events must be emitted from the data model so we can do the right thing on the web page. We associate messages with a room and namespace combination because in Socket.IO that combination has proved to be a good way to address a specific page in the Notes application.

This data model implementation will be written for Sequelize. If you prefer a different storage solution, you can, by all means, re-implement the same API on other data storage systems.

Create a new file, models/messages-sequelize.mjs, containing the following:

import Sequelize from ‘sequelize’; import {

connectDB as connectSequlz,

close as closeSequlz

} from ‘./sequlz.mjs’;

import EventEmitter from ‘events’;

class MessagesEmitter extends EventEmitter {}

export const emitter = new MessagesEmitter();

import DBG from ‘debug’;

const debug = DBG(‘notes:model-messages’);

const error = DBG(‘notes:error-messages’);

This sets up the modules being used and also initializes the EventEmitter interface. We’re also exporting the EventEmitter as emitter so other modules can be notified about messages as they’re created or deleted.

Now add this code for handling the database connection:

let sequelize;

export class SQMessage extends Sequelize.Model {}

async function connectDB() {

if (sequelize) return;

sequelize = await connectSequlz();

SQMessage.init({

id: { type: Sequelize.INTEGER, autoIncrement: true,

primaryKey: true },

from: Sequelize.STRING,

namespace: Sequelize.STRING,

room: Sequelize.STRING,

message: Sequelize.STRING(1024),

timestamp: Sequelize.DATE

}, {

hooks: {

afterCreate: (message, options) => {

const toEmit = sanitizedMessage(message);

emitter.emit(‘newmessage’, toEmit);

},

afterDestroy: (message, options) => {

emitter.emit(‘destroymessage’, {

id: message.id,

namespace: message.namespace, room: message.room

});

}

},

});

sequelize,

modelName: ‘SQMessage’

await SQMessage.sync();

}

The structure of connectDB is similar to what we did in notes-sequelize.mjs. We use the same connectSequlz function to connect with the same database, and we return immediately if the database is already connected.

With SQMessage.init, we define our message schema in the database. We have a simple database schema that is fairly self-explanatory. To emit events about messages, we’re using a Sequelize feature to be called at certain times.

The id field won’t be supplied by the caller; instead, it will be autogenerated. Because it is an autoIncrement field, each message that’s added will be assigned a new id number by the database. The equivalent in MySQL is the AUTO_INCREMENT attribute on a column definition.

The namespace and room fields together define which page in Notes each message belongs to. Remember that when emitting an event with Socket.IO we can target the event to one or both of those spaces, and therefore we will use these values to target each message to a specific page.

So far we defined one namespace, /home, for the Notes home page, and another namespace, /notes, for viewing an individual note. In theory, the Notes application could be expanded to have messages displayable in other areas. For example, a /private-message namespace could be used for private messages. Therefore, the schema is defined with both a namespace and room field so that, in due course, we could use messages in any future part of the Notes application that may be developed.

For our current purposes, messages will be stored with namespace equal to /home, and room equal to the key of a given Note.

We will use the timestamp to present messages in the order of when they were sent. The from field is the username of the sender.

To send notifications about created and destroyed messages, let’s try something different. If we follow the pattern we used earlier, the functions we’re about to create will have emitter.emit calls with corresponding messages. But Sequelize offers a different approach.

With Sequelize, we can create what are called hook methods. Hooks can also be called life cycle events, and they are a series of functions we can declare. Hook methods are invoked when certain trigger states exist for the objects managed by Sequelize. In this case, our code needs to know when a message is created, and when a message is deleted.

Hooks are declared as shown in the options object. A field named hooks in the schema options object defines hook functions. For each hook we want to use, add an appropriately named field containing the hook function. For our needs, we need to declare hooks.afterCreate and hooks.afterDestroy. For each, we’ve declared a function that takes the instance of the SQMessage object that has just been created or destroyed. And, with that object, we call emitter.emit with either the newmessage or destroymessage event name.

Continue by adding this function:

function sanitizedMessage(msg) {

return {

id: msg.id,

from: msg.from,

namespace: msg.namespace,

room: msg.room,

message: msg.message,

timestamp: msg.timestamp

};

}

The sanitizedMessage function performs the same function as sanitizedUser. In both cases, we are receiving a Sequelize object from the database, and we want to return a simple object to the caller. These functions produce that simplified object.

Next, we have several functions to store new messages, retrieve messages, and delete messages.

The first is this function:

export async function postMessage(from, namespace, room, message) {

await connectDB();

const newmsg = await SQMessage.create({

from, namespace, room, message, timestamp: new Date()

});

}

This is to be called when a user posts a new comment/message. We store it in the database, and the hook emits an event saying the message was created.

Remember that the id field is auto-created as the new message is stored. Therefore, it is not supplied when calling SQMessage.create.

This function, and the next, could have contained the emitter.emit call to send the newmessage or destroymessage events. Instead, those events are sent in the hook functions we created earlier. The question is whether it is correct to place emitter.emit in a hook function, or to place it here.

The rationale used here is that by using hooks we are assured of always emitting the messages.

Then, add this function:

export async function destroyMessage(id) {

await connectDB();

const msg = await SQMessage.findOne({ where: { id } });

if (msg) {

msg.destroy();

}

}

This is to be called when a user requests that a message should be deleted. With Sequelize, we must first find the message and then delete it by calling its destroy method.

Add this function:

export async function recentMessages(namespace, room) {

await connectDB();

const messages = await SQMessage.findAll({

where: { namespace, room },

order: [ [‘timestamp’, ‘DESC’] ], limit: 20

});

const msgs = messages.map(message => {

return sanitizedMessage(message);

});

return (msgs && msgs.length >= 1) ? msgs : undefined;

}

This function retrieves recent messages, and the immediate use case is for this to be used while rendering /notes/view pages.

While our current implementation is for viewing a Note, it is generalized to work for any Socket.IO namespace and room. This is for possible future expansion, as we explained earlier. It finds the most recent 20 messages associated with the given namespace and room combination, then returns a cleaned-up list to the caller.

In findAll, we specify an order attribute. This is similar to the ORDER BY phrase in SQL. The order attribute takes an array of one or more descriptors declaring how Sequelize should sort the results. In this case, there is one descriptor, saying to sort by the timestamp field in descending order. This will cause the most recent message to be displayed first.

We have created a simple module to store messages. We didn’t implement the full set of create, read, update, and delete (CRUD) operations because they weren’t necessary for this task. The user interfaces we’re about to create only let folks add new messages, delete existing messages, and view the current messages.

Let’s get on with creating the user interface.

2. Adding support for messages to the Notes router

Now that we can store messages in the database, let’s integrate this into the Notes router module.

Integrating messages to the /notes/view page will require some new HTML and JavaScript in the notesview.hbs template, and some new Socket.IO communications endpoints in the init function in routes/notes.mjs. In this section, let’s take care of those communications endpoints, then in the next section let’s talk about how to set it up in the user interface.

In routes/notes.mjs, add this to the import statements:

import {

postMessage, destroyMessage, recentMessages, emitter as msgEvents

} from ‘../models/messages-sequelize.mjs’;

import DBG from ‘debug’;

const debug = DBG(‘notes:home’);

const error = DBG(‘notes:error-home’);

This imports the functions we just created so we can use them. And we also set up debug and error functions for tracing.

Add these event handlers to the init function in routes/notes.mjs:

msgEvents.on(‘newmessage’, newmsg => {

io.of(newmsg.namespace).to(newmsg.room).emit(‘newmessage’,

newmsg);

});

msgEvents.on(‘destroymessage’, data => {

io.of(data.namespace).to(data.room).emit(‘destroymessage’, data);

});

These receive notifications of new messages, or destroyed messages, from models/messages-sequelize.mjs, then forwards the notification to the browser. Remember that the message object contains the namespace and room, therefore this lets us address this notification to any Socket.IO communication channel.

Why didn’t we just make the Socket.IO call in models/messages-sequelize.mjs? Clearly, it would have been slightly more efficient, require fewer lines of code, and therefore fewer opportunities for a bug to creep in, to have put the Socket.IO call in messages-sequelize.mjs. But we are maintaining the separation between model, view, and controller, which we talked of earlier in Chapter 5, Your First Express Application. Further, can we predict confidently that there will be no other use for messages in the future? This architecture allows us to connect multiple listener methods to those message events, for multiple purposes.

In the user interface, we’ll have to implement corresponding listeners to receive these messages, then take appropriate user interface actions.

In the connect listener in the init function, add these two new event listeners:

io.of(‘/notes’).on(‘connect’, async (socket) => {

let notekey = socket.handshake.query.key;

if (notekey) {

socket.join(notekey);

socket.on(‘create-message’, async (newmsg, fn) => {

try {

await postMessage(

newmsg.from, newmsg.namespace, newmsg.room, newmsg.message);

fn(‘ok’);

} catch (err) {

error(‘FAIL to create message ${err.stack}’);

}

}); 

socket.on(‘delete-message’, async (data) => {

try {

await destroyMessage(data.id);

} catch (err) {

error(‘FAIL to delete message ${err.stack}’);

}

});

}

});

This is the existing function to listen for connections from /notes/view pages, but with two new Socket.IO event handler functions. Remember that in the existing client code in notesview.hbs, it connects to the /notes namespace and supplies the note key as the room to join. In this section, we build on that by also setting up listeners for create-message and delete-message events when a note key has been supplied.

As the event names imply, the create-message event is sent by the client side when there is a new message, and the delete-message event is sent to delete a given message. The corresponding data model functions are called to perform those functions.

For the create-message event, there is an additional feature being used. This uses what Socket.IO calls an acknowledgment function.

So far, we’ve used the Socket.IO emit method with an event name and a data object. We can also include a callback function as an optional third parameter. The receiver of the message will receive the function and can call the function, and any data passed to the function is sent to the callback function. The interesting thing is this works across the browser-server boundary.

This means our client code will do this:

io.of(‘/notes’).to(note.key).emit(‘create-message’, {

… message data

},

function (result) {

… acknowledgement action

});

That function in the third parameter becomes the fn parameter in the create- message event handler function. Then, anything supplied to a call to fn will arrive in this function as the result parameter. It doesn’t matter that it’s a browser supplying that function across a connection to the server and that the call to the function happens on the server, Socket.IO takes care of transporting the response data back to the browser code and invoking the acknowledgment function there. The last thing to note is that we’re being lazy with error reporting. So, put a task on the backlog to improve error reporting to the users.

The next task is to implement code in the browser to make all this visible to the user.

3. Changing the note view template for messages

We need to dive back into views/noteview.hbs with more changes so that we can view, create, and delete messages. This time, we will add a lot of code, including using a Bootstrap modal popup to get the message, the Socket.IO messages we just discussed, and the jQuery manipulations to make everything appear on the screen.

We want the /notes/view page to not cause unneeded page reloads. Instead, we want the user to add a comment by having a pop-up window collect the message text, and then the new message is added to the page, without causing the page to reload.

Likewise, if another user adds a message to a Note, we want the message to show up without the page reloading. Likewise, we want to delete messages without causing the page to reload, and for messages to be deleted for others viewing the note without the page reloading.

Of course, this will involve several Socket.IO messages going back and forth between browser and server, along with some jQuery DOM manipulations. We can do both without reloading the page, which generally improves the user experience.

Let’s start by implementing the user interface to create a new message.

3.1. Composing messages on the Note view page

The next task for the /notes/view page is to let the user add a message. They’ll click a button, a pop-up window lets them enter the text, they’ll click a button in the popup, the popup will be dismissed, and the message will show up. Further, the message will be shown to other viewers of the Note.

The Bootstrap framework includes support for Modal windows. They serve a similar purpose to Modal dialogs in desktop applications. Modal windows appear above existing windows of an application, while preventing interaction with other parts of the web page or application. They are used for purposes such as asking a question of the user. The typical interaction is to click a button, then the application pops up a Modal window containing some UI elements, the user interacts with the Modal, then dismisses it. You will certainly have interacted with many thousands of Modal windows while using computers.

Let’s first add a button with which the user will request to add a comment. In the current design, there is a row of two buttons below the Note text. In views/noteview.hbs, let’s add a third button:

<div class=”row”><div class=”col-xs-12″>

<div class=”btn-group”>

<a class=”btn btn-outline-dark” href=”/notes/destroy?key={{notekey}}” role=”button”>Delete</a>

<a class=”btn btn-outline-dark” href=”/notes/edit?key={{notekey}}” role=”button”>Edit</a>

<button type=”button” class=”btn btn-outline-dark” data-toggle=”modal”

data-target=”#notes-comment-modal”>Comment</button>

</div>

</div></div>

This is directly out of the documentation for the Bootstrap Modal component. The btn-outline-dark style matches the other buttons in this row, and between the data-toggle and the data-target attributes, Bootstrap knows which Modal window to pop up.

Let’s insert the definition for the matching Modal window in views/noteview.hbs:

{{#if notekey}}{{#if user}}

<div class=”modal fade” id=”notes-comment-modal” tabindex=”-1″

role=”dialog” aria-labelledby=”noteCommentModalLabel” aria-hidden=”true”>

<div class=”modal-dialog modal-dialog-centered” role=”document”>

<div class=”modal-content”><div class=”modal-header”>

<h5 class=”modal-title” id=”noteCommentModalLabel”>Leave a Comment</h5>

<button type=”button” class=”close” data-dismiss=”modal”

aria-label=”Close”><span aria-hidden=”true”>&times;</span>

</button>

</div>

<div class=”modal-body”>

<form id=”submit-comment”>

<input id=”comment-from” type=”hidden” name=”from” value=”{{ user.id }}”>

<input id=”comment-namespace” type=”hidden” name=”namespace” value=”/notes”>

<input id=”comment-room” type=”hidden” name=”room” value=”{{notekey}}”>

<input id=”comment-key” type=”hidden” name=”key” value=”{{notekey}}”>

<fieldset>

<div class=”form-group”>

<label for=”noteCommentTextArea”>Your Excellent Thoughts</label>

<textarea id=”noteCommentTextArea” name=”message” class=”form-control” rows=”3″></textarea>

</div>

<div class=”form-group”>

<button id=”submitNewComment” type=”submit”

class=”btn btn-primary col-sm-offset-2 col-sm-10″> Make Comment</button>

</div>

</fieldset>

</form>

</div>

</div></div>

</div>

{{/if}}{{/if}}

Again, this comes directly from the Bootstrap documentation for the Modal component, along with a simple form to collect the message.

Notice there is <div class=”modal-dialog”>, and within that, <div class=”model-content”>. Together, these form what is shown within the dialog window. The content is split between a <div class=”modal-header”> for the top row of the dialog, and a <div class=”modal-body”> for the main content.

The id value on the outermost element, id=”notes-comment-modal”, matches the target declared in the button, data-target=”#notes-comment-modal”. Another connection to make is aria-labelledby, which matches the id of the <h5 class=”modal-title”> element.

<form id=”submit-comment”> is minimal because we will not use it to submit anything over an HTTP connection to a regular URL. Therefore, it does not have action and method attributes. Otherwise, this is a normal everyday Bootstrap form, with a fieldset and various form elements.

The next step is to add the client-side JavaScript code to make this functional. When clicking the button, we want some client code to run, which will send a create- message event matching the code we added to routes/notes.mjs.

In views/noteview.hbs, we have a section using $(document).ready that contains the client code. In that function, add a section that exists only if the user object exists, as follows:

$(document).ready(function () {

{{#if user}}

{{/if}}

});

That is, we want a section of jQuery code that’s active only when there is a user object, meaning that this Note is being shown to a logged-in user.

Within that section, add this event handler:

$(‘#submitNewComment’).on(‘click’, function(event) {

socket.emit(‘create-message’, {

from: $(‘#comment-from’).val(), namespace: $(‘#comment-namespace’).val(), room: $(‘#comment-room’).val(),

key: $(‘#comment-key’).val(),

message: $(‘#noteCommentTextArea’).val()

},

response => {

$(‘#notes-comment-modal’).modal(‘hide’);

$(‘#noteCommentTextArea’).empty();

});

});

This matches the button in the form we just created. Normally in the event handler for a type=”submit” button, we would use event.preventDefault to prevent the normal result, which is to reload the page. But that’s not required in this case.

The function gathers various values from the form elements and sends the create- message event. If we refer back to the server-side code, create-message calls postMessage, which saves the message to the database, which then sends a newmessage event, which makes its way to the browser.

Therefore, we will need a newmessage event handler, which we’ll get to in the next section. In the meantime, you should be able to run the Notes application, add some messages, and see they are added to the database.

Notice that this has a third parameter, a function that when called causes the Modal to be dismissed, and clears any message that was entered. This is the acknowledgment function we mentioned earlier, which is invoked on the server, and Socket.IO arranges to then invoke it here in the client.

3.2. Showing any existing messages on the Note view page

Now that we can add messages, let’s learn how to display messages. Remember that we’ve defined an SQMessage schema and that we’ve defined a function, recentMessages, to retrieve the recent messages.

We have two possible methods to display existing messages when rendering Note pages. One option is for the page, when it initially displays, to send an

event requesting the recent messages, and rendering those messages on the client once they’re received. The other option is to render the messages on the server, instead. We’ve chosen the second option, server-side rendering.

In routes/notes.mjs, modify the /view router function like so:

router.get(‘/view’, async (req, res, next) => {

try {

const note = await notes.read(req.query.key);

const messages = await recentMessages(‘/notes’, req.query.key);

res.render(‘noteview’, {

title: note ? note.title : “”, notekey: req.query.key,

user: req.user ? req.user : undefined, note, messages

});

} catch (err) { error(err); next(err); }

});

That’s simple enough: we retrieve the recent messages, then supply them to the noteview.hbs template. When we retrieve the messages, we supply the /notes namespace and a room name of the note key. It is now up to the template to render the messages.

In the noteview.hbs template, just below the delete, edit, and comment buttons, add this code:

<div id=”noteMessages”>

{{#if messages}}

{{#each messages}}

<div id=”note-message-{{ id }}” class=”card”>

<div class=”card-body”>

<h5 class=”card-title”>{{ from }}</h5>

<div class=”card-text”>{{ message }}

<small style=”display: block”>{{ timestamp }}</small>

</div>

<button type=”button” class=”btn btn-primary message-del-button”

data-id=”{{ id }}”

data-namespace=”{{ namespace }}” data-room=”{{ room }}”> Delete

</button>

</div>

</div>

{{/each}}

{{/if}}

</div>

If there is a messages object, these steps through the array, and for each entry, it sets up a Bootstrap card component to display the message. The messages are displayed within <div id=”noteMessages”>, which we’ll target in DOM manipulations later. The markup for each message comes directly from the Bootstrap documentation, with a few modifications.

In each case, the card component has an id attribute we can use to associate with a given message in the database. The button component will be used to cause a message to be deleted, and it carries data attributes to identify which message would be deleted.

With this, we can view a Note, and see any messages that have been attached. We did not select the ordering of the messages but remember that in models/messages- sequelize.mjs the database query orders the messages in reverse chronological order.

In any case, our goal was for messages to automatically be added without having to reload the page. For that purpose, we need a handler for the newmessage event, which is a task left over from the previous section.

Below the handler for the submitNewComment button, add this:

socket.on(‘newmessage’, newmsg => {

var msgtxt = [

‘<div id=”note-message-%id%” class=”card”>’,

‘<div class=”card-body”>’,

‘<h5 class=”card-title”>%from%</h5>’,

‘<div class=”card-text”>%message%’,

‘<small style=”display: block”>%timestamp%</small>’, ‘</div>’,

‘<button type=”button” class=”btn btn-primary message-del

-button” ‘,

‘data-id=”%id%” data-namespace=”%namespace%” ‘, ‘data-room=”%room%”>’,

‘Delete’,

‘</button>’,

‘</div>’,

‘</div>’

].join(‘\n’)

.replace(/%id%/g, newmsg.id)

.replace(/%from%/g, newmsg.from)

.replace(/%namespace%/g, newmsg.namespace)

.replace(/%room%/g, newmsg.room)

.replace(/%message%/g, newmsg.message)

.replace(/%timestamp%/g, newmsg.timestamp);

$(‘#noteMessages’).prepend(msgtxt);

});

This is a handler for the Socket.IO newmessage event. What we have done is taken the same markup as is in the template, substituted values into it, and used jQuery to prepend the text to the top of the noteMessages area.

Remember that we decided against using any ES6 goodness because a template string would sure be handy in this case. Therefore, we have fallen back on an older technique, the JavaScript String.replace method.

There is a common question, how do we replace multiple occurrences of a target string in JavaScript? You’ll notice that the target %id% appears twice. The best answer is to use replace(/pattern/g, newText); in other words, you pass a regular expression and specify the g modifier to make it a global action. To those of us who grew up using /bin/ed and for whom /usr/bin/vi was a major advance, we’re nodding in recognition that this is the JavaScript equivalent to s/pattern/newText/g.

With this event handler, the message will now appear automatically when it is added by the user. Further, for another window simply viewing the Note the new message will appear automatically.

Because we use the jQuery prepend method, the message appears at the top. If you want it to appear at the bottom, then use append. And in models/messages- sequelize.mjs, you can remove the DESC attribute in recentMessages to change the ordering.

The last thing to notice is the markup includes a button with the id=”message-del- button”. This button is meant to be used to delete a message, and in the next section, we’ll implement that feature.

3.3. Deleting messages on the Notes view page

To make the message-del-button button active, we need to listen to click events on the button.

Below the newmessage event handler, add this button click handler:

$(‘button.message-del-button’).on(‘click’, function(event) {

socket.emit(‘delete-message’, {

id: $(event.target).data(‘id’),

namespace: $(event.target).data(‘namespace’), room: $(event.target).data(‘room’)

})

});

The socket object already exists and is the Socket.IO connection to the room for this Note. We send to the room a delete-message event giving the values stored in data attributes on the button.

As we’ve already seen, on the server the delete-message event invokes the destroyMessage function. That function deletes the message from the database and also emits a destroymessage event. That event is received in routes/notes.mjs, which forwards the message to the browser. Therefore, we need an event listener in the browser to receive the destroymessage event:

socket.on(‘destroymessage’, data => {

$(‘#note-message-‘+data.id).remove();

});

Refer back and see that every message display card has an id parameter fitting the pattern shown here. Therefore, the jQuery remove function takes care of removing the message from the display.

3.4. Running Notes and passing messages

That was a lot of code, but we now have the ability to compose messages, display them on the screen, and delete them, all with no page reloads.

You can run the application as we did earlier, first starting the user authentication server in one command-line window and the Notes application in another:

It shows us any existing messages on a Note.

While entering a message, the Modal looks like this:

Try this with multiple browser windows viewing the same note or different notes. This way, you can verify that notes show up only on the corresponding note window.

Source: Herron David (2020), Node.js Web Development: Server-side web development made easy with Node 14 using practical examples, Packt Publishing.

Leave a Reply

Your email address will not be published. Required fields are marked *