Actually storing our data

Adding the back-end

Now it's going to get a bit more complicated, stay with me because it's gonna be worth it in the end! Let's add our list of contacts to the mix. Create a new file in your project and name it src/contacts.js. Put the following contents into this file:

import pages from './pagecomponent.js';
import chat_page from './chat.js';
import add_contact_page from './addcontact.js';

var md = require('./modules/md.coffee');

// The page itself
var contacts_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

pages.addPage(contacts_page);

// This container provides scrolling functionality and will hold our contacts
var contacts_container = new ScrollComponent({
  y: 80,
  height: contacts_page.height - 50,
  width: contacts_page.width,
  backgroundColor: "rgb(240, 240, 240)",
  scrollHorizontal: false,
  parent: contacts_page
});

// A button to add a new contact
var add_contact_button = new md.ActionButton({
  parent: contacts_page,
  action: function() {
    pages.snapToPage(add_contact_page);
  }
});

// The contact entry for our favorite chatbot
var contact_eliza = new md.RowItem({
  text: "Eliza",
  iconText: "E",
  superLayer: contacts_container.content
});

// Click event for when clicking on Eliza's contact
contact_eliza.on(Events.Click, function() {
  pages.snapToPage(chat_page);
});

// The bar at the top, containing a title and a button for navigation
var header = new md.Header({
  title: 'My contacts',
  parent: contacts_page
});

export default contacts_page;

While most of the code above will make sense, there are a couple of new things. First, we add an ActionButton, which is a big round button with a + sign on it, positioned in the bottom right of its parent Layer. We'll use this to be able to add new contacts, and as can see we already added the Event for that, even though we still have to create this "add contact" page.

Second, there is another new object, the RowItem, which we're adding to the ScrollComponent we've created. This works a lot like the speech bubbles on the chat window we created before, but these RowItems look slightly differently: they contain a rounded icon in the front, with text next to it. By default, the RowItem in the module we use only allows us to put images in this circle, but I've extended it so that it also accepts icons and text. It uses whichever property you supply: icon, image or iconText. For now we will stick to text, and we will show the initials of each contact — so for our bot Eliza, that will be "E". You can also imagine using a contact's profile picture as an image, or an icon representing a user's status, for example. For an overview of all the icons included in Material design, see here (although not all of them seem to work, for example person didn't work for me). We also add a click Event to the RowItem for Eliza, so that we are taken to the chat window.

Let's also update our chat.js page, for now simply putting "Eliza" as the title and adding an icon at the top to navigate back to the contact list. First, import the contacts_page at the top so we can navigate back to it:

import contacts_page from './contacts.js';

And then add the title and navigation by updating the Header:

// The bar at the top, containing a title and a button for navigation
var header = new md.Header({
  icon: 'arrow-left',
  title: 'Eliza',
  action: function() {
      pages.snapToPage(contacts_page);
  },
  parent: chat_page
});

We'll be extending this code later so that we can set the name of our conversational partner as the title. Finally, let's make our "add a new contact" page and then we can start creating and storing real data. Create a new file called src/addcontact.js and put the following code:

import pages from './pagecomponent.js';
import contacts_page from './contacts.js';

var md = require('./modules/md.coffee');

// The page itself
var add_contact_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

pages.addPage(add_contact_page);

// Input field for the e-mail address of a contact we want to find
var input_email = new md.TextField({
  x: 20,
  y: 150,
  width: add_contact_page.width * .7,
  labelText: "E-mail of your contact",
  parent: add_contact_page
});

input_email.centerX();

// The button to add the contact to our list
var button_add_contact = new md.Button({
  x: Align.center,
  y: 225,
  width: 200,
  text: "Add contact",
  parent: add_contact_page
});

button_add_contact.centerX();

// The bar at the top, containing a title and a button for navigation
var header = new md.Header({
  icon: 'arrow-left',
  title: 'Add new contact',
  action: function() {
      pages.snapToPage(contacts_page);
  },
  parent: add_contact_page
});

export default add_contact_page;

To show the list of contacts by default, in login.js add the following import:

import contacts_page from './contacts.js';

And change the click event on the login button so that it directs to the contact list instead of the chat window:

// Add the click event to the "login" button
button_login.on(Events.Click, function(event, layer) {

    // Use Firebase to log in to an existing account, based on our input fields
  firebase.auth().signInWithEmailAndPassword(
    input_email.value, input_password.value).then(function(usercredential) {
      // If it succeeded, go to the chat page
      pages.snapToPage(contacts_page);
    }).catch(function(error) {
        // If an error was received, show it in a Toast
        var toast = new md.Toast({
          title: error.message,
          parent: login_page
        });
    });

});

We should do the same for our registration screen in register.js, first adding the import at the top:

import contacts_page from './contacts.js';

...and then the updated transition after pressing the button:

button_create_account.on(Events.Click, function(event, layer) {
  // Use Firebase to create a new account, based on our input fields
  firebase.auth().createUserWithEmailAndPassword(
    input_email.value, input_password.value).then(function(usercredential) {
      // If it succeeded, go to the chat page
      pages.snapToPage(contacts_page);
    }).catch(function(error) {
      // If an error was received, show it in a Toast
      var toast = new md.Toast({
        title: error.message,
        parent: register_page
      });
    });
});

Now we can test our navigation to see if everything is still working:

The current flow between the pages of our app

If it's not working out so far, let me know in the comments below this post and have a look at the code at the bottom to see if that fixes it! It's now time to set up our database so that we can actually store our contacts and messages.

Creating our database

Back to Firebase! Again under Develop in the menu on the left, and then select Database. On the page that opens, click on Create database:

