Build a serverless chat app with Svelte and Firebase (PART 3)
arnu515
Posted on November 6, 2020
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>
<!-- ... -->
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>
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}
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>
<!-- ... -->
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
}
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>
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)
}
})
})
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>
<!-- ... -->
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
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}
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!
Posted on November 6, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.