#####
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 :
.. image:: tp5/1.png
Et celle-ci lorsque vous réduisez la largeur de la fenêtre :
.. image:: tp5/2.png
:height: 10000 px
:width: 550 px
:scale: 55 %
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.
.. image:: tp5/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 :
.. image:: tp5/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 :
.. image:: tp5/8.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 :
.. image:: tp5/9.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 :
.. image:: tp5/4.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``.
.. captionup:: ./serveur/routes.js
.. code-block:: javascript
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`` :
.. captionup:: ./views/erreur_discussion.ejs
.. code-block:: guess
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`` :
.. captionup:: ./serveur/sql.js
.. code-block:: javascript
// 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()`` :
.. captionup:: ./serveur/sql.js
.. code-block:: javascript
// 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 :
.. captionup:: ./views/discussion.ejs
.. code-block:: guess
<% include balise_head %>
<% include barre_navigation %>
<% if (nom_utilisateur === 'session inexistante') { %>
Vous n'êtes actuellement pas connecté.
Par conséquent, vous ne pouvez pas aimer ou publier des messages.
Connectez-vous
pour participer à la discussion.
<% } %>
<%= sujet_discussion %>
<% include chargement_messages %>
<% if (nom_utilisateur !== 'session inexistante') { %>
<% include editeur %>
<% } %>
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`` :
.. captionup:: ./views/chargement_messages.ejs
.. code-block:: guess
<% convertir_date = function(date) {
var id_temp = Math.random().toString(36).substring(7); %>
<% }
for (var i = 0; i < m.length; i++) { %>
<% m[i].contenu = m[i].contenu
.replace(/#g_doubles/g, '"')
.replace(/#g_simples/g, "'")
.replace(/#backslash/g, "\\"); %>
<% if (nom_utilisateur === m[i].nom_utilisateur) { %>
<% } else { %>
<% } %>
écrit le <% convertir_date(m[i].date_ecriture); %>
<% if (m[i].date_modification !== '0000-00-00 00:00:00') { %>
modifié le <% convertir_date(m[i].date_modification); %>
<% } %>
<%- m[i].contenu %>
<% if (nom_utilisateur === m[i].nom_utilisateur) { %>
modifier
enregistrer
supprimer
<% } %>
<% } %>
Nous aurons aussi besoin d'un tout petit fichier pour afficher l'éditeur ``TinyMCE`` au bas de la page :
.. captionup:: ./views/editeur.ejs
.. code-block:: guess
Vous pouvez agrandir la fenêtre
Publier
Afin que la page s'affiche correctement, il est également nécessaire d'apporter quelques retouches au fichier ``style.css``. Nous obtenons alors ceci :
.. captionup:: ./static/style.css
.. code-block:: 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 :
.. captionup:: ./static/responsive.css
.. code-block:: 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`` :
.. captionup:: ./serveur/socket.js
.. code-block:: javascript
// 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`` :
.. captionup:: ./static/js/discussion.js
.. code-block:: javascript
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 = '
';
zone_message('affichage', d).innerHTML = unechap(d.contenu);
});
.. tip:: Si l'attribut ``ID`` de la balise ``