Développer un chat temps-réel avec Socket.io (partie 3 / 3)

Nous voici donc arrivés dans cette troisième partie du développement d'un chat temps-réel utilisant Node.js et Socket.io. Nous avons pour l'instant un chat fonctionnelle permettant de se connecter avec un pseudonyme et d'envoyer des messages aux autres utilisateurs. Dans cette partie nous allons faire trois choses :

  • Rester en bas de page lorsque de nouveaux messages arrivent
  • Afficher la liste des utilisateurs connectés
  • Afficher un message "xxx is typing" quand un utilisateur est en train d'écrire
  • Gérer un historique de messages (affichage des x derniers messages envoyés avant la connexion de l'utilisateur)

Base de code

Nous repartons du code de la partie 2 pour développer cette troisième partie. Si vous n'avez pas suivi les deux premières parties de ce tutoriel, je vous invite soit à les suivre (première partie, deuxième partie) soit à télécharger son code source sur mon compte GitHub ou en clonant la branche git correspondante :

git clone --branch part-2 https://github.com/BenjaminBini/socket.io-chat.git  

Rester en bas de page

Vous avez peut-être remarqué un petit soucis dans notre chat : lorsque les messages partagés sont trop nombreux et qu'ils dépassent de la page, il est nécessaire de scroller afin de les lire. De plus, le dernier message envoyé sera masqué par le formulaire de saisie d'un message.

Ce n'est pas compliqué à corriger.
Pour que le formulaire ne masque pas le dernier message envoyé nous allons ajouter un padding-bottom à notre liste de messages.

section#chat #messages {
    list-style-type: none;
    margin: 0;
    padding: 0;
    padding-bottom: 50px;
}

Nous souhaitons également que l'on reste en bas de page lors de l'arrivée de nouveaux messages. Mais nous voulons également pouvoir remonter dans la liste des messages reçus sans être dérangé par la réception de ceux-ci !
Nous allons donc créer une fonction qui sera appelée après l'ajout d'un message (une balise li) utilisateur ou d'un message de service. Celle-ci va permettre de scroller automatiquement vers le bas de la page uniquement dans le cas où l'utilisateur n'est pas remonté lire les anciens messages (on va laisser une petite marge, disont qu'on scrollera vers le bas si l'utilisateur est remonté d'une distance supérieur à la hauteur d'un message).

Voici la fonction à ajouter au début de client.js.

/**
 * Scroll vers le bas de page si l'utilisateur n'est pas remonté pour lire d'anciens messages
 */
function scrollToBottom() {
  if ($(window).scrollTop() + $(window).height() + 2 * $('#messages li').last().outerHeight() >= $(document).height()) {
    $("html, body").animate({ scrollTop: $(document).height() }, 0);
  }
}

Si comme moi vous utilisez JSLint, je vous invite à ajouter ce commentaire en haut de fichier afin qu'il reconnaisse l'existence de window.

/*jslint browser: true*/

Il n'y a plus qu'à appeler cette fonction lors de la réception d'un message ou d'un message de service.

/**
 * Réception d'un message
 */
socket.on('chat-message', function (message) {
  $('#messages').append($('<li>').html('<span class="username">' + message.username + '</span> ' + message.text));
  scrollToBottom();
});

/**
 * Réception d'un message de service
 */
socket.on('service-message', function (message) {
  $('#messages').append($('<li class="' + message.type + '">').html('<span class="info">information</span> ' + message.text));
  scrollToBottom();
});

Et voilà la résultat !

Trop facile. Passons à la suite.

Afficher la liste des utilisateurs connectés

Afin d'afficher la liste des utilisateurs connectés nous devons tout d'abord modifier le front-end (HTML, CSS).
Ajoutons la liste des utilisateurs au fichier index.html.

<section id="chat">
   <ul id="messages">
   </ul><ul id="users">
   </ul>
   <form action="">
     <input id="m" autocomplete="off" /><button>Send</button>
   </form>
</section>

On ne met pas d'espace entre </ul> et <ul id="users"> car cela crée un espace entre les deux div au rendu, ce que nous ne souhaitons pas.

Avec un peu de CSS, le rendu sera sympa.
Ajoutons les règles suivantes :

