Build a Decentralized Chat App with Knockout and IPFS

tutorials Sep 27, 2018

How to build a fully decentralized chat app in under an hour with <100 lines of code

The final product from this tutorial!

Decentralized applications (Dapps) are apps that run on a decentralized network (of peers) via (preferably trust-less) peer-to-peer (p2p) protocols. One of their biggest strengths is that they avoid any single point of failure. Unlike traditional apps, there is no single entity who can completely control their operation. Dapps are a relatively new concept (so a standard definition is still a bit elusive), but the most prolific set of examples are operating as Smart Contracts on the Ethereum blockchain. Dapps have become increasingly popular as new decentralized technologies such as blockchains and projects like the Interplanetary File System (IPFS) have gained more attention and momentum.

There are many good reasons why developers should start seriously looking at developing decentralized apps, including — but certainly not limited to — scalability (in general, the network of peers participates in hosting the app, limiting pressure on your own infrastructure) and trust (by definition, Dapp code is open source and often content addressed, so your code can be independently verified). And there are now plenty of examples out there, from basic voting apps to advanced p2p collaboration tools, that can help paint of picture of the power of Dapp.

In today’s post, we’re going to develop a simple decentralized chat Dapp that runs on IPFS’s publish-subscribe mechanism, a p2p messaging pattern that allows peers to communicate on the open, decentralized web. While we’re at it, we’re going to develop our Dapp using the Model–view–viewmodel (MVVM) software design pattern, to give you a sense of using decentralized tools in a real-world development scenario. You’ll see that building fully-working decentralized apps that take advantage of IPFS is becoming increasingly easy thanks to the amazing work of the IPFS community of developers. But before we get started, here’s a quick overview of the primary decentralized messaging pattern we’re going to use to make our Dapp shine.

Pubsub

Pubsub (or publish-subscribe) is a pretty standard messaging pattern where the publishers don’t know who, if anyone’ will subscribe to a given topic. Basically, we have publishers that send messages on a given topic or category, and subscribers who receive only messages on a give topic they are subscribed to. Pretty easy concept. The key feature here is that there is no direct connection between publishers and subscribers required… which makes for a pretty powerful communication system. Ok, so why am I talking about this here? Because pubsub allows for dynamic communication between peers that is fast, scalable, and open… which is pretty much what we need to build a decentralized chat app… perfect!

Right now, IPFS uses something called floodsub, which is an implementation of pubsub that essentially just floods the network with messages, and peers are required to listen to the right messages based on their subscriptions, and ignore the rest. This probably isn’t ideal, but it is an excellent first pass, and works pretty well already. Soonish, IPFS will take advantage of gossipsub, which is more like a proximity-aware epidemic pubsub, where peers will communicate with proximal peers, and messages will be routed more efficiently this way. Watch this space… because this is going to be an important part of how IPFS scales and speeds things like IPNS up over the medium term.

Getting started

So to start, let’s clone the Textile dapp template repo, which is really just a simple scaffolding to help us accelerate our development process. We’ve used this template in previous examples (here and here). Feel free to use your own development setup if you prefer, but I’m going to assume you’re working off our template for the remainder of this tutorial.

git clone https://github.com/textileio/dapp-template.git chat-dapp
cd chat-dapp
yarn remove queue window.ipfs-fallback
yarn add ipfs ipfs-pubsub-room knockout query-string

If you want to follow along, but from a fully-baked working version then instead of lines 3 and 4 above…

git checkout build/profile-chat
yarn install

Ok, so first things first. What do those packages above get us? Let’s start with ipfs and ipfs-pubsub-room. Obviously, we’re going to use ipfs for interacting with the IPFS network… but what about ipfs-pubsub-room? This is a really nice package from the IPFS shipyard (a GitHub repo for incubated projects by the IPFS community) that simplifies interacting with the IPFS pubsub facilities. Basically, it allows developers to easily create a room based on an IPFS pubsub channel, and then emits membership events, listens for messages, broadcasts and direct messages to peers. Nice.

Model–view–viewmodel

