TP - page de discussions

Nous voici arrivés à la dernière page de notre forum, la page de discussions. Il s’agit probablement de la page la plus difficile à créer, en raison de la complexité du responsive design et du grand nombre de fonctionnalités à implanter.

A la fin de ce travail pratique, vous devriez obtenir la page suivante :

_images/13.png

Et celle-ci lorsque vous réduisez la largeur de la fenêtre :

_images/23.png

Comme vous le voyez, si un utilisateur est connecté, il peut aimer et publier des messages, et modifier/supprimer ceux qu’il a lui-même écrits.

Si l’utilisateur n’est pas connecté, le bouton publier et l’éditeur disparaissent au profit d’un message l’invitant à se connecter.

_images/5.png

Cette page s’actualise en temps réel. Donc, si un utilisateur envoie un message, celui-ci s’ajoute automatiquement sur la page de toutes les personnes ayant chargé cette discussion. De même, la modification, la suppression et les likes d’un message s’actualisent en temps réel.

Lorsqu’on clique sur le bouton modifier, le message se transforme en zone de texte qui contient déjà le message existant :

_images/6.png

Vous remarquez que le bouton modifier s’est transformé en enregistrer.

Lorsqu’un utilisateur enregistre un message modifié, la date de modification apparaît (ou s’actualise) au-dessous de la date d’écriture :

_images/81.png

S’il clique sur le bouton supprimer, le message disparaît de la discussion et est supprimé de la base de données. Le bouton like se trouve au-dessous du nom d’utilisateur (à côté lorsque l’écran est plus petit) et fait également office de compteur de likes. Bien entendu, il n’est possible d’aimer qu’une seule fois un message par utilisateur. Par ailleurs, un clic sur le nom d’utilisateur qui a écrit le message affiche son profil.

La hauteur que peut prendre un message est limitée. Lorsque le message est plus long que cette hauteur, il apparaît une barre de défilement :

_images/91.png

Si un utilisateur souhaite créer une nouvelle discussion, c’est possible à l’aide du bouton nouvelle discussion qui se situe dans la barre de navigation. Ce bouton est accessible depuis n’importe quelle page du site (pourvu que l’utilisateur soit connecté). Cette page ressemble à la page de discussions, mais un champ est rajouté pour permettre à l’utilisateur de saisir le sujet de la discussion :

_images/41.png

Pistes

  • Pour l’actualisation en direct de la page, il est conseillé d’utiliser du juste socket.io pour la modification du contenu du message, l’actualisation des likes et la suppression des messages. Par contre, il serait intéressant de créer une page dernier_message qui s’occuperait de charger le dernier message d’une discussion lorsqu’un utilisateur vient d’en publier un. Ce contenu serait alors chargé avec une requête AJAX.
  • Comme le code sur les petites fenêtres est sensiblement différent de celui des grandes, nous utiliserons les classes mobile et desktop que nous avons créées dans responsive.css.
  • TinyMCE n’accepte pas d’être “caché” (avec style.display = 'none'), il faut donc créer la zone d’édition de texte directement lorsque l’utilisateur clique sur le bouton modifier
  • Les messages s’affichent dans l’ordre chronologique selon leur date d’écriture : le plus ancien en premier, le plus récent en dernier.

Bon travail !

Correction

Important

Le code qui est inclus dans ce corrigé ne protège pas cette application de toutes les failles de sécurité. S’il est impossible de faire des injections SQL, il est possible de modifier, supprimer ou publier un message au nom d’un autre utilisateur en raison de l’absence de vérification des événements socket qui sont envoyés à partir du client.

Routes

A présent, nous allons créer trois routes ; une pour la page des discussions, une pour la page des nouvelles discussions et une pour le chargement des messages à l’aide d’AJAX.

./serveur/routes.js
app.get(/^\/([0-9]{1,9})$/, function(req, res) {
  sql.chargement_messages(req.params[0], mysql, sql, true,
    function(discussion_existante, sujet_discussion, messages) {
      if (!discussion_existante) {
        res.render('erreur_discussion.ejs');
      }
      else {
        res.render('discussion.ejs', {
          nom_utilisateur: session.session_active(req.session, req),
          sujet_discussion: sujet_discussion,
          m : messages
        });
      }
    }
  );
});