html, body, head {
    height: 100%;
}
section#chat {
    height: 100%;
}
section#chat #users {
    display: inline-block;
    position: fixed;
    vertical-align: top;
    overflow: auto;
    width: 250px;
    list-style-type: none;
    height: 100%;
    padding-bottom: 50px;
    border-left: 3px solid #eee;
}
section#chat #users li.new {
    background: #e67e22;
    color: white;
}
section#chat #users li {
    padding: 6px 10px;
    margin: 10px 10px;
    border-radius: 5px;
    border: 1px solid #e67e22;
    color: black;
    transition: all 0.5s;
}

Et ajoutez les règles qui suivent pour le selecteur section#chat #messages :

    display: inline-block;
    width: calc(100% - 250px);

Côté javascript client, on se doute que l'on va devoir gérer deux événements :

  • La connexion d'un utilisateur
  • Sa déconnexion

Lors de la connexion on va ajouter un élément à notre liste d'utilisateurs alors que l'on va en supprimer un lors de la déconnexion. Afin de rendre le tout un peu plus sexy, on met la classe new sur le nouvel élément. Classe que l'on supprime après une seconde (l'animation de la couleur de fond se fait alors grâce au CSS précédent).

/**
 * Connexion d'un nouvel utilisateur
 */
socket.on('user-login', function (user) {
  $('#users').append($('<li class="' + user.username + ' new">').html(user.username));
  setTimeout(function () {
    $('#users li.new').removeClass('new');
  }, 1000);
});

/**
 * Déconnexion d'un utilisateur
 */
socket.on('user-logout', function (user) {
  var selector = '#users li.' + user.username;
  $(selector).remove();
});

Côté serveur il y a un peu plus de travail, il faut gérer une liste des utilisateurs connectés. En effet, on ne peut pas se contenter de prévenir l'utilisateur qu'un nouvel utilisateur arrive ou qu'un autre s'en va, il faut, lorsqu'il arrive sur la page, qu'il reçoive l'ensemble des utilisateurs connectés.

Pour cela on crée une liste des utilisateurs connectés au début de notre fichier server.js.

/**
 * List of connected users
 */
var users = [];

La gestion de la déconnexion est simple.

/**
   * Déconnexion d'un utilisateur
   */
  socket.on('disconnect', function () {
    if (loggedUser !== undefined) {
      // Broadcast d'un 'service-message'
      var serviceMessage = {
        text: 'User "' + loggedUser.username + '" disconnected',
        type: 'logout'
      };
      socket.broadcast.emit('service-message', serviceMessage);
      // Suppression de la liste des connectés
      var userIndex = users.indexOf(loggedUser);
      if (userIndex !== -1) {
        users.splice(userIndex, 1);
      }
      // Emission d'un 'user-logout' contenant le user
      io.emit('user-logout', loggedUser);
    }
  });

La connexion est un peu plus compliquée.
Nous allons en effet devoir gérer le fait qu'un utilisateur ne peut pas se connecter avec un pseudonyme déjà existant.
Nous allons donc utiliser une fonctions de callback. Lors de l'émission d'un événement, le client peut ajouter comme paramètre à la fonction emit une fonction de callback qui sera appelée par le serveur une fois les traitements nécessaires effectués. On va donc ajouter une fonction de callback à l'événement user-login émit par le client qui renverra true ou false selon le succès ou l'échec de la connexion. Voilà comment gérer cela côté client :

/**
 * Connexion de l'utilisateur
 * Uniquement si le username n'est pas vide et n'existe pas encore
 */
$('#login form').submit(function (e) {
  e.preventDefault();
  var user = {
    username : $('#login input').val().trim()
  };
  if (user.username.length > 0) { // Si le champ de connexion n'est pas vide
    socket.emit('user-login', user, function (success) {
      if (success) {
        $('body').removeAttr('id'); // Cache formulaire de connexion
        $('#chat input').focus(); // Focus sur le champ du message
      }
    });
  }
});

On n'affiche le chat que si success vaut true.
Côté serveur il nous reste à appeler la fonction de callback et à émettre un événement contenant l'utilisateur connecté à tous les utilisateurs.

  /**
   * Connexion d'un utilisateur via le formulaire :
   */
  socket.on('user-login', function (user, callback) {
    // Vérification que l'utilisateur n'existe pas
    var userIndex = -1;
    for (i = 0; i < users.length; i++) {
      if (users[i].username === user.username) {
        userIndex = i;
      }
    }
    if (user !== undefined && userIndex === -1) { // S'il est bien nouveau
      // Sauvegarde de l'utilisateur et ajout à la liste des connectés
      loggedUser = user;
      users.push(loggedUser);
      // Envoi des messages de service
      var userServiceMessage = {
        text: 'You logged in as "' + loggedUser.username + '"',
        type: 'login'
      };
      var broadcastedServiceMessage = {
        text: 'User "' + loggedUser.username + '" logged in',
        type: 'login'
      };
      socket.emit('service-message', userServiceMessage);
      socket.broadcast.emit('service-message', broadcastedServiceMessage);
      // Emission de 'user-login' et appel du callback
      io.emit('user-login', loggedUser);
      callback(true);
    } else {
      callback(false);
    }
  });