Next we have the knockout and query-string packages. The latter of these two is just a simple package for parsing a url query string, and really just simplifies our lives a bit when developing dapps with url parameters (which we’ll do here) — nothing fancy here. But the knockout package actually is pretty fancy, and we’ll use it to develop our app using a real software architectural pattern: Model–view–viewmodel (MVVM).

New role of view is to simply ‘bind’ model data exposed by view model to view

MVVM facilitates a separation of development of the graphical user interface — be it via a markup language or GUI code — from development of the business logic or back-end logic (the data model). For the uninitiated, this pattern introduces the concept of a ‘view model’ in the middle of your application, which is responsible for exposing (converting) data objects from your underlying model so that it is easily managed and presented. Essentially, the view model of MVVM is a value converter, meaning the view model is responsible for exposing (converting) the data objects from the model in such a way that objects are easily managed and presented. The new role of your view is then to simply ‘bind’ model data exposed by the view model to your view. In this respect, the view model is more model than view, and handles most, if not all of the view’s display logic.

Ok, so what does this ‘get us’? Well, it allows us to develop dynamic apps using a simpler declarative programing style, we get automatic UI/data model updates and dependency tracking between UI elements ‘for free’, plus it facilitates a clearer separation of concerns. But more than anything, its a way to explore a common design pattern with decentralized software components. And why Knockout? Because it is really easy to get started building single-page applications with minimal markup/code, their interactive tutorials are super helpful, and they provide useful docs covering various MVVM concepts and features.

Next steps

If you want to see where we’re headed, you can check out this diff of our target build/profile-chat branch with the default dapp-template (master) branch. In the mean time, let’s setup our new imports. Start by editing your src/main.js file using your favorite text editor/IDE, and replace line 2 (import getIpfs from 'window.ipfs-fallback') with:

import Room from 'ipfs-pubsub-room'
import IPFS from 'ipfs'
import ko from 'knockout'
import queryString from 'query-string'

// Global references for demo purposes
let ipfs
let viewModel

Now that we’ve adjusted our imports (and added some global variables for later), you can run yarn watch from the terminal to get our local build server running. We’ll have to make some changes before our code will run properly, but it’s useful to have our code ‘browserfied’ for us as we work.

IPFS peer

Next, we’ll create a new IPFS object to use to interact with the decentralized web. Unlike in previous tutorials, we’ll create an IPFS object directly, rather than relying on something like window.ipfs-fallback. This is primarily because we want more control over how we setup our IPFS peer. In particular, we want to be able to enable some experimental features (i.e., pubsub), and control which swarm addresses we announce on (see this post for details). So our async setup function now becomes:

const setup = async () => {
  try {
    ipfs = new IPFS({
      // We need to enable pubsub...
      EXPERIMENTAL: {
        pubsub: true
      },
      config: {
        Addresses: {
          // ...And supply swarm address to announce on
          Swarm: [
            '/dns4/ws-star.discovery.libp2p.io/tcp/443/wss/p2p-websocket-star'
          ]
        }
      }
    })
  } catch(err) {
    console.error('Failed to initialize peer', err)
    viewModel.error(err) // Log error...
  }
}

View model

Now that we have our imports in order, and our IPFS peer initializing the way we want, let’s start editing/creating our view model. You can do this inside our modified setup function, so that the first few lines of that function would now look something like this:

const setup = async () => {  
  // Create view model with properties to control chat
  function ViewModel() {
    let self = this
    // Stores username
    self.name = ko.observable('')
    // Stores current message
    self.message = ko.observable('')
    // Stores array of messages
    self.messages = ko.observableArray([])
    // Stores local peer id
    self.id = ko.observable(null)
    // Stores whether we've successfully subscribed to the room
    self.subscribed = ko.observable(false)
    // Logs latest error (just there in case we want it)
    self.error = ko.observable(null)
    // We compute the ipns link on the fly from the peer id
    self.url = ko.pureComputed(() => {
      return `https://ipfs.io/ipns/${self.id()}`
    })
  }
  // Create default view model used for binding ui elements etc.
  viewModel = new ViewModel()
  // Apply default bindings
  ko.applyBindings(viewModel)
  window.viewModel = viewModel // Just for demo purposes later!
  ...

Basically, we are creating a relatively simple Javascript object with properties that control the username (name), the current message (message), the local IPFS Peer Id (id), as well as application state information such as an array of past messages (messages), and whether we’ve successfully subscribed to the given chat topic (subscribed). We also have a convenience computed property for representing a user’s IPNS link, just for fun. You’ll notice for each of these properties, that we’re using knockout’s Observable objects. From the Knockout docs:

[…] one of the key benefits of KO is that it updates your UI automatically when the view model changes. How can KO know when parts of your view model change? Answer: you need to declare your model properties as observables, because these are special JavaScript objects that can notify subscribers about changes, and can automatically detect dependencies.

So in order to be able to react to changes in a given property, we need to make it an observable property. This is kind of the whole point of observables in Knockout: other code can be notified of changes. As we’ll see shortly, this means we can ‘bind’ HTML element properties to our view model’s properties, so that, for example, if we have a <div> element with a data-bind="text: name" attribute, the text binding will register itself to be notified when the name property of our view model changes. As always, these concepts are much easier to understand when you have some code to play with, so let’s start modifying our src/index.html to take advantage of Knockouts observable binding features.

Binding properties

To take advantage of the view model that we have just set up, we’ll need to specify how our various HTML elements ‘bind’ to our view model properties. We do this using Knockout’s data-bind property. There aren’t a lot of changes to make here, but your <body> div should now look something like this (we’ll go over the various components one by one to make sure we’re all on the same page):

<body>
  <div id="main">
    <div class="controls">
      <input id="name" type="text" data-bind="value: name"
             placeholder="Pick a name (or remain anonymous)"/>
    </div>
    <div class="output"
         data-bind="foreach: { data: messages, as: 'msg' }">
      <div>
        <a data-bind="text: msg.name,
                      css: { local: msg.from === $root.id() },
                      attr: { href: `ipfs.io/ipns/${msg.from}` }">
        </a>
        <div data-bind="text: msg.text"></div>
      </div>
    </div>
    <div class="input">
      <input id="text" type="text" placeholder="Type a message"
             data-bind="value: message, enable: subscribed" />
    </div>
  </div>
  <script src="bundle.js"></script>
</body>

Since an image is worth 1000 words, here’s what your web-app should now look like if you refresh localhost:8000/ (you might also want to copy the minimal CSS from here, so it looks a bit nicer):

Basic Chat ĐApp with some minimal CSS styling.

As you can see (I’ve added a blue background to our output div for visual reference), we have three main elements: i) a 'name' input element for controlling our username, ii) an 'output' div for displaying our chat history (this will hold series of message divs with username, IPNS links, and the message), and iii) a 'text' message input for typing our messages. The only ‘new’ syntax you’ll likely be unfamiliar with are the data-bind attributes, so we’ll go through those one at a time:

  1. <input id="name" type="text" data-bind="value: name" />: bind the name property of our viewModel to the value of this input element.
  2. <input id="text" type="text" data-bind="value: message, enable: subscribed" />: bind the message property of our viewModel to the value of this input element, and only enable the element if subscribed is true.
  3. <div class="output" data-bind="foreach: { data: messages, as: 'msg' }">: for each item in the messages array, label item as msg, and…
  4. <a data-bind="text: msg.name, css: { local: msg.from === $root.id() }, attr: { href: `ipfs.io/ipns/${msg.from}` }">: bind the text of the hyperlink element to the name property of the msg, set the href attribute to string (template literal) containing the from property of the msg, and finally, set the CSS class of the element to 'local' if the from property of the msg is equal to the root viewModel’s id (i.e., if this was your own message).

Whew that was a lot of new ideas! But the markup is actually pretty simple, and it greatly simplified our overall code, because Knockout handles all of the interactions between app state and view elements. And just so we’re all on the same page before moving forward, here’s the current state of the web-app as we have it now...

Pubsub interactions

Alright, now it’s time to actually add some chat capabilities. For this, we’re going to rely on the very awesome ipfs-pubsub-room library. We’re going to start by modifying our src/main.js file again, this time, by creating a new try/catch block containing all the interaction callbacks we’ll need. We’ll go through each section of this code separately, but you can also follow along by checking out the file diff or the secondary state in this gist.