Create a database in Firebase

In the pop-up window, select "Start in test mode" so that we can actually access our database. We'll restrict access to only the right users soon enough. Click on the Enable button.

We'll need to create a couple of databases. One will contain our "user profiles" — this currently only contains the person's real name, which is entered when you register, but you can imagine in the future this can be extended with all kinds of pictures, inspirational quotes etc. We then need another database that manages each user's list of contacts, which is basically a collection of references to other people's profiles. Finally, we need to keep track of all the messages that are exchanged between people.

First, let's identify one of our existing user accounts to use as a guinea pig. Go to Develop / Authentication in Firebase and click on the Copy UID icon next to a user:

Copying the UID of a user

Then, in the database overview (Develop / Database), click on + Add collection. Let's name our first collection "profiles":

Creating a collection in the database

In step 2, we're asked to make one example entry of what this data would look like. This is where we define what kind of data we want to store in this collection of profiles:

Adding an example entry of our collection in the database

The next one is our contact list. It will have two fields: the UID of the person whose contact list it is, and the UID of a contact that belongs to that list. To come up with example UIDs, I had the Authentication page of Firebase open in another tab, so that I could easily copy/paste them. We can define this collection as follows (I named it "contacts"):

The collection of contacts

Finally, let's add a collection of messages. They contain a sender, receiver, content and a point in time at which the message was sent:

The collection of messages

For security reasons, we also want to limit access to our collections. So for example, if I'm currently logged in, I should only be able to retrieve certain documents:

  • The profiles of all other users (to be able to add them to my contact list), but only after I am logged in;
  • Contacts that belong to my own contact list;
  • Messages that I sent, and those that were directed at me.

So effectively this means that, for the contacts collection my UID needs to match the user_id of the document. For messages, I need to be either the sender_id or the receiver_id. Finally, for the profiles I simply need to be logged in. This can be done in the Rules tab at the top of the Database page:

Setting access rules for our collection

It takes a bit of trial and error to get this right, and it is rather advanced level stuff, so don't worry too much about this for now. However, adding these rules to your own projects does contribute to its safety, by ensuring that users cannot retrieve other users' data.

Adding database actions to our code

Now that we have our structure all sorted, we can start adding code to our prototype to actually add and retrieve these data. Let's start by creating a profile (storing the real name) the moment a new user registers. In register.js, add the following to our action when the button is pressed:

button_create_account.on(Events.Click, function(event, layer) {

  // Use Firebase to create a new account, based on our input fields
  firebase.auth().createUserWithEmailAndPassword(
    input_email.value, input_password.value).then(function(usercredential) {

      // If it succeeded, first add a profile for our newly created user
      firebase.firestore().collection("profiles").add({
        user_id: usercredential.user.uid,
        full_name: input_name.value,
        email: input_email.value
      }).then(function() {
        // If that also worked out, we move to the next page
        pages.snapToPage(headerpage);
      }).catch(function(error) {
        // Error while storing profile
        var toast = new md.Toast({
          title: error.message,
          parent: register_page
        });
      })
    }).catch(function(error) {
        // If an error was received, show it in a Toast
        var toast = new md.Toast({
          title: error.message,
          parent: register_page
        });
    });

});

This has become quite complicated, because we should only create the profile if the registration actually went correctly, and we should also display any errors that might occur while creating the profile. If you look closely you can see that the code to actually store the profile is only this part:

firebase.firestore().collection("profiles").add({
  user_id: usercredential.user.uid,
  full_name: input_name.value,
  email: input_name.email
});

We also store the e-mail address so we can use it later on to find people for our contact list, which is what we'll be building next. For that, we should add some magic to our addcontact.js file:

// See if the contact exists and, if so, store it to my contact list if it is not already there
button_add_contact.on(Events.Click, function(event, layer) {
    firebase.firestore().collection('profiles').where('email', '==', input_email.value).get().then(function(result) {
      // If we don't get any results, the profile doesn't exist
      if (result.docs.length == 0) {
        var toast = new md.Toast({
          title: "Sorry, I can't find anyone with that e-mail address.",
          parent: add_contact_page
        });
      }
      // We found them — add to my contact list!
      else {
        firebase.firestore().collection('contacts').add({
          user_id: firebase.auth().currentUser.uid,
          contact_id: result.docs[0].data().user_id
        }).then(function() {
          // Simply go back to the contact list and they should show up!
          pages.snapToPage(contacts_page);
        }).catch(function(error) {
          // Error while adding to contact list
          var toast = new md.Toast({
            title: error.message,
            parent: add_contact_page
          });
        });
      }
    }).catch(function(error) {
      // Error while retrieving profile
      var toast = new md.Toast({
        title: error.message,
        parent: add_contact_page
      });
    });
});

