DEV Community

Cover image for Build a serverless chat app with Svelte and Firebase (PART 3)
arnu515
arnu515

Posted on

Build a serverless chat app with Svelte and Firebase (PART 3)

In the last two parts, we set up the app and configured authentication. Now, let's get to the juicy stuff, Chatting!

We'll be using Firebase Firestore as our database. Firestore is a NoSQL Document database, like MongoDB. It also has a really cool feature of listening to database changes, which allows us to make this chat app without using stuff like socket.io.

We created a messages collection in Firestore. This is where we'll be storing our messages. So let's get started!

Be sure to check the first two parts out before continuing with this one! All code is available on Github

The chat route

Let's create a /chat route that will only work if the user is authenticated.

But first, we have to make sure that our Auth component doesn't allow the user to authenticate if they are already authenticated. This simple line of code will do:

<!-- src/components/Auth.svelte -->

<script lang="ts">
    // ...

    auth.onAuthStateChanged(user => {
        isAuthenticated = !!user;
        if (user) d("auth")
    })

    // ...
</script>

<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

The auth.onAuthStateChanged() function is called by Firebase everytime the user's auth state changes, i.e. whenever they login and logout.

Let's move on to our /chat route.

<!-- src/routes/chat.svelte -->

<script lang="ts">
    import { auth } from "../services/firebase";
    import router from "page";

    interface User {
        email: string, photoURL: string, uid: string
    }

    let user: User | null;

    auth.onAuthStateChanged(u => user = u);

    $: {
        // user === null is explicitly called instead of !user because we need firebase to decide what the user is, and not us, so we dont initialise user up there.
        if (user === null) router.redirect("/auth?action=login&next=%2Fchat");
    }
</script>
Enter fullscreen mode Exit fullscreen mode

The funny little $: block you're seeing there is not vanilla javascript. It is svelte magic. The code in that block is called whenever any dependencies (i.e. variables initialised outside that block) change. It's like the useEffect hook in React.

Now, let's do the UI:

<!-- src/routes/chat.svelte -->

<!-- ... -->