app.get(/^\/dernier_([0-9]{1,9})$/, function(req, res) {
  sql.chargement_messages(req.params[0], mysql, sql, false,
    function(discussion_existante, sujet_discussion, messages) {
      res.render('chargement_messages.ejs', {
        nom_utilisateur: session.session_active(req.session, req),
        m: messages
      });
    }
  );
});

app.get('/new', function(req, res) {
  res.render('new.ejs', {
    nom_utilisateur: session.session_active(req.session, req)
  });
});

Note

Il ne faut pas oublier de spécifier un message d’erreur dans erreur_discussion.ejs :

./views/erreur_discussion.ejs

Cette discussion n’existe pas

Chargement initial de la page

Code côté serveur

Dans le fichier routes.js, le code pour obtenir les messages d’une discussion se contente d’une simple fonction, chargement_messages(), qui se trouve dans le fichier sql.js :

./serveur/sql.js
// fonction qui envoie tout ce qu'il faut pour afficher un message
exports.chargement_messages = function(
  id_discussion,
  mysql,
  sql,
  tout_charger,
  callback) {

    // requête SQL pour vérifier si la discussion existe
    var requete_sql = '\
      SELECT id_discussion\
      FROM discussions\
      WHERE id_discussion = ?';
    var inserts = [id_discussion];
    requete_sql = sql.preparer(mysql, requete_sql, inserts);

    sql.requete(mysql, sql, requete_sql, function(results) {

      // nous vérifions si la variable results[0] contient l'id de discussion
      if (!results[0]) {
        // retourne false à l'argument discussion_existante
        // ceci est ensuite traité par routes.js et envoie une page d'erreur
        callback(false);

      } else {
        // nous récupérons le sujet de la discussion
        sql.sujet_discussion(mysql, sql, id_discussion,
          function(sujet_discussion) {

            // une fois que nous l'avons, nous récupérons tous les messages
            var requete_sql = '\
              SELECT\
                messages.id_message AS "id_message",\
                messages.nom_utilisateur AS "nom_utilisateur",\
                messages.contenu AS "contenu",\
                messages.date_ecriture AS "date_ecriture",\
                messages.date_modification AS "date_modification",\
                messages.id_discussion AS "id_discussion",\
                messages.likes AS "likes",\
                users.avatar AS "avatar"\
              FROM users, messages\
              WHERE messages.id_discussion = ?\
              AND messages.nom_utilisateur = users.nom_utilisateur\
              ORDER BY messages.date_ecriture';

            // si nous ne voulons que le dernier message...
            if (!tout_charger) {
              var requete_sql += ' DESC LIMIT 0,1';
            }

            var inserts = [id_discussion];
            requete_sql = sql.preparer(mysql, requete_sql, inserts);

            sql.requete(mysql, sql, requete_sql, function(results) {
              // enfin, nous pouvons envoyer le sujet de discussion et les messages
              callback(true, sujet_discussion, results);
            });
          }
        );
      }
    }
  );
}

Pour fonctionner correctement, cette fonction a également besoin de sql.sujet_discussion() :

./serveur/sql.js
// fonction qui renvoie le sujet d'une discussion à partir d'une discussion
exports.sujet_discussion = function(mysql, sql, id_discussion, callback) {
  var requete_sql = 'SELECT sujet FROM discussions WHERE id_discussion = ?';
  var inserts = [id_discussion];
  requete_sql = sql.preparer(fmysql, requete_sql, inserts);

  sql.requete(mysql, sql, requete_sql, function(results) {
    callback(results[0].sujet);
  });
}

Code côté client

Pour envoyer la page web, routes.js fait appel au fichier discussion.ejs. Voici son contenu :

./views/discussion.ejs
<!DOCTYPE html>
<html lang="fr">
  <head>
    <% include balise_head %>
    <script src="static/js/conversion_dates.js"></script>
    <script src="static/js/discussion.js"></script>
  </head>
  <body>
    <% include barre_navigation %>
    <div class="container">
      <div class="row">
        <div class="col-lg-offset-2 col-md-offset-1 col-md-10 col-lg-8
        blanc discussions">
          <p>
            <% if (nom_utilisateur === 'session inexistante') { %>
              <div class="alert alert-info" style="text-align:justify">
                Vous n'êtes actuellement pas connecté.
                Par conséquent, vous ne pouvez pas aimer ou publier des messages.
                <a href="login">
                  Connectez-vous
                </a>
                pour participer à la discussion.
              </div>
             <% } %>
          </p>
          <div class="sujet_discussion">
            <%= sujet_discussion %>
          </div>
          <div id="messages">
            <% include chargement_messages %>
          </div>
          <% if (nom_utilisateur !== 'session inexistante') { %>
            <% include editeur %>
          <% } %>
        </div>
        <div class="col-lg-offset-2 col-md-offset-1"></div>
      </div>
    </div>
  </body>