What this code does is, it first checks whether there is a profile known under this e-mail address. Note that we won't enter the catch() part of this even when there is no such user, in that case we simply get an empty result.docs from Firebase. Therefore, we need to check how many items we get, and if it's 0 we show a Toast saying we cannot find the user. If we found a matching profile, we then store the UID of this user, together with ours (so that we know it's our contact list), into the contacts collection. If everything worked out, we automatically return to our overview of contacts, which should be updated with this newly added contact — but we didn't implement that yet.

Note that at this point you're still able to add the same person to your contact list more than once. I'll leave that as homework, because we still have much more to do. For example, right now our contact list is not even loaded yet, so let's take care of that next. Open your contacts.js and add the following:

var contacts_collection = null;

// Register for data updates from our contact list
firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    // Unsubscribe if an old user is still logged in
    if (contacts_collection != null) {
      contacts_collection();
    }

    contacts_collection = firebase.firestore().collection("contacts").where("user_id", "==", firebase.auth().currentUser.uid).onSnapshot(function(snapshot) {
      var promises = [];

      // For each item in our contact list, retrieve the full name
      snapshot.forEach(function(item) {
        // Retrieve the full name of the user
        promises.push(firebase.firestore().collection("profiles").where("user_id", "==", item.data().contact_id).get());
      });

      // Once all promises (retrieval of full names) has been completed, store the full names in one big list.
      Promise.all(promises).then(function(results) {
        var contacts = [];
        var row_index = 1;

        results.forEach(function(result) {
            var name = result.docs[0].data().full_name;
            contacts.push({
              full_name: name,
              user_id: result.docs[0].data().user_id
            });
        });

        // Sort by full name ascending
        contacts.sort(function(a, b) {
          if (a.full_name.toLowerCase() > b.full_name.toLowerCase()) {
            return 1;
          }
          if (a.full_name.toLowerCase() < b.full_name.toLowerCase()) {
            return -1;
          }
          return 0;
        });

        // First clean the screen of old contacts, but leave Eliza
        for (var i = contacts_container.content.subLayers.length - 1; i > 0; i--) {
          contacts_container.content.subLayers[i].destroy();
        }

        // Add RowItems for each contact
        for (var i = 0; i < contacts.length; i++) {
          var name_parts = contacts[i].full_name.split(" ");
          var initials = name_parts[0].substring(0, 1) + name_parts[name_parts.length-1].substring(0, 1);

          // Add the RowItem
          var rowitem = new md.RowItem({
            text: contacts[i].full_name,
            iconText: initials.toUpperCase(),
            row: row_index,
            superLayer: contacts_container.content
          });

          // Click event for when clicking on this contact
          rowitem.on(Events.Click, function(event, layer) {
            pages.snapToPage(chat_page);
          });

          // Update our row index
          row_index += 1;
        }

      });

    });
  }
});

This is interesting (and a huge amount of code)! We start with a new piece of code right at the top. So far, all of our interactions with Firebase have been after a certain event was triggered, for example a button was pushed. However, now we want to load the list of contacts once at the start of the app, and we also want to ensure that we get notified whenever any changes have been made to this list (for example, a new contact was added). To take care of the first part, we subscribe to an event that is triggered when a user is logged in:

// Register for data updates from our contact list
firebase.auth().onAuthStateChanged(function(user) {
[...]
});

We then check whether user exists in order to see if someone actually logged in (this event also triggers when someone logs out). Remember that in the previous case of retrieving an existing profile, we called get() on our collection of profiles in Firebase. If we don't want to just retrieve data once, but also be notified whenever something changes to this dataset, we can use onSnapshot instead. In our scenario, we technically only log in once (yolo). However, Firebase actually remembers your last login, so we could actually skip the login screen if that's the case (another homework assignment!). What this means is that it immediately tries to retrieve the existing contacts the moment you load the prototype, if you've used it before. Therefore, if any onSnapshot was already activated when we started the prototype, and yet we log in with another account, we will first remove the old reference so that we don't try to retrieve someone else's contact list:

// Unsubscribe if an old user is still logged in
if (contacts_collection != null) {
  contacts_collection();
}

Then, we register for any updates to the contact list. Note that this also always fires once, the moment you start listening for updates, ensuring that you always start with the initial list of contacts:

contacts_collection = firebase.firestore().collection("contacts").where("user_id", "==", firebase.auth().currentUser.uid).onSnapshot(function(snapshot) {

[...]

});

We store this ongoing interaction in the contacts_collection variable, so that we can use it later to unsubscribe if needed. Now whenever anything changes to our contact list (and the very first time we run this code) the function that is given to onSnapshot is triggered, where we will turn this data from Firebase into RowItems on our screen. This is extra complicated because we don't have users' full names in our contacts collection, so for each user we find we'll have to retrieve the matching full name from our profiles collection. Also, we'll probably want to sort our list based on our contacts' full names...

You might remember from our Eliza adventure that code that depends on external sources usually runs asynchronously. What we get when we put in a request for data at Firebase is called a promise. Rather than immediately defining what should happen once the promise is fulfilled (the data is delivered by the external source, in this case Firebase), we can also store this promise in a list and wait for all of our responses to come in first, before moving on to the next piece of code. This is useful when you have code that can only run properly once all the data has come in. For example, we now want to build a sorted list of names, which can only be done once we've retrieved all the matching names to our contacts.

The list, which you can see is defined by brackets, is similar to the dictionary we've seen before, but in this case there are no keys to identify different entries, rather they are numbered. So I can access item number one by retrieving list[0], item number 2 is in list[1] and so forth. What you can (and will often) do with lists is to loop through them, meaning you run through each item of the list and do something with each of them individually. Let's have a look at this list of promises and how we use it later on to fill another list of contacts:

var promises = [];

// For each item in our contact list, retrieve the full name
snapshot.forEach(function(item) {
  // Retrieve the full name of the user
  promises.push(firebase.firestore().collection("profiles").where("user_id", "==", item.data().contact_id).get());
});

