I recently launched Ninjobu, an anonymous job search platform for software engineers. The premise is, as a programmer, you create a profile with your previous experience, programming languages used, domain knowledge, etc., as well as what role you are looking for, preferred location (or remote), and desired salary. Next, you sit back and wait while recruiters search the list of profiles to find matching candidates for their open roles. Once a recruiter has found a potentially suitable candidate, they make contact and start the hiring process, which brings us to the chat system.
Ninjobu only needs a way for recruiters to initiate a 1-on-1 chat with candidates. There's no need for groups, file sharing, emojis, gifs, or similar features. Although you can send emojis just fine, there's no UI for selecting them, and it's more of a happy accident due to how Firestore supports Unicode rather than something I specifically implemented. The lack of these additional features simplifies the chat system's requirements somewhat, but we code what we need to code and nothing else, right?
I went with a design loosely inspired by the Firebase pricing example. The primary data consists of a collection of chats, where each chat has a subcollection of messages.
There are two members in every chat because Ninjobu only needs 1-on-1 communication. It's trivial to extend this to groups with additional members. Still, since I don't anticipate the need for multi-member discussions in this project, parts of the code occasionally assume only two chat members exist.
members array then contains two entries: a unique user id for each chat participant. With these UIDs, we can find the chats for a specific user and block everyone else from accessing the data. Ninjobu uses Firebase Auth to authenticate users, and I use the UID from the Auth module to identify each user in the chat data.
names array holds the name of the chat members, and the
desc array holds a secondary string for each user. For the recruiter, this is the company name, and for the candidate, it's their preferred job title. Storing these strings in the chat document allows us to give each chat a bit of context in the user's inbox without querying any additional data.
Ninjobu aims to keep its candidate users anonymous. Candidates aren't even asked for their names when they sign up. When communicating with a candidate, their name shows up as candidate#xyz while the recruiter's name and company are visible as expected.
Lastly, we have a string with the last message sent to this chat and the timestamp when this happened. With this data in the document, we can query a list of chats and display them similar to how an email application might show the user a list of emails. When a user selects a chat, we pull the subcollection of messages as needed.
The message data is straightforward. Each document stores the author UID, the timestamp for the message, and the string itself. The UID is used to check the author's index in the parent chat's
members array, and that index maps to the author name and secondary string in the
desc arrays, respectively. You could store the member's index instead of the UID here, but using the UID simplified some of the front-end code in my case.
Firebase calculates the cost based on the number of documents read, deleted, and updated, in addition to network bandwidth usage and storage. With this setup, we duplicate some data, such as the last message sent, but it allows us to request fewer documents overall. The flipside to this is that when we post a new chat message, we need to create a new document in the message subcollection and update the parent chat document with the last message string and timestamp. I still think this is acceptable. Presumably, there will be much more document reads from users logging in and viewing their inbox than document writes from users sending new messages.
Firestore data is encrypted when saved to the hard drives, and Ninjobu redirects any unencrypted requests to go over HTTPS, which means our data should stay safe in transit and at rest. But we still need to make sure chats are only visible to their participants. We do this with Firebase's excellent security rule system.
Firestore integrates with Firebase Auth to make our life easy. Since the chat documents have a
members array with each chat participant's UID, we can write a security rule that gives access only to chat participants.
For unauthenticated requests, accessing the
request.auth.uid variable will be invalid, and the rule will fail. Otherwise, we allow access to chat documents containing the requester's UID in the
Unfortunately, there's a tiny glitch with this setup. When we first create a document, the resource doesn't exist, and we have no members array to check, so the rule fails. We need to treat the creation of the document as a special case, which we can do.
With the updated rule, we allow reads and updates to a chat document when the requester's UID exists in the
members array already recorded in the database. However, when the document gets created, we expect the request to contain a two-entry array with the requester's UID present.
For nested documents, we need independent rules because each rule applies only to the specified path. The chat rule will have no effect on the subcollection for messages. However, we can still use the
members array in the parent chat document when securing message documents.
get() function allows us to read any document in the database. In the above rule, we access the parent chat and verify that the user is a participant - keeping in mind that document access in a security rule counts towards billing as a regular document read made manually.
You can fetch data from the database at any time you like, but since we're trying to build a chat system, we want to see updates happen as soon as possible. If you hang out on your Inbox page, and someone sends you a message, you want to see it right away without having to do a page reload.
Firestore provides a way to register a listener on a particular query and then calls it with snapshots every time the data affected by that query is modified. It's incredibly convenient as data updates happen in real-time without any additional work to synchronize state between users. This way, the chat behaves more like a chat and less like a laggy email.
There's one caveat I'd like to mention so that you don't have to spend the time debugging as I did if you were to use a similar implementation as mine. If you build your UI around snapshot listeners, one drawback tends to be the high latency caused by the roundtrip to the database server. When you send a message, it gets sent to the server, the database gets updated, and only then does the snapshot callback trigger with the update. This update time is adequate for a chat experience, but it will be way too sluggish as far as UI responsiveness goes. Luckily, the folks at Firebase provide an excellent latency compensation solution.
With Firestore, a database change will first be applied locally, then sent to the server. The snapshot listener will trigger before the server has recorded the change, allowing the UI to update quickly. You can check for this in your callback, if you'd like, by looking at the snapshot metadata for the hasPendingWrites flag. The critical thing to realize is that the local change doesn't communicate with the server and has no way of running the security rules. This isn't a problem if you design the code well, but I didn't, so I had a problem.
When a recruiter on Ninjobu first sent a message to a candidate, it would create a new chat document and a subcollection with that first message. Then, in a snapshot listener for chat documents, it detected the new chat and immediately read its subcollection of messages to display on the page. If you remember our security rules earlier, we first look at the parent chat document and verify that the user is a member of that chat. In this case, the rule would fail because we queried the collection of messages for a chat document that had only been created locally and wasn't yet recorded in the database server. The solution was simple: display the message locally immediately, but don't query the subcollection from the database until the chat document write finished successfully.
I wanted to add a visual cue for when the user has new messages. I decided to go with a tiny blip next to the Inbox header to indicate unread messages and mark each chat the user hasn't yet read. For this to work, I needed to add some additional data to the chat document.
I added a new two-entry array,
lastSeenTime, with a timestamp for each chat participant that indicates when they last read the chat. To detect a new message now is simple: we compare the last message timestamp with the new last seen timestamp for a member. When a user opens a chat and retrieves all messages, or a new message is received while the chat is open, we update the last seen timestamp. We can also use these fields to show when the recipient has seen a message, but I'm not too fond of that aspect of chat software, so I didn't add it to Ninjobu.
Firebase makes it incredibly easy to build a chat component for your web app. The design in this post may not be the best way to implement such a system, and perhaps it's not even very efficient. Nevertheless, it has worked well for Ninjobu, and I believe it will scale well enough for this platform's needs. If I'm wrong, there will probably be another post with the improvements made.
There is one thing missing from this chat system that I'd like to build next. When a user receives a message, they have to log in to find out about it. Ideally, we send an email notification to let them know there's a new message waiting for them. Once I build this, perhaps that will be the subject of the next blog post.