<html>

Pour améliorer la structure de ce code, cette page contient une inclusion d’un fichier du nom de chargement_messages.ejs. Ce fichier utilise les données envoyées à partir de sql.chargement_messages() et les formate en HTML :

./views/chargement_messages.ejs
<!-- cette fonction sert à lancer la conversion d'une date formatée par mysql.
elle utilise le fichier conversion_dates.js -->
<% convertir_date = function(date) {
  var id_temp = Math.random().toString(36).substring(7); %>
  <span id="<%= id_temp %>"></span>
  <script>convertir_date("<%= date %>","<%= id_temp %>");</script>
<% }
<!-- cette boucle parcourt tous les messages et applique à chacun un formatage -->
for (var i = 0; i < m.length; i++) { %>
  <!-- les lignes suivantes traitent quelques caractères spéciaux (' " \)
  qui créent des bugs s'ils ne sont pas "encodés" -->
  <% m[i].contenu = m[i].contenu
    .replace(/#g_doubles/g, '"')
    .replace(/#g_simples/g, "'")
    .replace(/#backslash/g, "\\"); %>
  <!-- cet attribut ID permet de sélectionner
  et supprimer plus facilement les messages -->
  <div id="<%= m[i].id_message %>">
    <p class="ligne"></p>
    <div class="row">
      <div class="col-sm-2 zone_utilisateur">
        <image src="static/avatars_grands/<%= m[i].avatar %>.jpg" class="avatar"><br>
      <div>
        <a href="profil_<%= m[i].nom_utilisateur %>" class="nom_utilisateur">
          <%= m[i].nom_utilisateur %>
        </a>
        <!-- affichage sur les appareils dont la largeur de la fenêtre est petite -->
        <span class="mobile">
          <a onclick="aimer(<%= m[i].id_message %>, '<%= nom_utilisateur %>')"
            class="btn btn-sm btn-success">
              <span class="glyphicon glyphicon-heart"></span>
              <span class="likes">
                <%= m[i].likes %>
              </span>
          </a>
          <!-- les boutons suivants s'affichent seulement si l'utilisateur connecté
          a écrit le message -->
          <% if (nom_utilisateur === m[i].nom_utilisateur) { %>
            <!-- bouton "modifier" -->
            <span id="<%= m[i].id_message %>_bouton_modifier_mobile">
              <span onclick="modifier_message(<%= m[i].id_message %>)"
                class="btn btn-sm btn-default">
                <span class="glyphicon glyphicon-pencil"></span>
                  modifier
              </span>
            </span>
            <!-- bouton "enregistrer", qui s'affichera après un clic sur "modifier" -->
            <span id="<%= m[i].id_message %>_bouton_enregistrer_mobile"
            style="display: none;">
              <span onclick="enregistrer_message(<%= m[i].id_message %>)"
                class="btn btn-sm btn-default">
                <span class="glyphicon glyphicon-pencil"></span>
                  enregistrer
              </span>
            </span>
            <span>
            <!-- bouton "supprimer" -->
              <span onclick="supprimer_message(<%= m[i].id_message %>)"
                class="btn btn-sm btn-danger">
                <span class="glyphicon glyphicon-remove"></span>
                  supprimer
              </span><br>
            </span>
          <% } %>
        </span>
      </div>
        <center>
          <!-- affichage sur les appareils
          dont la largeur de la fenêtre est plus grande -->
          <span class="desktop">
            <a onclick="aimer(<%= m[i].id_message %>, '<%= nom_utilisateur %>')"
            class="btn btn-sm btn-success"
            style="margin-top: 5px;">
              <span class="glyphicon glyphicon-heart"></span>
              <span class="likes">
                <%= m[i].likes %>
              </span>
            </a><br>
          </span>
        </center>
      </div>
      <!-- si les boutons s'affichent, la balise prend la classe bootstrap col-sm-8 -->
      <% if (nom_utilisateur === m[i].nom_utilisateur) { %>
        <div class="td_message col-sm-8">
      <% } else { %>
      <!-- dans le cas contraire, on peut se permettre
      d'avoir une zone de texte plus grande -->
        <div class="td_message col-sm-10">
      <% } %>
        <div class="zone_message">
          <div class="dates">
            <span class="date_ecriture">
              écrit le <% convertir_date(m[i].date_ecriture); %><br>
            </span>
            <span class="date_modification">
              <% if (m[i].date_modification !== '0000-00-00 00:00:00') { %>
                modifié le <% convertir_date(m[i].date_modification); %><br>
              <% } %>
            </span>
          </div>
          <div class="editeur mod_message" style="display: none;">
            <!-- la textarea contient un attribut ID généré au hasard
            pour permettre à TinyMCE de générer l'éditeur plusieurs fois -->
            <textarea
            id="<%= m[i].id_message + Math.random().toString(36).substring(7); %>">
              <%= m[i].contenu %>
            </textarea>
          </div>
          <span class="affichage">
            <%- m[i].contenu %>
          </span>
        </div>
      </div>
      <% if (nom_utilisateur === m[i].nom_utilisateur) { %>
        <!-- affichage des boutons sur les fenêtres plus larges -->
        <div class="desktop">
          <!-- l'attribut "style" force le texte à s'afficher en haut -->
          <div class="col-sm-2" style="vertical-align: text-top;">
            <center>
              <!-- bouton "modifier" -->
              <div id="<%= m[i].id_message %>_bouton_modifier">
                <span onclick="modifier_message(<%= m[i].id_message %>)"
                  class="btn btn-sm btn-default">
                  <span class="glyphicon glyphicon-pencil"></span>
                    modifier
                </span><br>
              </div>
            </center>
            <center>
              <!-- bouton "enregistrer" -->
              <div id="<%= m[i].id_message %>_bouton_enregistrer"
              style="display: none;">
                <span onclick="enregistrer_message(<%= m[i].id_message %>)"
                  class="btn btn-sm btn-default">
                  <span class="glyphicon glyphicon-pencil"></span>
                    enregistrer
                </span><br>
              </div>
            </center>
            <center>
              <div>
                <!-- bouton "supprimer" -->
                <span onclick="supprimer_message(<%= m[i].id_message %>)"
                  class="btn btn-sm btn-danger">
                  <span class="glyphicon glyphicon-remove"></span>
                    supprimer
                </span><br>
              </div>
            </center>
          </div>
        </div>
      <% } %>
    </div>
  </div>
<% } %>

Nous aurons aussi besoin d’un tout petit fichier pour afficher l’éditeur TinyMCE au bas de la page :

./views/editeur.ejs
<p class="editeur">
  <textarea id="emplacement_nouveau_message" class="form-control">
  </textarea>
    <script>
      init_editeur('emplacement_nouveau_message');
    </script>
  <div class="help-block">Vous pouvez agrandir la fenêtre
    <span onclick="publier_message('<%= nom_utilisateur %>');"
      class="btn btn-primary publication_message" >
      Publier
    </span>
  </div>
</p>

Afin que la page s’affiche correctement, il est également nécessaire d’apporter quelques retouches au fichier style.css. Nous obtenons alors ceci :

./static/style.css
body {
  background-color: rgb(245, 245, 245);
}
.blanc {
  background-color: rgb(255, 255, 255);
  margin-top: -4px;
  padding-top: 4px;
}
table {
  position: relative;
    top: -2px;
}
.sujet {
  padding-top: 10px;
  padding-bottom: 10px;
}
.sujet_texte {
  font-size: 18px;
  font-weight: bold;
  position: relative;
    top: -1px;
  color:black;
}
.sujet_texte:hover {
  text-decoration: underline;
  color:black;
}
.infos_sujet {
  position: relative;
    top: 2px;
}
.nombre_messages {
  position: relative;
    top: 2px;
  font-weight: bold;
}
.dm {
  color: rgb(150,150,150);
}
.td_message {
  padding-left: 10px;
  padding-right: 10px;
}
.act_message {
  vertical-align: text-top;
}
.btn {
  margin-bottom: 10px;
}
.ecrire_message {
  padding-top: 10px;
}
.help-block {
  position: relative;
    top: -8px;
}
.row {
  position: relative;
    top: -15px;
}
.modification_message {
    min-height: 60px;
    overflow-y: auto;
    word-wrap:break-word;
}
.publication_message {
    float:right;
    position:relative;
        top:5px;
}
.dates {
  color: grey;
  position: relative;
    top: -3px;
}
.zone_message {
  max-height: 400px;
  overflow: auto;
  margin-right: -11px;
}
.message_erreur {
  position: relative;
  top: 2px;
  margin-bottom: -15px;
  display: none;
}
.gros_titre {
  font-size:30px;
  text-decoration:bold;
  text-align:center;
  margin-bottom: 25px;
}
.sujet_discussion {
  font-size:30px;
  text-decoration:bold;
  text-align:center;
  margin-bottom: 12px;
  position: relative;
    bottom: 5px;
}
.avatar {
  max-width: 80%;
  display: block;
  margin-left: auto;
  margin-right: auto;
}
.avatar_profil {
  max-width:180px;
  margin-top: 5px;
  position: relative;
  bottom: 20px;
}
.ligne {
  border-top: solid 3px rgb(235,235,235);
  margin-left: -15px;
  margin-right: -15px;
}
.btn-nouveau_compte {
  margin-bottom:4px
  margin-top:-15px;
  position: relative;
    bottom: 9px;
}
#mod_coordonnees, #mod_password {
  padding-right:20px;
  display:none;
}
.editeur {
  margin-top: 10px;
}

Note

Certaines modifications CSS isolées et non-récurentes se trouvent directement dans le code HTML.

De plus, il reste encore quelques styles à appliquer seulement sur certaines largeurs d’écran :

./static/responsive.css
@media(max-width:991px) {
  .discussions {
    padding-bottom: 15px;
  }
}
@media(max-width:767px) {
  .desktop {
    display: none;
  }
  .avatar {
    display: none;
  }
  .ligne {
    position:relative;
      bottom: 10px;
  }
  .sujet_discussion {
    margin-bottom: 15px;
  }
  .nom_utilisateur {
    position: relative;
      bottom: 4px;
      right: 3px;
  }
  .editeur {
    margin-right: 10px;
  }
  .form-group {
    padding-left: 17px;
  }
  .ligne {
    margin-bottom: -8px;
  }
}
@media(min-width: 768px) {
  .mobile {
    display: none;
  }
  .nom_utilisateur {
    display: block;
    text-align: center;
  }
  .ligne {
    margin-bottom: 24px;
  }
}

/* ... */

Gestion des messages

Code côté serveur

Pour pouvoir créer, modifier et supprimer des messages, ainsi qu’ajouter des likes, il nous faut ajouter de nombreux événements socket.io :

./serveur/socket.js
// publication d'un nouveau message
socket.on('nouveau_message', function (message) {
  var requete_sql = '\
    INSERT INTO messages(nom_utilisateur, contenu, date_ecriture, id_discussion, likes)\
    VALUES(??, ??, NOW(), ?, 0)';
  var inserts = [
    message.nom_utilisateur,
    message.contenu,
    message.id_discussion
  ];
  requete_sql = sql.preparer(mysql, requete_sql, inserts);
  sql.requete(mysql, sql, requete_sql);
  /* la concaténation de 'charger_dernier_message' avec l'id de la discussion
  permet d'éviter que le message se charge chez tous les utilisateurs
  ayant une discussion ouverte */
  socket.emit('charger_dernier_message'+message.id_discussion);
  socket.broadcast.emit('charger_dernier_message'+message.id_discussion);
});

// modifie un message dans la base de données
socket.on('modification_message', function (d) {
  var requete_sql = '\
    UPDATE messages SET contenu = ??, date_modification=NOW() WHERE id_message = ?';
  var inserts = [d.contenu.replace(/'/g, "\\'"), d.id_message];
  requete_sql = sql.preparer(mysql, requete_sql, inserts);
  sql.requete(mysql, sql, requete_sql, function(results) {
    var requete_sql = '\
      SELECT * FROM messages WHERE id_message = ?';
    var inserts = [d.id_message];
    requete_sql = sql.preparer(mysql, requete_sql, inserts);
    sql.requete(mysql, sql, requete_sql, function(results) {
      socket.emit('update_message'+d.id_discussion, {
        id_message : results[0].id_message,
        date_modification : results[0].date_modification,
        contenu : results[0].contenu
      });
      socket.broadcast.emit('update_message'+d.id_discussion, {
        id_message : results[0].id_message,
        date_modification : results[0].date_modification,
        contenu : results[0].contenu
      });
    });
  });
});

// supprime le message (dans la base de données)
socket.on('suppression_message_serveur', function (r) {
  var requete_sql = 'DELETE FROM messages WHERE id_message = ?';
  var inserts = [r.id_message];
  requete_sql = sql.preparer(mysql, requete_sql, inserts);
  sql.requete(mysql, sql, requete_sql);
  socket.emit('suppression_message_client_'+r.id_discussion, r.id_message);
  socket.broadcast.emit('suppression_message_client_'+r.id_discussion, r.id_message);
});

/* ajoute un "like" à un message tout en vérifiant que l'utilisateur
n'aie pas déjà aimé le message (chaque utilisateur ne peut aimer qu'une
seule fois un message) */
socket.on('like_message', function(d) { // id_message, nom_utilisateur
  if (d.nom_utilisateur !== 'session inexistante') {
    var requete_sql = 'SELECT nom_utilisateur FROM likes WHERE id_message = ?';
    var inserts = [d.id_message];
    requete_sql = sql.preparer(mysql, requete_sql, inserts);
    sql.requete(mysql, sql, requete_sql, function(results) {
      for (var i = 0; i < results.length; i++) {
        if (results[i].nom_utilisateur === d.nom_utilisateur) {
          var deja_like = true;
          return;
        }
      }
      if (!deja_like) {
        var requete_sql = 'SELECT likes FROM messages WHERE id_message = ?';
        requete_sql = sql.preparer(mysql, requete_sql, inserts);
        sql.requete(mysql, sql, requete_sql, function(results) {
          var likes_actuels = results[0].likes + 1;
          var requete_sql = 'UPDATE messages SET likes = ? WHERE id_message = ?';
          var inserts = [likes_actuels, d.id_message];
          requete_sql = sql.preparer(mysql, requete_sql, inserts);
          sql.requete(mysql, sql, requete_sql);

          var requete_sql = '\
            INSERT INTO likes(id_message, nom_utilisateur) VALUES (?, ??)';
          var inserts = [d.id_message, d.nom_utilisateur];
          requete_sql = sql.preparer(mysql, requete_sql, inserts);
          sql.requete(mysql, sql, requete_sql);

          socket.emit('update_likes'+d.id_discussion, {
            id_message : d.id_message,
            nombre_likes : likes_actuels
          });
          socket.broadcast.emit('update_likes'+d.id_discussion, {
            id_message : d.id_message,
            nombre_likes : likes_actuels
          });
        });
      }
    });
  }
});

Note

A la fin de presque toutes ces fonctions, socket.io émet un événement pour demander au client d’appliquer les modifications sur la page web.

Code côté client

Comme le javascript côté client est conséquent, nous allons créer un nouveau fichier, discussion.js :

./static/js/discussion.js
var id_discussion = window.location.href.replace(/.+\/([0-9]+)/, '$1');

/* fonction permettant de formatter les guillemets simples et doubles
ainsi que les backslash pour éviter un bug lors de la requête SQL */
function echap(chaine) {
  return chaine
    .replace(/"/g, "#g_doubles")
    .replace(/'/g, "#g_simples")
    .replace(/\\/g, "#backslash")
}

// fonction qui réalise l'inverse de la précédente
function unechap(chaine) {
  return chaine
    .replace(/#g_doubles/g, '"')
    .replace(/#g_simples/g, "'")
    .replace(/#backslash/g, "\\");
}

function publier_message(nom_utilisateur) {
  if (tinyMCE.get('emplacement_nouveau_message').getContent() !== '') {
    socket.emit('nouveau_message', {
     nom_utilisateur : nom_utilisateur,
     contenu : echap(tinyMCE.get('emplacement_nouveau_message').getContent()),
     id_discussion : id_discussion
    });
    tinyMCE.get('emplacement_nouveau_message').setContent('');
  }
}

function supprimer_message(id_message) {
  socket.emit('suppression_message_serveur', {
    id_discussion : id_discussion,
    id_message : id_message
  });
}

/* "like" d'un message
à noter que cette fonction envoie également le nom d'utilisateur
pour l'insérer dans la table "likes" de la base de données
et ainsi éviter qu'un utilisateur puisse aimer 2X un message */
function aimer(id_message, nom_utilisateur) {
  socket.emit('like_message', {
    id_message : id_message,
    nom_utilisateur : nom_utilisateur,
    id_discussion : id_discussion
  });
}

/* retourne simplement l'objet javascript de la classe "zone_message"
à partir d'un objet contenant une propriété "id_message" */
function zone_message(classe, d) {
  return byId(d.id_message)
    .getElementsByClassName('zone_message')[0]
    .getElementsByClassName(classe)[0];
}

/* affiche l'éditeur TinyMCE à la place du contenu du message
masque le bouton "modifier" et affiche le bouton "enregistrer" */
function modifier_message(id_message) {
  var d = { id_message : id_message };
  byId(id_message+'_bouton_modifier').style.display = 'none';
  byId(id_message+'_bouton_modifier_mobile').style.display = 'none';
  byId(id_message+'_bouton_enregistrer').style.display = 'block';
  byId(id_message+'_bouton_enregistrer_mobile').style.display = 'inline';
  zone_message('affichage', d).style.display = 'none';
  zone_message('editeur', d).style.display = 'block';
  // récupère l'ID aléatoire de TinyMCE
  var id_tinymce = zone_message('editeur', d).getElementsByTagName('textarea')[0].id;
  init_editeur(id_tinymce);
}

/* enregistre le message, envoie un événement socket, masque le bouton "enregistrer",
affiche le bouton "modifier". A noter que le message est modifié sur la page une
fois que socket.io retourne l'événement "update_message" */
function enregistrer_message(id_message) {
  var d = { id_message : id_message };
  byId(id_message+'_bouton_modifier').style.display = 'block';
  byId(id_message+'_bouton_modifier_mobile').style.display = 'inline';
  byId(id_message+'_bouton_enregistrer').style.display = 'none';
  byId(id_message+'_bouton_enregistrer_mobile').style.display = 'none';
  zone_message('affichage', d).style.display = 'block';
  zone_message('editeur', d).style.display = 'none';
  var id_tinymce = zone_message('editeur', d).getElementsByTagName('textarea')[0].id;
  socket.emit('modification_message', {
    contenu: echap(tinyMCE.get(id_tinymce).getContent()),
    id_message: id_message,
    id_discussion : id_discussion
  });
}

/* télécharge le dernier message à l'aide d'une requête AJAX
s'exécute lorsqu'un utilisateur a publié un message sur la discussion */
socket.on('charger_dernier_message'+id_discussion, function() {
  var xhr = new XMLHttpRequest();
  xhr.open('GET', 'dernier_'+id_discussion);
  xhr.addEventListener('readystatechange', function() {
    if (xhr.readyState === 4 && xhr.status === 200) {
      byId('messages').innerHTML += xhr.responseText;
      var dates_ecriture = byId('messages').getElementsByClassName('date_ecriture');
      var id = dates_ecriture[dates_ecriture.length-1]
        .getElementsByTagName('span')[0].id;
      convertir_date(Date.now(), id);
    }
  });
  xhr.send(null);
});

// supprime un message du côté client
socket.on('suppression_message_client_'+id_discussion, function(id_message) {
  byId(id_message).style.display = 'none';
});

// met à jour les likes d'un message
socket.on('update_likes'+id_discussion, function(m) {
  byId(m.id_message).getElementsByClassName('likes')[0].innerHTML = m.nombre_likes;
  byId(m.id_message).getElementsByClassName('likes')[1].innerHTML = m.nombre_likes;
})

/* met à jour le contenu d'un message, change l'ID de la textarea pour que le
message puisse toujours être modifié */
socket.on('update_message'+id_discussion, function(d) {
  zone_message('date_modification', d).innerHTML = 'modifié le ' +
    convertir_date(Date.now());
  zone_message('editeur', d).innerHTML = '<textarea id="'+Date.now()+'">' +
    unechap(d.contenu)+'</textarea>';
  zone_message('affichage', d).innerHTML = unechap(d.contenu);
});

Astuce

Si l’attribut ID de la balise <textarea> qui s’occupe d’afficher l’éditeur TinyMCE lors du clic sur le bouton modifier contient une chaîne de caractères aléatoires, c’est parce que TinyMCE n’accepte de convertir une <textarea> en éditeur qu’une seule fois par ID. Pour éviter que l’utilisateur ne puisse modifier plusieurs messages sans raffraîchir la page web, cette balise change donc d’ID à chaque clic sur le bouton enregistrer. Ainsi, chaque fois que nous cliquons sur modifier, l’ID est différent et l’éditeur peut s’afficher correctement.

Nouvelles discussions

Code côté serveur

En plus de publier un nouveau message, la page des nouvelles discussions doit également ajouter une discussion dans la base de données. Nous allons donc créer un événement socket permettant de le faire :

./serveur/socket.js
socket.on('nouvelle_discussion', function(discussion) {
  // sélectionne le dernier ID de discussion
  var requete_sql = '\
    SELECT id_discussion FROM messages \
    ORDER BY id_discussion DESC \
    LIMIT 0,1';
  sql.requete(mysql, sql, requete_sql, function(results) {
    // incrémente le dernier ID de 1, ce qui détermine l'ID de la nouvelle discussion
    var id_nouvelle_discussion = results[0].id_discussion+1;
    // insert le premier message de la discussion
    var requete_sql = '\
      INSERT INTO \
        messages(nom_utilisateur, contenu, date_ecriture, id_discussion, likes)\
      VALUES(??, ??, NOW(), ?, 0)';
    var inserts = [
      discussion.nom_utilisateur,
      discussion.message,
      id_nouvelle_discussion
    ];
    requete_sql = sql.preparer(mysql, requete_sql, inserts);
    sql.requete(mysql, sql, requete_sql);
    // attribue le sujet à la discussion dans la table "discussions"
    var requete_sql = '\
      INSERT INTO discussions(sujet, id_discussion)\
      VALUES(??, ?)';
    var inserts = [discussion.sujet.replace(/'/g, "\\'"), id_nouvelle_discussion];
    requete_sql = sql.preparer(mysql, requete_sql, inserts);
    sql.requete(mysql, sql, requete_sql, function() {
      // redirige l'utilisateur vers la nouvelle discussion
      socket.emit('redirection', id_nouvelle_discussion);
      // requête pour charger la dernière discussion dans la page d'accueil
      socket.broadcast.emit('charger_derniere_discussion');
    });
  });
});

Code côté client

Comme nous avons créé une route qui s’occupe d’afficher la page nouvelle discussion, il nous faut un nouveau fichier, new.ejs :

./views/new.ejs
<!DOCTYPE html>
<html lang="fr">
  <head>
    <% include balise_head %>
    <script src="static/js/new.js"></script>
  </head>
  <body>
    <% include barre_navigation %>
    <div class="container">
      <div class="row">
        <div class="col-lg-offset-2 col-md-offset-1 col-md-10 col-lg-8
          blanc corps_page">
          <% if (nom_utilisateur !== 'session inexistante') { %>
          <input type="text" id="input_sujet" placeholder="Sujet de la discussion">
            <% include editeur %>
          <% } else { %>
            <script>
              window.location.assign('login');
            </script>
          <% } %>
        </div>
        <div class="col-lg-offset-2 col-md-offset-1"></div>
      </div>
    </div>
  </body>
</html>

Cette page reprend des éléments que nous avons déjà créés, il nous suffit donc d’ajouter une directive CSS pour la balise <input> :

./static/style.css
#input_sujet {
  width: 100%;
  font-size: 20px;
  margin-top: 10px;
  text-align: center;
}

Pour le javascript qui s’exécute côté client, nous allons créer un petit fichier, new.js, qui gère l’événement redirection et la fonction servant à créer une nouvelle discussion :

./static/js/new.js
function publier_message(nom_utilisateur) {
  if (byId('input_sujet').value !== '' &&
    tinyMCE.get('emplacement_nouveau_message').getContent() !== '') {
      socket.emit('nouvelle_discussion', {
        sujet : byId('input_sujet').value,
        message : tinyMCE.get('emplacement_nouveau_message').getContent(),
        nom_utilisateur : nom_utilisateur
      }
    );
  }
}

socket.on('redirection', function(id_discussion) {
  window.location.assign(id_discussion);
});

Il ne reste plus qu’à ajouter un lien vers cette page dans la barre de navigation :

./views/barre_navigation.ejs
<!-- ... -->
<ul class="nav navbar-nav">
    <li><a href="/">Accueil</a></li>
  </ul>
  <% if (nom_utilisateur !== 'session inexistante') { %>
    <form class="navbar-form navbar-left">
      <a href="new" class="btn btn-default">
        <span class="glyphicon glyphicon-pencil"></span>
        Nouvelle discussion
      </a>
    </form>
  <% } %>
<!-- ... -->

Et voilà, désormais, vous pouvez créer des discussions, modifier, supprimer, aimer et envoyer des messages en temps réel sur un forum moderne !