{#if typeof user === "undefined"}
    <p class="w3-center w3-section"><i class="fas fa-spinner w3-spin fa-3x"></i> Loading</p>
{:else}
    {#if user}
        <h1 class="w3-jumbo w3-center">Serverless chat</h1>
        <p class="w3-center">Chatroom</p>
        <p class="w3-center"><button class="w3-button w3-blue" on:click={logout}>Logout</button></p>

        <br>
        <div class="w3-container w3-border w3-border-gray" style="margin: 0 auto; width: 60%; height: 600px; overflow-y: auto;">
            <br>
            {#if messages.length > 0}
                {#each messages as m}
                    <Chat {...m} self={user.uid === m.uid} />
                {/each}
            {:else}
                <p class="w3-center w3-text-gray">Looks like nobody's sent a message. Be the first!</p>
            {/if}
            <!-- Dummy element used to scroll chat -->
            <br id="scroll-to">
        </div>
        <input on:keydown={messageSubmit} type="text" style="margin: 0 auto; width: 60%; margin-top: -1px" placeholder={cooldown ? "3 second cooldown" : "Enter message and press enter"} class="w3-input w3-border w3-border-gray {cooldown && "w3-pale-red"}" id="message-input">
        <br>
    {:else}
        <p class="w3-center w3-section">Not logged in!</p>
    {/if}
{/if}
Enter fullscreen mode Exit fullscreen mode

Now, we'll update the javascript inside the svelte component. We'll create all the variables referenced in the HTML.

<!-- src/routes/chat.svelte -->

<script lang="ts">
    import {auth} from "../services/firebase"
    import router from "page";

    interface User {
        email: string, photoURL: string, uid: string
    }

    // new
    interface Message extends User {
        message: string, createdAt: number
    }

    let user: User | null;
    // new
    let messages: Message[] = [];
    let cooldown = false;

    auth.onAuthStateChanged(u => user = u);

    $: {
        if (user === null) router.redirect("/auth?action=login&next=%2Fchat");
    }

    // new
    function messageSubmit(e: KeyboardEvent & {
        currentTarget: EventTarget & HTMLInputElement;
    }) {
    }

    // new
    function logout() {
        if (auth.currentUser) {
            auth.signOut().then(() => {}).catch(e => {
                throw new Error(e)
            });
        }
    }
</script>

<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Now, all but one of the squiggly lines in your code should disappear.

How a chat message will look like

No, I'm not talking about visual looks, but I'm talking about how a message will be structured in our database. This is the thing im going for:

{
   // the main content of the message
   message: string,
   // the id of the user who posted the message
   uid: string,
   // the email of the user who posted the message
   email: string,
   // the avatar of the user who posted the message (URL)
   photoURL: string,
   // the timestamp when the message was created
   createdAt: number
}
Enter fullscreen mode Exit fullscreen mode

Chat component

Let's make a component that will render each of the chat messages:

<!-- src/components/Chat.svelte -->
<script lang="ts">
    import md5 from "md5";

    export let photoURL: string;
    export let createdAt: number;
    export let email: string;
    export let message: string;
    export let uid: string;
    // if the message was made by the current user
    export let self = false;
</script>

<div title={`${email} (${uid}) at ${new Date(createdAt)}`} style="display: flex; margin-bottom: 0.5rem; {self && "flex-direction: row-reverse; "}align-items: center;">
    <img src={photoURL || `https://www.gravatar.com/avatar/${md5(email)}?d=mp&s=32&r=g`} style="width: 32px; height: 32px;" class="w3-circle" alt="avatar">
    <span class="{self ? "w3-blue" : "w3-light-gray"} w3-padding w3-round-xxlarge" style="margin: 0 6px">{message}</span>
</div>
Enter fullscreen mode Exit fullscreen mode

Notice in the above code, we're using Gravatar to give us the user's avatar if it doesn't exist.

Syncing with the database in REAL TIME

Now, let's do the juicy part! Let's sync our app to firestore. It is WAY easier than you think. Here's the code we need:

db.collection("messages").onSnapshot((snapshot) => {
        snapshot.docChanges().forEach(change => {
            if (change.type === "added") {
                messages = [...messages, change.doc.data() as Message]
                setTimeout(() => {if (document.getElementById("scroll-to")) document.getElementById("scroll-to").scrollIntoView({behavior: "smooth"});}, 500)
            }
        })
    })

Enter fullscreen mode Exit fullscreen mode

Let's implement this in our app!

<!-- src/routes/chat.svelte -->

<script lang="ts">
    import {auth, db} from "../services/firebase";
    import router from "page";
    import Chat from "../components/Chat.svelte";
    import {onDestroy} from "svelte";

    // ...

    const unsubscribe = db.collection("messages").onSnapshot((snapshot) => {
        snapshot.docChanges().forEach(change => {
            if (change.type === "added") {
                messages = [...messages, change.doc.data() as Message]
                setTimeout(() => {if (document.getElementById("scroll-to")) document.getElementById("scroll-to").scrollIntoView({behavior: "smooth"});}, 500)
            }
        })
    })

    // calling the unsubscribe() method when the component gets destroyed to prevent listening to changes when not needed. Also, bandwith gets saved.
    onDestroy(unsubscribe);
</script>

<!-- ... -->
Enter fullscreen mode Exit fullscreen mode

Lost track? The Github can help

Add chat messages

Let's finish this off by adding functionality to add chat messages. It's very simple. All you have to do, is modify the messageSubmit() event handler to add messages in the database. Along with that, I also decided to add functionality to censor bad words using the bad-words npm package.

npm install bad-words
Enter fullscreen mode Exit fullscreen mode

And here's what our code finally looks like:

<!-- src/routes/chat.svelte -->

<script lang="ts">
    import {auth, db} from "../services/firebase"
    import router from "page";
    import { onDestroy } from "svelte";
    import Chat from "../components/Chat.svelte";
    import Filter from "bad-words";

    interface User {
        email: string, photoURL: string, uid: string
    }

    interface Message extends User {
        message: string, createdAt: number
    }

    let user: User | null;
    let messages: Message[] = [];
    let cooldown = false;

    auth.onAuthStateChanged(u => user = u);

    $: {
        if (user === null) router.redirect("/auth?action=login&next=%2Fchat");
    }

    const unsubscribe = db.collection("messages").onSnapshot((snapshot) => {
        snapshot.docChanges().forEach(change => {
            if (change.type === "added") {
                messages = [...messages, change.doc.data() as Message]
                setTimeout(() => {if (document.getElementById("scroll-to")) document.getElementById("scroll-to").scrollIntoView({behavior: "smooth"});}, 500)
            }
        })
    })

    function messageSubmit(e: KeyboardEvent & {
        currentTarget: EventTarget & HTMLInputElement;
    }) {
        if (e.key.toLowerCase() !== "enter") return;
        if (cooldown) return;
        const message = (new Filter()).clean(((document.getElementById("message-input") as HTMLInputElement).value || "").trim());
        if (!message) return;

        (document.getElementById("message-input") as HTMLInputElement).value = ""
        cooldown = true;
        setTimeout(() => cooldown = false, 3000)

        db.collection("messages").add({
            message,
            email: user.email,
            photoURL: user.photoURL,
            uid: user.uid,
            createdAt: Date.now()
        })
    }

    onDestroy(unsubscribe)

    function logout() {
        if (auth.currentUser) {
            auth.signOut().then(() => {}).catch(e => {
                throw new Error(e)
            });
        }
    }
</script>

{#if typeof user === "undefined"}
    <p class="w3-center w3-section"><i class="fas fa-spinner w3-spin fa-3x"></i> Loading</p>
{:else}
    {#if user}
        <h1 class="w3-jumbo w3-center">Serverless chat</h1>
        <p class="w3-center">Chatroom</p>
        <p class="w3-center"><button class="w3-button w3-blue" on:click={logout}>Logout</button></p>

        <br>
        <div class="w3-container w3-border w3-border-gray" style="margin: 0 auto; width: 60%; height: 600px; overflow-y: auto;">
            <br>
            {#if messages.length > 0}
                {#each messages as m}
                    <Chat {...m} self={user.uid === m.uid} />
                {/each}
            {:else}
                <p class="w3-center w3-text-gray">Looks like nobody's sent a message. Be the first!</p>
            {/if}
            <!-- Dummy element used to scroll chat -->
            <br id="scroll-to">
        </div>
        <input on:keydown={messageSubmit} type="text" style="margin: 0 auto; width: 60%; margin-top: -1px" placeholder={cooldown ? "3 second cooldown" : "Enter message and press enter"} class="w3-input w3-border w3-border-gray {cooldown && "w3-pale-red"}" id="message-input">
        <br>
    {:else}
        <p class="w3-center w3-section">Not logged in!</p>
    {/if}
{/if}
Enter fullscreen mode Exit fullscreen mode

Test the app

Yay! We're done. Feel free to mess around and send chat messages to your friend. Try using two browser tabs (with different accounts, ofcourse) and see how the chat updates real-time!

That's all for this part! There is one final part, however, and that will teach you how to deploy your code. And here's the link!

Top comments (0)