J'en profite pour ajouter l'émission d'un message de service indiquant "You logged in as ..." à l'utilisateur qui vient de se connecter.

On doit encore envoyer la liste des utilisateurs connectés aux utilisateurs qui viennent d'arriver sur le chat. Pour cela on va envoyer au nouvaux arrivant un événement 'user-login' pour chaque utilisateur déjà connecté.

io.on('connection', function (socket) {
[...]
  /**
   * Emission d'un événement "user-login" pour chaque utilisateur connecté
   */
  for (i = 0; i < users.length; i++) {
    socket.emit('user-login', users[i]);
  }
[...]

On est bons ! Testez tout cela, ça devrait tourner sans soucis.

Gestion d'un historique de messages

Quand on arrive sur le chat, il serait plutôt cool d'avoir accès aux derniers messages partagés entre les utilisateurs.
Pour cela, tout comme on a une liste des utilisateurs connectés, on va gérer une liste des derniers messages envoyés.

Ajoutez au début de server.js :

/**
 * Historique des messages
 */
var messages = [];

A chaque nouveau message envoyé, on va ajouter ce dernier à la liste. Mais on ne veut pas qu'elle grossisse indéfiniment. On va donc définir une taille maximale de 150 messages à partir de laquelle tout ajout entraîne la suppression du plus ancien message.
Modifions la gestion de l'événement chat-message.

  /**
   * Réception de l'événement 'chat-message' et réémission vers tous les utilisateurs
   * Ajout à la liste des messages et purge si nécessaire
   */
  socket.on('chat-message', function (message) {
    message.username = loggedUser.username;
    io.emit('chat-message', message);
    messages.push(message);
    if (messages.length > 150) {
      messages.splice(0, 1);
    }
  });

On veut également enregistrer les messages de service (connexion et déconnexion des utilisateurs). Il faut donc aussi les ajouter à la liste.

Dans la gestion de l'événement user-login, ajoutons la troisième ligne ci-dessous.

 socket.emit('service-message', userServiceMessage);
 socket.broadcast.emit('service-message', broadcastedServiceMessage);
 messages.push(broadcastedServiceMessage);

Dans la gestion de l'événement disconnect, ajoutons la dernière ligne ci-dessous.

// Suppression de la liste des connectés
var userIndex = users.indexOf(loggedUser);
if (userIndex !== -1) {
	users.splice(userIndex, 1);
}
// Ajout du message à l'historique
messages.push(serviceMessage);

Reste à émettre un événement pour chaque message enregistré lors de l'arrivée d'un nouvel utilisateur. Le type de l'événement dépendra du type de message : classique ou de service. On fait la différence via la présence ou nom de la propriété username de l'objet.

io.on('connection', function (socket) {
[...]
  /** 
   * Emission d'un événement "chat-message" pour chaque message de l'historique
   */
  for (i = 0; i < messages.length; i++) {
    if (messages[i].username !== undefined) {
      socket.emit('chat-message', messages[i]);
    } else {
      socket.emit('service-message', messages[i]);
    }
  }
[...]

Dernier petit détail d'ordre graphique : les messages sont affichés sur l'écran (mais floutés "sous" le formulaire de connexion) avant que l'utilisateur saisisse son username. Si la liste des messages enregistrés est grande cela créera un bug du design de la page. Pour le corriger, dans le ficher css on va modifier la propriété position de la section#login. De absolute, on la fait passer à fixed.

Voilà ! Terminé.
Next step.

Notification d'une saisie en cours

Une fonctionnalité plutôt utile pour une application de chat est de voir qui est en train d'écrire un message. Où afficher cela ? Dans la liste des utilisateurs, à droite du nom, me semble être une bonne solution.
Comment cela va fonctionner ?
Côté client on va ajouter une balise span à droite du nom de l'utilisateur contenant le texte "typing". On ne l'affichera que quand l'utilisateur en question sera en train de taper.

Trois événements sont à gérer :

  • start-typing : envoyé par le client lorsque l'utilisateur commence à écrire
  • stop-typing : envoyé par le client lorsque l'utilisateur a arrêter d'écrire
  • update-typing : envoyé par le serveur quand une modification de la liste des utilisateurs en train d'écrire est arrivée

Commençons par le côté serveur.
Nous avons besoin d'une liste globale des utilisateurs en train d'écrire, à ajouter en début de fichier.

/**
 * Liste des utilisateurs en train de saisir un message
 */
var typingUsers = [];

Gérons maintenant les deux événements start-typing et stop-typing.

 /**
   * Réception de l'événement 'start-typing'
   * L'utilisateur commence à saisir son message
   */
  socket.on('start-typing', function () {
    // Ajout du user à la liste des utilisateurs en cours de saisie
    if (typingUsers.indexOf(loggedUser) === -1) {
      typingUsers.push(loggedUser);
    }
    io.emit('update-typing', typingUsers);
  });

  /**
   * Réception de l'événement 'stop-typing'
   * L'utilisateur a arrêter de saisir son message
   */
  socket.on('stop-typing', function () {
    var typingUserIndex = typingUsers.indexOf(loggedUser);
    if (typingUserIndex !== -1) {
      typingUsers.splice(typingUserIndex, 1);
    }
    io.emit('update-typing', typingUsers);
  });

On ne fait que recevoir les événements, ajouter ou supprimer l'utilisateur de la liste selon l'événement et émettre l'événement update-typing en y joignant la liste des utilisateurs en cours de saisie.

Dernier petit détail : gérer la déconnexion des utilisateurs. Si un utilsateur est déconnecté alors qu'il est en train d'écrire un message, il n'est pas supprimé de la liste. Gérons cela en ajoutant ces quelques lignes à la fin de la fonction gérant l'événement disconnect.

  socket.on('disconnect', function () {
    if (loggedUser !== undefined) {
      [...]
      // Si jamais il était en train de saisir un texte, on l'enlève de la liste
      var typingUserIndex = typingUsers.indexOf(loggedUser);
      if (typingUserIndex !== -1) {
        typingUsers.splice(typingUserIndex, 1);
      }
    }
  });

Côté client maintenant, on va commencer par ajouter la balise <span> à côté du nom des utilisateurs.

/**
 * Connection d'un nouvel utilisateur
 */
socket.on('user-login', function (user) {
  $('#users').append($('<li class="' + user.username + ' new">').html(user.username + '<span class="typing">typing</span>'));
  setTimeout(function () {
    $('#users li.new').removeClass('new');
  }, 1000);
});

Pour gérer l'émission des événements start-typing et stop-typing, nous allons utiliser les événements keypress et keyup. Pourquoi pas keydown ? Parce que cet événement est émis même lors de la frappe des touches Ctrl, Alt, etc. Ce qui n'est pas désiré ici.
On utilisera setTimeout afin d'autoriser un délais de 0.5 secondes entre la frappe de chaque touche.

/**
 * Détection saisie utilisateur
 */
var typingTimer;
var isTyping = false;

$('#m').keypress(function () {
  clearTimeout(typingTimer);
  if (!isTyping) {
    socket.emit('start-typing');
    isTyping = true;
  }
});

$('#m').keyup(function () {
  clearTimeout(typingTimer);
  typingTimer = setTimeout(function () {
    if (isTyping) {
      socket.emit('stop-typing');
      isTyping = false;
    }
  }, 500);
});

Passons à la gestion de la réception de l'événement update-typing pour mettre à jour l'UI.

/**
 * Gestion saisie des autres utilisateurs
 */
socket.on('update-typing', function (typingUsers) {
  $('#users li span.typing').hide();
  for (i = 0; i < typingUsers.length; i++) {
    $('#users li.' + typingUsers[i].username + ' span.typing').show();
  }
});

Et il n'y a plus qu'à rajouter le style de cette balise span.

section#chat #users li span.typing {
    float: right;
    font-style: italic;
    color: #eee;
    display: none;
}

Enfin terminé !

Ces trois parties nous ont permis de découvrir plein de choses sur le fonctionnement de socket.io avec node.js et jQuery. Vous pouvez bien sûr essayer d'aller plus loin en ajoutant par exemple plusieurs salles de chat ou bien des messages privés entre utilisateurs.

Sources

Comme d'habitude, les sources sont sur Github : à télécharger ou en clonant la branche git correspondante.

git clone --branch part-3 https://github.com/BenjaminBini/socket.io-chat.git  


Strasbourg, France

Ingénieur en informatique chez Sully Group.