Cómo construir un chat sincronizado en tiempo real – Proyecto – Parte 2

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 snapshotCada 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 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: "general", created_at: uo, message: "hey there!", username: "max"}
{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:

const chatroom = new Chatroom('music', 'Jim');

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.

crear indice firebase

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: "hey there!", username: "max"}
{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: 

1º Imprimir sólo los mensajes pertenecientes al canal general.
2º Dejar de escuchar los cambios que suceden en general.
Cambiar al canal music.
 Imprimir sólo los mensajes pertenecientes al canal music (teníamos uno en la database más otro que hemos generado en el código al actualizar el canal de general music).

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 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:

            <ul class="chat-list list-group"></ul>

Al constructor le añadimos la propiedad listque 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 htmlLa 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 timestample 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í. 

    render(data) {
        const html = `
            <li class="list-group-item">
                <span class="username">${data.username}</span>
                <span class="message">${data.message}</span>
                <div class="time">${data.created_at.toDate()}</div>
            </li>
        `;
        this.list.innerHTML += html;
    }

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. ?

datos de firebase en el DOM

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 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.

        const when = dateFns.distanceInWordsToNow(
            data.created_at.toDate(),
            { addSuffix: true }
        );
        const html = `
            <li class="list-group-item">
                <span class="username">${data.username}</span>
                <span class="message">${data.message}</span>
                <div class="time">${when}</div>
            </li>
        `;

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! ? ? 

live chat screenshot

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

Cómo aprendí a programar cuando estaba «programada» para ser de letras
[tcb-script src="https://player.vimeo.com/api/player.js"][/tcb-script]A nadie le gusta su trabajo. Eso es lo que me decía a mí misma cuando conseguí mi primer[...]
Días del 160 al 203 – ¡Primer objetivo conseguido!
“A veces podemos pasarnos años sin vivir en absoluto, y de pronto toda nuestra vida se concentra en un solo[...]
Claves para entender Angular. Qué es y cómo se utiliza
Angular es un framework creado por Google que nos permite construir Single Page Applications (SPA, por sus siglas en inglés).Frameworks¿Pero qué es[...]
Si crees que este post puede serle útil a alguien, por favor, ¡compártelo!:

Deja un comentario

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.

Esta web utiliza cookies para asegurar que se da la mejor experiencia al usuario. Si continúas utilizando este sitio se asume que estás de acuerdo. más información

Los ajustes de cookies en esta web están configurados para «permitir las cookies» y ofrecerte la mejor experiencia de navegación posible. Si sigues usando esta web sin cambiar tus ajustes de cookies o haces clic en «Aceptar», estarás dando tu consentimiento a esto.

Cerrar