try {
  ipfs.on('ready', async () => {
    const id = await ipfs.id()
    // Update view model
    viewModel.id(id.id)
    // Can also use query string to specify, see github example
    const roomID = "test-room-" + Math.random()
    // Create basic room for given room id
    const room = Room(ipfs, roomID)
    // Once the peer has subscribed to the room, we enable chat,
    // which is bound to the view model's subscribe
    room.on('subscribed', () => {
      // Update view model
      viewModel.subscribed(true)
    })
...

Once our IPFS peer is ready, we await the peer id, update our viewModel’s id property, setup the pubsub Room (here we use a fixed room id, but in practice you’ll likely want to use a query string to specify this… see the example on GitHub), and then subscribe to the subscribed event on the room and link it to our viewModel’s subscribed property. This will automatically enable the chat input box once we have successfully subscribed to the room. So far, so good.

...
    // When we receive a message...
    room.on('message', (msg) => {
      const data = JSON.parse(msg.data) // Parse data
      // Update msg name (default to anonymous)
      msg.name = data.name ? data.name : "anonymous"
      // Update msg text (just for simplicity later)
      msg.text = data.text
      // Add this to _front_ of array to keep at bottom
      viewModel.messages.unshift(msg)
    })
...

Now we subscribe to the Room’s message event, where we specify a callback that parses the msg data (as JSON), updates the username (or uses 'anonymous'), updates the msg text, and then adds the msg object to our viewModel’s messages observable array. Again, pretty straightforward.

...
    viewModel.message.subscribe(async (text) => {
      // If not actually subscribed or no text, skip out
      if (!viewModel.subscribed() || !text) return
      try {
        // Get current name
        const name = viewModel.name()
        // Get current message (one that initiated this update)
        const msg = viewModel.message()
        // Broadcast message to entire room as JSON string
        room.broadcast(Buffer.from(JSON.stringify({ name, text })))
      } catch(err) {
        console.error('Failed to publish message', err)
        viewModel.error(err)
      }
      // Empty message in view model
      viewModel.message('')
    })
    // Leave the room when we unload
    window.addEventListener('unload',
                            async () => await room.leave())
  })
...

Finally, we subscribe to message changes on our view model (likely as a result of user interaction), and specify a callback that will get the current message (msg), the username (name), and broadcast the msg to the entire room as a JSON-encoded string. The makes it possible to type in the input text box and submit the message when the user submits the text. The rest of the code is cleanup and error handling…

Test it

If you go ahead and refresh your app in your browser, and open another window or browser, you should now be able to communicate between windows, similarly to the session depicted here.

A simulated chat session between two browser windows (Firefox and Safari)

You can add even more windows (users) to the chat session (as many as you like), and add additional features like announcing when someone joins or leaves the room, etc. which I’ll leave as an exercise for the reader. In the mean time bask in the glory of knowing you’ve just created a fully-working chat app using a little bit of Javascript, some minimal HTML markup, a tiny bit of CSS, and a whole new appreciation for the decentralized web. The best part about all of this is there are no centralized points of control involved. Of course, we haven’t added in any security measures, encryption, or privacy controls, so think twice before using this to hold real-life conversions over the decentralized web.

Deploy it

And speaking of easy, because this particular dapp doesn’t rely on any external code or locally running peers, we can quite easily deploy it over IPFS. It’s as easy as building the code, adding the dist/ folder to IPFS, and opening it in a browser via a public gateway:

yarn build
hash=$(ipfs add -rq dist/ | tail -n 1)
open https://ipfs.io/ipfs/$hash

That’s all

And there you have it! In this tutorial, we’ve managed to build a fully-working decentralized chat app with minimal code and effort. We’ve kept things pretty simple, but managed to take advantage of real-world programming patterns that make app development a breeze. All in an effort to demonstrate how surprisingly easy it is to develop real-world apps on top of IPFS and its underlying libraries. But before you go decentralizing all the things, make sure you evaluate all the possible downsides as well. If you like what you’ve read here, why not check out some of our other stories and tutorials, or sign up for our Textile Photos wait-list to see what we’re building with IPFS. While you’re at it, drop us a line and tell us what cool distributed web projects you’re working on— we’d love to hear about them!

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.