// Once all promises (retrieval of full names) has been completed, store the full names in one big list.
Promise.all(promises).then(function(results) {
  var contacts = [];
  row_index = 1;

  results.forEach(function(result) {
    var name = result.docs[0].data().full_name;
    contacts.push({
      full_name: name,
      user_id: result.docs[0].data().user_id
    });
});

There are several loops here: the snapshot, or each update of the collection of contacts from Firebase, contains a set of contacts and we loop through that with the snapshot.forEach(function(item) { ... });. This means that the function(item) gets executed for each item we have in our snapshot. Then, once all promises have been completed (Promise.all(promises)), we again loop through the results of all promises (another list) by using results.forEach(...). We then add dictionaries to the list of contacts, where each dictionary contains the full name and the UID of a user on our contact list.

Okay, this might be a bit much — and there's even more coming — but hang in there, you will keep encountering these loops so there are plenty of opportunities to practice! What we now want to do is sort this collection of contacts by name. Luckily, a list in JavaScript (also called an array) comes with this functionality built-in, and we can provide it with a function that indicates which fields to compare:

// Sort by full name ascending
contacts.sort(function(a, b) {
  if (a.full_name.toLowerCase() > b.full_name.toLowerCase()) {
    return 1;
  }

  if (a.full_name.toLowerCase() < b.full_name.toLowerCase()) {
    return -1;
  }

  return 0;
});

So what this code does is, for all possible combinations of items in the list, it compares the two full names. If the full name of the first item is larger than the full name of the second item it's comparing, meaning that it comes later in the alphabet, it is given a higher ID in the list so that it ends up lower. Now that our list is sorted, we have a very interesting loop coming up:

// First clean the screen of old contacts, but leave Eliza
for (var i = contacts_container.content.subLayers.length - 1; i > 0; i--) {
  contacts_container.content.subLayers[i].destroy();
}

Here we remove all of the old RowItems that may be left from our ScrollContainer. This is required because whenever an update is sent to us, for example when we add a new contact, we receive again the full contact list. Therefore, we first clean the screen before adding all of them all over again. However, because we are actively deleting items from the same list we're looping through, we should do it from the back to the front instead of how we would normally loop through a list (from the front to the back). This needs to be done because the list gets shorter as we delete stuff and the positions of elements in the list changes, so we will forget to delete a bunch if we start from the front. In this loop, we define an index i that indicates the position of the list we are currently on. It starts at the length of our list - 1, because that's the last item — the first element is at location 0, so if our list has a length of 10 the last element is actually located at position 9. What we then do is perform an action on the element at location i, namely we destroy it (bwahahaha) and then decrease our index by 1. We keep going as long as the condition in the middle is satisfied: i > 0. This means that if we have 5 RowItems in our list, it will start deleting the one at position 4, then 3, then 2, then 1, and then it stops. The RowItem at position 0 is our special case Eliza, so we want to leave that one.

Note that we've also reset our row_index so that we can start adding our new RowItems for all our contacts:

// Add RowItems for each contact
for (var i = 0; i < contacts.length; i++) {
  var name_parts = contacts[i].full_name.split(" ");
  var initials = name_parts[0].substring(0, 1) + name_parts[name_parts.length-1].substring(0, 1);

  // Add the RowItem
  var rowitem = new md.RowItem({
    text: contacts[i].full_name,
    iconText: initials.toUpperCase(),
    row: row_index,
    superLayer: contacts_container.content
  });

  // Click event for when clicking on this contact
  rowitem.on(Events.Click, function(event, layer) {
    headerpages.snapToPage(chat_page);
  });

  // Update our row index
  row_index += 1;
}

Note that we do some cool stuff with the full name: we split it into parts wherever a space occurs. This results in a list of smaller strings that each contain one word. For example, my name "Jan de Wit" would turn into a list with 3 items: ["Jan", "de", "Wit"]. We then take the first letter (substring(0, 1) means take the substring that starts at position 0 and runs until position 1, excluding the end position — resulting in only the first character) of the first word and the last word, and join them together to form the initials we show at the front of the RowItem.

That's finally it! I think we just finished our hardest piece of code in the whole of this tutorial, phew. Don't worry if it doesn't fully sink in yet, with time it will! Let's move on to our pièce de résistance: the actual messages. It's time for another new trick, because we'll need to pass the full name and UID of the selected user from our contact list to the chat window, since we don't want to create a new chat window for each contact we have — it's more efficient to simply clean up the one chat window. Let's define a function in chat.js:

var chat_uid = "eliza";

var set_contact = function(uid, full_name) {
  header.title = full_name;
  chat_uid = uid;
};

And at the bottom, change the export to include this function so that we can access it from outside of this file:

export {chat_page, set_contact};

We can now update the contacts.js page so that it sets the correct information on chat.js before transitioning. First, we should fix our import of chat_page to also include our new function:

import {chat_page, set_contact} from './chat.js';

And then let's update Eliza's RowItem:

// Click event for when clicking on Eliza's contact
contact_eliza.on(Events.Click, function() {
    set_contact("eliza", "Eliza");
  pages.snapToPage(chat_page);
});

Finally, the RowItems that are generated on-the-fly as we load our contact list. For that, we need to add a small trick where we temporarily store the UID on our RowItem (don't worry about this too much):

[...]
rowitem._uid = contacts[i].user_id;

// Click event for when clicking on this contact
rowitem.on(Events.Click, function(event, layer) {
  set_contact(layer._uid, layer._text);
  pages.snapToPage(chat_page);
});

Alright, now let's extend our event for when the page is shown to establish a connection to the collection of messages, similar to how we were monitoring the contact list in our previous page. However, we now need to establish two connections: one for messages we sent, one for messages we received. Also, rather than fully deleting the chat history and then re-adding all messages, we're going to be a bit smarter about it here:

// This allows us to unregister from messages from previous sessions with other contacts.
var message_collection_sent = null;
var message_collection_received = null;

// Event to update the title when we change screens to this one
pages.on("change:currentPage", function() {
  if (pages.currentPage == chat_page) {

    // Clean the history of messages
    for (var index = chat_container.content.subLayers.length-1; index >= 0; index--) {
      chat_container.content.subLayers[index].destroy();
    }

    cur_y = 16;

    // Only register for messages if we're not talking to our chatbot
    if (chat_uid != "eliza") {
      var initial_messages = [];

      // Connect to our two data sources: messages sent and messages received
      if (message_collection_sent != null) {
        message_collection_sent();
      }

      if (message_collection_received != null) {
        message_collection_received();
      }

      // We need to combine two messages: those we received, and those we sent.
      message_collection_sent = firebase.firestore().collection("messages").where("sender_id", "==", firebase.auth().currentUser.uid).where("receiver_id", "==", chat_uid).onSnapshot(function(snapshot) {
        // Only add the latest one
        if (!first_sent) {
          var data = snapshot.docs;
          data.sort(sort_by_time);
          add_text_balloon(data[data.length-1].data().content, true);
        }

        // If there are currently no messages this is the initial batch.
        else {
          first_sent = false;

          // Merge the initial array with our new data
          initial_messages = initial_messages.concat(snapshot.docs);

          // If we also already received the other messages, start filtering them and putting them on screen.
          if (!first_received) {
            initial_messages.sort(sort_by_time);

            initial_messages.forEach(function(item) {
              add_text_balloon(item.data().content, item.data().sender_id == firebase.auth().currentUser.uid);
            })
          }
        }

      });

      message_collection_received = firebase.firestore().collection("messages").where("receiver_id", "==", firebase.auth().currentUser.uid).where("sender_id", "==", chat_uid).onSnapshot(function(snapshot) {
        // Only add the latest one
        if (!first_received) {
          var data = snapshot.docs;
          data.sort(sort_by_time);
          add_text_balloon(data[data.length-1].data().content, false);
        }

        // If there are currently no messages this is the initial batch.
        else {
          first_received = false;

          // Merge the initial array with our new data
          initial_messages = initial_messages.concat(snapshot.docs);

          // If we also already received the other messages, start filtering them and putting them on screen.
          if (!first_sent) {
            initial_messages.sort(sort_by_time);

            initial_messages.forEach(function(item) {
              add_text_balloon(item.data().content, item.data().sender_id == firebase.auth().currentUser.uid);
            })
          }
        }
      });
    }

  }
});

That's a chunk of code! Most of it will be familiar though, and there's a lot of repetition. The first line is a new type of Event:

pages.on("change:currentPage", function() {
[...]
}

This event is triggered whenever our PageComponent changes screens, even when the chat screen we're currently on is not the screen we're navigating to. What we want to achieve is to retrieve our messages the moment the chat window is opened, so we make sure to only do something when we are indeed on the correct screen, using the if-statement below:

pages.on("change:currentPage", function() {
  if (pages.currentPage == chat_page) {
    [...]
  }
}

What happens first when the chat window is opened, is clean-up: similar to refreshing our contact list, we remove all the existing text bubbles that might be left over from previous visits to the window, and we reset the cur_y location to the top for the first new message. The rest of the code is only executed if we are not talking to Eliza, by using this if-statement: if (chat_uid != "eliza") { ... }, because if we are talking to Eliza none of the messages are stored in Firebase.

What happens next is similar to the list of contacts: we connect to two data sources — messages where we are the sender and our selected conversational partner the receiver, and vice versa. We now distinguish between the first time the data are received, which we track using the first_sent and first_received variables, so that the first time we get data we put all the messages in the list on-screen, while later on only the newest added message gets published. This is more efficient and more visually pleasing than clearing the screen and adding all messages every time. We use the initial_data list to combine the first sets of sent and received messages, so that they can be sorted together and displayed in the right order. Note the use of the concat() function, which can be used to add the entire contents of one list to another. Finally, we use our existing function to create the text balloons, another nice example of the benefits of creating functions out of code that is used a lot!

That was maybe a bit much... Let me know if it's not clear! Ah, we still need to define the function that is used to sort the messages by the time they were originally sent, similarly to how we were sorting by full name in the contact list:

var sort_by_time = function(a, b) {
  if (a.data().time > b.data().time) {
    return 1;
  }

  if (a.data().time < b.data().time) {
    return -1;
  }

  return 0;
}

Finally, we need to actually store messages when we send them (except in the case of Eliza). We do this as follows:

// Add a click event to our "send" button to actually put the message on screen
new_message_icon.on(Events.Click, function() {

  if (chat_uid == "eliza") {
    // Create balloon
    add_text_balloon(new_message_text.value, true);

    // Send our message to Eliza
    xhttp.open("POST", "http://eliza.lt3.nl/", true);
    xhttp.send(JSON.stringify({"message": new_message_text.value}));
  }

  else {
    // Send it to the internets
    firebase.firestore().collection("messages").add({
      sender_id: firebase.auth().currentUser.uid,
      receiver_id: chat_uid,
      content: new_message_text.value,
      time: Date.now()
    });
  }

  // Empty our input field for the next message
  new_message_text.value = "";
});

Wrapping up

While we now limited ourselves to the chat app domain, also only taking into account one-on-one conversations, hopefully you will now also have an idea on how to apply this to any other domain that might benefit from permanent data storage and/or social connectedness. The database structure, and the way you can limit access rights, are very flexible in Firebase so it should be able to support most of your needs.

Our final chapter, at least for now, will allow you to publish and share your prototype online.

login.js

var md = require('./modules/md.coffee');

import pages from './pagecomponent.js';
import register_page from './register.js';
import contacts_page from './contacts.js';

// The page itself
var login_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

// The (central) container displaying the required fields
var login_container = new md.Card({
  width: login_page.width * .8,
  height: 260,
  backgroundColor: "rgb(255, 255, 255)",
  parent: login_page
});

// If our screen is very wide, we limit the size of our container to 300px
if (login_container.width > 300) {
  login_container.width = 300;
}

// Center it, both horizontally and vertically
// Center after resizing, otherwise it uses the old width to calculate the center!
login_container.center();

// The amazing logo
var login_logo = new Layer({
  y: 0,
  width: 200,
  height: 80,
  image: "images/sprinkles.svg",
  parent: login_container
});

// Center this one, but only horizontally
login_logo.centerX();

// Field to enter e-mail address
var input_email = new md.TextField({
  x: 20,
  y: 77,
  width: login_container.width * .8,
  labelText: "Your e-mail here",
  type: "email",
  parent: login_container
});

// Field to enter password
var input_password = new md.TextField({
  x: 20,
  y: 140,
  width: login_container.width * .8,
  labelText: "Your password here",
  type: "password",
  parent: login_container
});

// The login button
var button_login = new md.Button({
  x: Align.center,
  y: 210,
  text: "Login",
  type: "raised",
  parent: login_container
});

// Add the click event to the "login" button
button_login.on(Events.Click, function(event, layer) {

    // Use Firebase to log in to an existing account, based on our input fields
  firebase.auth().signInWithEmailAndPassword(
    input_email.value, input_password.value).then(function(usercredential) {
      // If it succeeded, go to the chat page
      pages.snapToPage(contacts_page);
    }).catch(function(error) {
        // If an error was received, show it in a Toast
        var toast = new md.Toast({
          title: error.message,
          parent: login_page
        });
    });

});

// Link to create a new account
var create_account_text = new md.Regular({
  y: login_container.y + login_container.height + 10,
  width: 300,
  color: "rgb(15, 108, 200)",
  text: "No account? Click here to register!",
  textAlign: "center",
  parent: login_page
});

create_account_text.centerX();

// Add the click event to the "new account" link
create_account_text.on(Events.Click, function(event, layer) {
  pages.snapToPage(register_page);
});

export default login_page;

register.js

var md = require('./modules/md.coffee');

import pages from './pagecomponent.js';
import contacts_page from './contacts.js';

// The page itself
var register_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

pages.addPage(register_page);

// The (central) container displaying the required fields
var register_container = new md.Card({
  width: register_page.width * .8,
  height: 325,
  parent: register_page
});

// If our screen is very wide, we limit the size of our container to 300px
if (register_container.width > 300) {
  register_container.width = 300;
}

register_container.center();

// The amazing logo
var register_logo = new Layer({
  y: 0,
  width: 200,
  height: 80,
  image: "images/sprinkles.svg",
  parent: register_container
});

// Center this one, but only horizontally
register_logo.centerX();

// Field to enter name
var input_name = new md.TextField({
  x: 20,
  y: 75,
  width: register_container.width * .8,
  labelText: "Your display name here",
  parent: register_container
});

// Field to enter e-mail address
var input_email = new md.TextField({
  x: 20,
  y: 140,
  width: register_container.width * .8,
  labelText: "Your e-mail here",
  type: "email",
  parent: register_container
});

// Field to enter password
var input_password = new md.TextField({
  x: 20,
  y: 205,
  width: register_container.width * .8,
  labelText: "Your password here",
  type: "password",
  parent: register_container
});

// The button to create an account
var button_create_account = new md.Button({
  x: Align.center,
  y: 275,
  width: 200,
  text: "Create account",
  parent: register_container
});

button_create_account.on(Events.Click, function(event, layer) {

  // Use Firebase to create a new account, based on our input fields
  firebase.auth().createUserWithEmailAndPassword(
    input_email.value, input_password.value).then(function(usercredential) {

      // If it succeeded, first add a profile for our newly created user
      firebase.firestore().collection("profiles").add({
        user_id: usercredential.user.uid,
        full_name: input_name.value,
        email: input_email.value
      }).then(function() {
        // If that also worked out, we move to the next page
        pages.snapToPage(contacts_page);
      }).catch(function(error) {
        // Error while storing profile
        var toast = new md.Toast({
          title: error.message,
          parent: register_page
        });
      })
    }).catch(function(error) {
        // If an error was received, show it in a Toast
        var toast = new md.Toast({
          title: error.message,
          parent: register_page
        });
    });

});

export default register_page;

chat.js

import pages from './pagecomponent.js';
import contacts_page from './contacts.js';

var md = require('./modules/md.coffee');

var xhttp = new XMLHttpRequest();

// The page itself
var chat_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

pages.addPage(chat_page);

// The bar at the top, containing a title and a button for navigation
var header = new md.Header({
  icon: 'arrow-left',
  title: 'Eliza',
  action: function() {
    pages.snapToPage(contacts_page);
  },
  parent: chat_page
});

// This container provides scrolling functionality and will hold all the chat messages
var chat_container = new ScrollComponent({
  y: 80,
  height: chat_page.height - 50,
  width: chat_page.width,
  backgroundColor: "rgb(240, 240, 240)",
  scrollHorizontal: false,
  parent: chat_page
});

// This layer at the bottom of the screen will host the input field and send button
var new_message_container = new Layer({
  y: Align.bottom,
  height: 70,
  width: chat_page.width,
  backgroundColor: md.Theme.colors.primary.main,
  parent: chat_page
});

// A nice rounded background for our text
var new_message_background = new Layer({
  x: 10,
  y: 10,
  height: 50,
  width: chat_page.width - 80,
  backgroundColor: "rgb(255, 255, 255)",
  borderRadius: 3,
  parent: new_message_container
});

// We remove all the indicators and borders from our TextArea since we have our own background layer
var new_message_text = new md.TextArea({
  tint: "rgba(0, 0, 0, 0)",
  width: chat_page.width - 120,
  parent: new_message_background
});

// The paper plane icon to send our message
var new_message_icon = new md.Icon({
  x: Align.right(-25),
  y: Align.center,
  icon: "send",
  color: "rgb(255, 255, 255)",
  parent: new_message_container
});

// Add a click event to our "send" button to actually put the message on screen
new_message_icon.on(Events.Click, function() {

  if (chat_uid == "eliza") {
    // Create balloon
    add_text_balloon(new_message_text.value, true);

    // Send our message to Eliza
    xhttp.open("POST", "http://eliza.lt3.nl/", true);
    xhttp.send(JSON.stringify({"message": new_message_text.value}));
  }

  else {
    // Send it to the internets
    firebase.firestore().collection("messages").add({
      sender_id: firebase.auth().currentUser.uid,
      receiver_id: chat_uid,
      content: new_message_text.value,
      time: Date.now()
    });
  }

  // Empty our input field for the next message
  new_message_text.value = "";
});

// A reply from Eliza!
xhttp.onreadystatechange = function() {
  if (this.readyState == 4 && this.status == 200) {
    var response = JSON.parse(this.responseText);
    Utils.delay(2, function() {
      add_text_balloon(response.message, false);
    });
  }
};

// Initialize the y-coordinate for our text balloons
var cur_y = 16;

// Function to add a text balloon to the ScrollComponent
var add_text_balloon = function(text, is_mine) {
  var card = new md.Card({
    x: Align.left(16),
    y: cur_y,
    superLayer: chat_container.content
  });

  var txt = new md.Regular({
    text: text
  });

  if (txt.width > Screen.width / 2) {
    txt.width = Screen.width / 2;
  }

  var txt_width = txt._element.children[0].offsetWidth;
  var txt_height = txt._element.children[0].offsetHeight;

  card.width = txt_width + 32;
  card.height = txt_height + 8;
  card.addToStack(txt);

  if (is_mine) {
    card.backgroundColor = md.Theme.colors.secondary.light;
    card.x = Align.right(-16);
  }

  cur_y += txt_height + 48;
  chat_container.scrollToLayer(card);
};

var chat_uid = "eliza";

var set_contact = function(uid, full_name) {
  header.title = full_name;
  chat_uid = uid;
};

// This allows us to unregister from messages from previous sessions with other contacts.
var message_collection_sent = null;
var message_collection_received = null;

var first_sent = true;
var first_received = true;

// Event to update the title when we change screens to this one
pages.on("change:currentPage", function() {
  if (pages.currentPage == chat_page) {

    // Clean the history of messages
    for (var index = chat_container.content.subLayers.length-1; index >= 0; index--) {
      chat_container.content.subLayers[index].destroy();
    }

    cur_y = 16;

    // Only register for messages if we're not talking to our chatbot
    if (chat_uid != "eliza") {
      var initial_messages = [];

      // Connect to our two data sources: messages sent and messages received
      if (message_collection_sent != null) {
        message_collection_sent();
      }

      if (message_collection_received != null) {
        message_collection_received();
      }

      // We need to combine two messages: those we received, and those we sent.
      message_collection_sent = firebase.firestore().collection("messages").where("sender_id", "==", firebase.auth().currentUser.uid).where("receiver_id", "==", chat_uid).onSnapshot(function(snapshot) {
        // Only add the latest one
        if (!first_sent) {
          var data = snapshot.docs;
          data.sort(sort_by_time);
          add_text_balloon(data[data.length-1].data().content, true);
        }

        // If there are currently no messages this is the initial batch.
        else {
          first_sent = false;

          // Merge the initial array with our new data
          initial_messages = initial_messages.concat(snapshot.docs);

          // If we also already received the other messages, start filtering them and putting them on screen.
          if (!first_received) {
            initial_messages.sort(sort_by_time);

            initial_messages.forEach(function(item) {
              add_text_balloon(item.data().content, item.data().sender_id == firebase.auth().currentUser.uid);
            })
          }
        }

      });

      message_collection_received = firebase.firestore().collection("messages").where("receiver_id", "==", firebase.auth().currentUser.uid).where("sender_id", "==", chat_uid).onSnapshot(function(snapshot) {
        // Only add the latest one
        if (!first_received) {
          var data = snapshot.docs;
          data.sort(sort_by_time);
          add_text_balloon(data[data.length-1].data().content, false);
        }

        // If there are currently no messages this is the initial batch.
        else {
          first_received = false;

          // Merge the initial array with our new data
          initial_messages = initial_messages.concat(snapshot.docs);

          // If we also already received the other messages, start filtering them and putting them on screen.
          if (!first_sent) {
            initial_messages.sort(sort_by_time);

            initial_messages.forEach(function(item) {
              add_text_balloon(item.data().content, item.data().sender_id == firebase.auth().currentUser.uid);
            })
          }
        }
      });
    }

  }
});

var sort_by_time = function(a, b) {
  if (a.data().time > b.data().time) {
    return 1;
  }

  if (a.data().time < b.data().time) {
    return -1;
  }

  return 0;
}

export {chat_page, set_contact};

contacts.js

import pages from './pagecomponent.js';
import {chat_page, set_contact} from './chat.js';
import add_contact_page from './addcontact.js';

var md = require('./modules/md.coffee');

// The page itself
var contacts_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

pages.addPage(contacts_page);

// This container provides scrolling functionality and will hold our contacts
var contacts_container = new ScrollComponent({
  y: 80,
  height: contacts_page.height - 50,
  width: contacts_page.width,
  backgroundColor: "rgb(240, 240, 240)",
  scrollHorizontal: false,
  parent: contacts_page
});

// A button to add a new contact
var add_contact_button = new md.ActionButton({
  parent: contacts_page,
  action: function() {
    pages.snapToPage(add_contact_page);
  }
});

// The contact entry for our favorite chatbot
var contact_eliza = new md.RowItem({
  text: "Eliza",
  iconText: "E",
  superLayer: contacts_container.content
});

// Click event for when clicking on Eliza's contact
contact_eliza.on(Events.Click, function() {
    set_contact("eliza", "Eliza");
  pages.snapToPage(chat_page);
});

// The bar at the top, containing a title and a button for navigation
var header = new md.Header({
  title: 'My contacts',
  parent: contacts_page
});

var contacts_collection = null;

// Register for data updates from our contact list
firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    // Unsubscribe if an old user is still logged in
    if (contacts_collection != null) {
      contacts_collection();
    }

    contacts_collection = firebase.firestore().collection("contacts").where("user_id", "==", firebase.auth().currentUser.uid).onSnapshot(function(snapshot) {
      var promises = [];

      // For each item in our contact list, retrieve the full name
      snapshot.forEach(function(item) {
        // Retrieve the full name of the user
        promises.push(firebase.firestore().collection("profiles").where("user_id", "==", item.data().contact_id).get());
      });

      // Once all promises (retrieval of full names) has been completed, store the full names in one big list.
      Promise.all(promises).then(function(results) {
        var contacts = [];
        var row_index = 1;

        results.forEach(function(result) {
            var name = result.docs[0].data().full_name;
            contacts.push({
              full_name: name,
              user_id: result.docs[0].data().user_id
            });
        });

        // Sort by full name ascending
        contacts.sort(function(a, b) {
          if (a.full_name.toLowerCase() > b.full_name.toLowerCase()) {
            return 1;
          }
          if (a.full_name.toLowerCase() < b.full_name.toLowerCase()) {
            return -1;
          }
          return 0;
        });

        // First clean the screen of old contacts, but leave Eliza
        for (var i = contacts_container.content.subLayers.length - 1; i > 0; i--) {
          contacts_container.content.subLayers[i].destroy();
        }

        // Add RowItems for each contact
        for (var i = 0; i < contacts.length; i++) {
          var name_parts = contacts[i].full_name.split(" ");
          var initials = name_parts[0].substring(0, 1) + name_parts[name_parts.length-1].substring(0, 1);

          // Add the RowItem
          var rowitem = new md.RowItem({
            text: contacts[i].full_name,
            iconText: initials.toUpperCase(),
            row: row_index,
            superLayer: contacts_container.content
          });

          rowitem._uid = contacts[i].user_id;

          // Click event for when clicking on this contact
          rowitem.on(Events.Click, function(event, layer) {
                        set_contact(layer._uid, layer._text);
            pages.snapToPage(chat_page);
          });

          // Update our row index
          row_index += 1;
        }

      });

    });
  }
});

export default contacts_page;

addcontact.js

import pages from './pagecomponent.js';
import contacts_page from './contacts.js';

var md = require('./modules/md.coffee');

// The page itself
var add_contact_page = new Layer({
  width: pages.width,
  height: pages.height,
  backgroundColor: "rgb(240, 240, 240)",
  parent: pages.content
});

pages.addPage(add_contact_page);

// Input field for the e-mail address of a contact we want to find
var input_email = new md.TextField({
  x: 20,
  y: 150,
  width: add_contact_page.width * .7,
  labelText: "E-mail of your contact",
  parent: add_contact_page
});

input_email.centerX();

// The button to add the contact to our list
var button_add_contact = new md.Button({
  x: Align.center,
  y: 225,
  width: 200,
  text: "Add contact",
  parent: add_contact_page
});

button_add_contact.centerX();

// See if the contact exists and, if so, store it to my contact list if it is not already there
button_add_contact.on(Events.Click, function(event, layer) {
    firebase.firestore().collection('profiles').where('email', '==', input_email.value).get().then(function(result) {
      // If we don't get any results, the profile doesn't exist
      if (result.docs.length == 0) {
        var toast = new md.Toast({
          title: "Sorry, I can't find anyone with that e-mail address.",
          parent: add_contact_page
        });
      }
      // We found them — add to my contact list!
      else {
        firebase.firestore().collection('contacts').add({
          user_id: firebase.auth().currentUser.uid,
          contact_id: result.docs[0].data().user_id
        }).then(function() {
          // Simply go back to the contact list and they should show up!
          pages.snapToPage(contacts_page);
        }).catch(function(error) {
          // Error while adding to contact list
          var toast = new md.Toast({
            title: error.message,
            parent: add_contact_page
          });
        });
      }
    }).catch(function(error) {
      // Error while retrieving profile
      var toast = new md.Toast({
        title: error.message,
        parent: add_contact_page
      });
    });
});

// The bar at the top, containing a title and a button for navigation
var header = new md.Header({
  icon: 'arrow-left',
  title: 'Add new contact',
  action: function() {
      pages.snapToPage(contacts_page);
  },
  parent: add_contact_page
});

export default add_contact_page;




Add a comment