Retomamos este proyecto, donde estamos construyendo un chat en tiempo real, donde lo dejamos. Si acabas de aterrizar aquí, puedes ver la parte #1 antes para ubicarte. En ella hablamos de cómo conectar un proyecto a Firebase y cómo crear y añadir JS classes a Firestore.
Configurando la sincronización en tiempo real
Vamos a configurar la tecnología con la que viene Firebase de serie, llamada real time listener. De esta manera estaremos al tanto cada vez que un usuario mande un mensaje, haciendo así que el código cree un nuevo chat object y lo envíe a la collection de Firestore.
PSST! ¿TODO ESTO TE SUENA A CHINO? ?
Recuerda que aquí tienes un tutorial completo sobre Firebase y Cloud Firestore
Para eso, creamos en primer lugar un método dentro de la función addChat(). Será un método normal, no asíncrono, porque no vamos a solicitar cierta información una única vez y esperar la respuesta. En lugar de eso vamos a configurar un real time listener. Lo llamaremos getChats().
1. Aplicamos el método onSnapshot() sobre la referencia a nuestra database (this.chats). Al método onSnapshot() le pasamos una callback function con un parámetro al que llamamos snapshot. Cada vez que recibamos un snapshot (algo así como una imagen del estado actual de nuestra database), queremos averiguar qué es exactamente lo que ha cambiado en nuestra database.
Para eso utilizamos el método docChanges(), que nos da un array con los cambios sucedidos. Iteramos sobre esos cambios y comprobamos si el cambio es de tipo added o removed. Recuerda que cada cambio es un objeto que tiene una propiedad llamada type.
2. En el punto en el que comprobamos si es de tipo added es donde más adelante actualizaremos nuestra UI. Por ahora dejamos puesto un comentario.
getChats() { this.chats .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI } }); }) }
3. A la función getChats le pasamos como parámetro una callback function. Hagamos un ejemplo definiendo esa callback function aplicando el método a chatroom. El objetivo es que esa callback function se dispare cada vez que haya un cambio de tipo added.
getChats(callbackFunc) { this.chats .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI } }); }) } } const chatroom = new Chatroom('music', 'Jim'); // console.log(chatroom); // chatroom.addChat('any rock music fan here?') // .then(() => console.log('chat added!')) // .catch(err => console.log(err)); chatroom.getChats(data => { console.log(data); });
En el if statement llamamos a la callback function usando nuestro parámetro callbackFunc. Como argumento espera algún tipo de dato, así que lo hacemos es pasarle los datos del cambio que sea de tipo added. Para eso obtenemos una referencia al document y le aplicamos el método data() para acceder a los datos de ese documents.
getChats(callbackFunc) { this.chats .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI callbackFunc(change.doc.data()); } }); }) }
Recordemos que la primera vez que nuestra app se cargue, cargará los (en mi caso) tres documents que tengo en Firestore y los clasificará como de tipo added. Por tanto, la callbackFunc se disparará, nada más empezar, tres veces, mostrándonos tres objetos en la consola.
Guarda los cambios y comprueba que efectivamente los tres objetos te aparecen en tu consola.
| Elements Console Sources Performance Network ...
{channel: "music", created_at: uo, message: "any rock music fan here?", username: "Jim"}
{channel: "general", created_at: uo, message: "hi guys", username: "billy"}
4. Vamos a Firestore y añadimos manualmente otro document dentro de nuestra collection. Si guardas y vuelves a tu consola, verás que también aparece. ¡Esa es la magia del real time listener en acción! ?♂️
Ya puedes borrar ese document porque no lo vamos a necesitar.
Queries complejas: cómo filtrar y ordenar datos recibidos
Con la configuración actual, JS nos informa de cada cambio que ocurra en nuestra database, sin importar sobre qué channel se haya hecho dicho cambio (general, tv shows, etc...).
Al crear una instance de la clase Chatroom, como la que tenemos en nuestro código:
sería útil que nuestro real time listener tomara nota de los cambios sucedidos únicamente en el channel que le especifiquemos en la instance de la clase. En este caso, me gustaría que sólo recogiera los cambios sucedidos en el channel "music". Para conseguir eso debemos usar una query compleja (en inglés, complex query).
Esta complex query es en este caso un método llamado where(). Este método nos permite obtener documents de una collection cuando cierta condición se cumpla. Es decir, nos permite filtrar información para sólo obtener la que elijamos. Acepta tres parámetros:
1º El nombre de la propiedad que queremos usar de filtro
2º El operador == . Firestore utiliza double equals en lugar de triple equals.
3º El valor de la propiedad que le pasemos a una instance de la clase.
Veámoslo en la práctica. ??
1. Aplicamos el método where() a nuestra collection (this.chats), antes del método snapshot(), y le pasamos los tres parámetros indicados arriba.
getChats(callbackFunc) { this.chats .where('channel', '==', this.channel) .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI callbackFunc(change.doc.data()); } }); }) }
Traducido a lenguaje humano, el método where() está diciéndole a JS: Busca una propiedad cuyo valor sea igual al que el usuario elija. Cuando la encuentres, aplícale el método onSnapshot solo a los documents que tengan ese mismo valor elegido por el usuario.
Para mi caso, si ahora guardo y voy a mi consola, sólo podré ver este objeto:
{channel: "music", created_at: uo, message: "any rock music fan here?", username: "Jim"}
Podemos probar a cambiar el channel de la instance, por ejemplo por general, y verás que se imprimen únicamente dos objetos relacionados con ese canal. ¡Genial! ?
Lo siguiente que vamos a hacer es ordenar los datos que recibimos de Firestore cronológicamente, de manera que se muestre siempre el document más reciente primero. Firestore no ordena los documents de ninguna manera en particular. ?
Para ordenar los datos cronológicamente vamos a echar mano de la propiedad created_at. Lo haremos a continuación del método where(), porque primero filtramos qué documents queremos recibir para posteriormente ordenarlos usando el método orderBy(). Este método acepta un parámetro de tipo string con el nombre de la propiedad que queremos usar para ordenar nuestros datos.
En nuestro caso sería la propiedad created_at.
Si únicamente añadimos el método y su parámetro, guarda y verás en tu consola que tienes un error.
getChats(callbackFunc) { this.chats .where('channel', '==', this.channel) .orderBy('created_at') .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI callbackFunc(change.doc.data()); } }); }) }
Por su suerte, en la consola Firestore nos deja un mensaje de error muy útil con un link para solucionar el asunto.
1. Seguimos el link y creamos el índice.
Tarda unos minutillos en crearse. Pero una vez el status cambia a "habilitado" (o enabled), si vas a tu consola, ya deberías ver los dos documents, esta ver ordenados por fecha. ¡Chachi ?! Cierto es que en mi caso aún no se verá la diferencia, porque los dos documentos del canal general fueron creados con la misma fecha en Firebase ?. Pero puedes probar a añadir un document nuevo, y verás que se imprime el último en tu consola.
Cómo actualizar el username
1. Creamos otro método al final de la clase Chatroom y lo llamamos updateUsername(), que tomará como parámetro un nuevo username que le pasemos.
2. Actualizamos el valor de this.username, dándole el del parámetro.
Retomaremos este punto más adelante, mejorándolo con local storage.
updateUsername(username) { this.username = username; }
Cómo actualizar el channel
Esta es una primera fase del proceso de actualización de un canal. En este momento sólo vamos a configurar el comportamiento de los canales para que un usuario pueda actualizarlos, pero aún no veremos nada en nuestra UI. Eso será algo que haremos en una fase posterior.
1. Creamos un método al final de la clase Chatroom llamado updateChannel(), que recibirá por parámetro una string consistente en el nombre de alguno de nuestros canales (general, foodies, TV shows, music).
2. Actualizamos el valor de la propiedad this.channel, dándole el del parámetro.
3. Hacemos un console.log con un mensaje para asegurarnos de que el canal se ha actualizado.
updateChannel(channel) { this.channel = channel; console.log('channel updated'); }
4. Utilizamos nuestra instance para aplicarle este método recién creado. Le pasamos un nuevo canal, por ejemplo, music. Si guardas, verás en tu consola el mensaje que hemos definido arriba.
chatroom.updateChannel('music');
Pero lo único que está haciendo es sustituir el nombre del canal por el que le hemos pasado como argumento. Lo que ocurre es el real time listener todavía está programado para escuchar los cambios que sucedan en el canal inicial que le hemos pasado (la primera vez que hemos utilizado una instance de la clase, aquí):
const chatroom = new Chatroom('general', 'Jim');
Así que lo debemos hacer es desactivar la sincronización, lo en inglés se conoce como unsubscribe from changes.
Si has leído la guía sobre Firebase, sabrás que esta parte:
this.chats .where('channel', '==', this.channel) .orderBy('created_at') .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI callbackFunc(change.doc.data()); } }); })
devuelve una función, concretamente la función unsubscribe. Vamos a guardarla en una propiedad de la clase, dentro del constructor. Inicialmente sólo la declaramos, no dándole ningún valor.
constructor(channel, username) { this.channel = channel; this.username = username; this.chats = db.collection('chats'); this.unsubscribe;
Y ahora sí, le damos el valor de la función unsubscribe.
getChats(callbackFunc) { this.unsubscribe = this.chats .where('channel', '==', this.channel) .orderBy('created_at') .onSnapshot(snapshot => { snapshot.docChanges().forEach(change => { if (change.type === 'added') { // update UI callbackFunc(change.doc.data()); } }); }) }
Con esto hemos configurado un real time listener que se mantendrá activo hasta que invoquemos la función unsubscribe(), que lo desactivará.
5. El lugar para desactivarlo es dentro de la función updateChannel(), porque no queremos obtener los cambios relativos al canal inicial. Pero necesitamos comprobar si la propiedad this.unsubscribe tiene un valor establecido o todavía no (recuerda que lo establecemos dentro del método getChats().
Sin embargo, esto nos deja en una situación en la que hemos dejado de escuchar los cambios sucedidos en el nuevo canal (music). Pero eso no es un problema, porque no necesitamos escuchar los cambios dentro del método updateChannel().
updateChannel(channel) { this.channel = channel; console.log('channel updated'); if (this.unsubscribe) { this.unsubscribe(); } }
6. Establecemos un nuevo real time listener sobre el canal actual.
chatroom.updateChannel('music'); chatroom.getChats(data => { console.log(data); });
En lugar de dejarlo ahí suelto, vamos incluirlo (junto con la actualización del nuevo canal a music), dentro de una función setTimeout(), para simular que el usuario ha cambiado de canal. Le damos un tiempo de 3 segundos para que se dispare. ?
7. Añadimos un nuevo mensaje a la instance, usando el método addChat().
8. Usamos el método updateUsername() sobre la instance para actualizar el username y darle el que queramos.
setTimeout(() => { chatroom.updateChannel('music'); chatroom.updateUsername('Dustin'); chatroom.getChats(data => { console.log(data); }); chatroom.addChat('hellooooo'); }, 3000);
Guarda y presta atención a tu consola. A priori deberías ver:
| Elements Console Sources Performance Network ...
{channel: "general", created_at: uo, message: "hi guys", username: "billy"}
Pero a los tres segundos, debería aparecer:
channel updated
{channel: "music", created_at: uo, message: "hellooooo", username: "Dustin"}
{channel: "music", created_at: uo, message: "any rock music fan here?", username: "Jim"}
¡Genial! ? Con esto hemos conseguido:
Y con eso prácticamente cerramos la configuración de la clase Chatroom. ?
Creación de la clase ChatUI
Llega el momento de crear la segunda clase de este proyecto. Recuerda que esta clase será la encargada de coger todos los datos que hemos obtenido a través de la clase Chatroom y mostrarlos en nuestra página (en nuestra UI, vaya).
Esto lo haremos usando HTML templates que posteriormente mostraremos en el DOM.
1. No vamos a usar más el código del setTimeout, así que podemos borrarlo.
//setTimeout(() => { // chatroom.updateChannel('music'); // chatroom.updateUsername('Dustin'); // chatroom.getChats(data => { // console.log(data); // }); // chatroom.addChat('hellooooo'); // }, 3000);
2. Cortamos este bloque de código donde creábamos una instance de la clase y obteníamos datos:
const chatroom = new Chatroom('general', 'Jim'); chatroom.getChats(data => { console.log(data); });
y lo pegamos en el archivo app.js. En el app.js será donde creamos instances de ambas clases, Chatroom y ChatUI.
3. Comentamos los diferentes bloques de código para que quede clara la estructura.
// class instances const chatroom = new Chatroom('general', 'Jim'); // get chats and render to the DOM chatroom.getChats(data => { console.log(data); });
Por ahora lo único que estamos haciendo es obtener los datos de nuestra database e imprimirlos. En lugar del console.log, mostraremos esos datos en el DOM. Ahí es donde entra el juego la clase ChatUI, que va dentro del archivo ui.js.
Más concretamente, la clase ChatUI será responsable de:
- mostrar HTML templates en el DOM
- limpiar la lista de chats cuando cambiemos de canal
4. Creamos el esqueleto de la clase con su constructor, que acepta un parámetro que será una list. En esa lista será donde al final mostraremos los chats, y no es más que esta <ul> del index.html:
Al constructor le añadimos la propiedad list, que será igual al parámetro list.
class ChatUI { constructor(list) { this.list = list; } }
5. Volvemos a app.js y obtenemos una referencia de la <ul>.
6. Creamos una instance de la clase ChatUI y le pasamos la lista como argumento.
// DOM queries const chatList = document.querySelector('.chat-list'); // class instances const chatUI = new ChatUI(chatList);
Con esto ya tenemos una referencia que podemos usar dentro de la clase en el ui.js para mostrar datos. ?
7. Creamos un método en la clase para mostrar datos en la UI. Lo llamamos render(). Aquí es donde crearemos una HTML template por cada document (un chat object) que recibamos desde la base de datos. Por eso, como parámetro espera esos datos (un chat object).
Guardamos la HTML template en una variable llamada html. La template consistirá en un <li> con dos <span> para el username y el message y un <div> para la fecha.
Utilizamos algunas clases de bootstrap. ?
Como la propiedad created_at es un timestamp, le damos un poco de formato con el método toDate().
Para mostrar la template en la UI, aplicamos la propiedad innerHTML sobre la lista y le adjuntamos la template ahí.
8. Volvemos a app.js y llamamos al método render() cuando obtenemos chats nuevos. Así que ya podemos borrar el console.log.
chatroom.getChats(data => { // console.log(data); chatUI.render(data); });
Si quieres puedes simplificar el código:
chatroom.getChats(data => chatUI.render(data));
¡Y voilà! Ya se ven los datos de la database en nuestra web. ?
Cómo dar formato a las fechas
La verdad es que el aspecto que tiene la fecha no es muy atractivo. Vamos a cambiar eso usando la librería Date-dns. Como explico en ese artículo al que enlazo, las últimas versiones de Date-dns deben usarse con npm o yarn. Pero como eso queda fuera del ámbito de este post, vamos a utilizar la versión que aún se puede usar con un CDN.
1. Copiamos el CDN en nuestro index.html, encima de los scripts de Firebase. Al ser el primer script, podremos usarlo en todos los demás.
<script src="https://cdnjs.cloudflare.com/ajax/libs/date-fns/1.30.1/date_fns.js"></script>
El lugar para darle formato a las fechas es dentro del método render() de la clase ChatUI.
Actualmente mostramos la fecha aquí:
<div class="time">${data.created_at.toDate()}</div>
Pero eso va a cambiar.
2. Creamos una variable llamada when y le damos el valor del objeto dateFns, al que le aplicamos el método distanceInWordsToNow(). Este método nos da una fecha relativa, por ejemplo, "hace dos días", "hace 10 minutos". Coge la fecha de creación y nos dice el tiempo que ha pasado, en ese formato de frase.
El método acepta dos parámetros:
- un date object
- un objeto (opcional)
Nosotros vamos a usar ambos parámetros.
Definitivamente, este formato tiene mucha mejor pinta. ?
Retoques de CSS
Vamos a añadirle un poco de CSS para mejorar aún más el aspecto. Esto lo hacemos en el styles.css.
.username { font-weight: bold; } .time { font-size: 0.7em; color: #999; }
¡Ouh yeah! ¡Esto ya es otra cosa! ? ?
THE END!
¡Y hasta aquí la parte #2 de este proyecto! Espero que hayas aprendido algo nuevo ?. Si te queda alguna duda, ¡nos vemos en los comentarios! Si quieres seguir aprendiendo, aquí tienes la parte #3.
Sobre la autora de este post
Soy Rocío, una abogada reconvertida en programadora. Soy una apasionada de aprender cosas nuevas y ferviente defensora de que la única manera de ser feliz es alcanzando un equilibrio entre lo que te encanta hacer y lo que te saque de pobre. Mi historia completa, aquí.
Otros artículos que pueden interesarte