Nithin Bekal About

Building a chat application using Elixir and Phoenix

11 Jul 2015

I’ve been looking at Phoenix channels lately, and going through Chris McCord’s Phoenix chat example gives a great intro to getting started with it. In this post, we’ll be walking through the process of building the same app step by step.

We’ll use Elixir 1.0.5 and Phoenix 1.0.0. (This was originally written for Phenix 0.14, but was later updated for 1.0.0.) We’ll also be using ES6 instead of Javascript, but anyone familiar with JS should be able to follow along. I’ll add notes explaining the ES6 code wherever we’re looking at features not available in Javascript.

Our app will be called “Chatter”, and will contain a single page where all connected users can post chat messages. Let’s get started, and generate the project:

mix phoenix.new chatter

Add the HTML markup

The first thing we’ll do is add the HTML markup for the form. We’ll be adding the chat functionality to the default page generated by themix command. Replace the html code in web/templates/pages/index.html.eex with this:

<div id="messages"></div>
<br/>

<div class="col-md-2 form-group">
  <label>Username</label>
  <input id="username" type="text" class="form-control" />
</div>

<div class="col-md-6 form-group">
  <label>Message</label>
  <input id="message" type="text" class="form-control" />
</div>

We will render the chat messages to the #messages div. To keep things simple, users will enter their username and a message to the respective input fields and hit enter to send the message.

Add jQuery

We’re going to be using jQuery for the client side code. Add the following line above the first <script> tag in web/templates/layouts/app.html.eex.

<script src="http://code.jquery.com/jquery-2.1.4.min.js"></script>

(I’m using this approach for loading jQuery only so that we can get started quickly. A better approach would be to use Bower to fetch jquery, since that would also help you manage other Javascript libraries as well.)

Setup app.js

Let’s set up the front end code before we start writing the Elixir code to handle the messages. We will be writing our code in web/static/app.js. The App.init() function will be invoked on page load and it is will set up handlers for form submission and for receiving messages over the channel.

import {Socket} from "deps/phoenix/web/static/js/phoenix"

class App {
  static init() {
    console.log("Initialized")
  }
}

$( () => App.init() )

export default App

If you’re unfamiliar with ES6 syntax, here’s an outline of what’s happening here: The first line imports the Socket object from web/static/vendon/phoenix.js. The static init() adds the function to the App object so that we can invoke it as App.init() The $( () => ... ) line uses the arrow syntax for functions and is equivalent to $(function() { App.init() }).

If you’re not familiar with jQuery, the $() function gets called when the page is loaded. Save this file and make sure the text “Initialized” is getting logged to the browser console.

Handling form submission on the client side

Before we look at Phoenix channels, let’s flesh out the init() function to handle form submission. For now, we will log the form inputs to the browser console.

We need to submit the fields when the user hits enter from the #message text field. Let’s change the App.init() function so that it handles that event.

static init() {
  var username = $("#username")
  var msgBody  = $("#message")

  msgBody.off("keypress")
    .on("keypress", e => {
      if (e.keyCode == 13) {
        console.log(`[${username.val()}] ${msgBody.val()}`)
        msgBody.val("")
      }
    })
}

Here we are clearing all the keypress event handlers and then adding a new one which logs the contents of the username and message inputs to the console when the enter key is pressed (key code = 13). We’re using the sting interpolation feature of ES6 in the console.log line.

Adding channel routes

A UserSocket module will be present in web/channels/user_socket.ex. This module is used for handling socket authentication in a single place.

We just need to add one line to the module.

    channel "rooms:*", Chatter.RoomChannel

With this route in place, whenever we send a message from the browser with a topic that starts with rooms:, it will be handled by RoomChannel.

Joining a channel

Having set up the socket routes, let’s go back to the font end. We’ll change the App.init() function so that it connects to the channel on the page load event.

static init() {
  // var msgBody = ...

  let socket = new Socket("/socket")
  socket.connect()
  socket.onClose( e => console.log("Closed connection") )

  var channel = socket.channel("rooms:lobby", {})
  channel.join()
    .receive( "error", () => console.log("Connection error") )

  // msgBody.off("keypress") ...
}

At this point, you will see the “Connection error” message being logged to the browser console. If you see the Phoenix server log, you will find an exception that looks like this:

[error] an exception was raised:
    ** (UndefinedFunctionError) undefined function: Chatter.RoomChannel.join/3
    (module Chatter.RoomChannel is not available)
    ...
[info] Replied rooms:lobby :error

This is because we haven’t added RoomChannel yet. Let’s go ahead and add it.

Adding RoomChannel

The first thing we need to do with RoomChannel is to write a function join/3 that will be called when we connect to the channel.

defmodule Chatter.RoomChannel do
  use Phoenix.Channel

  def join("rooms:lobby", message, socket) do
    {:ok, socket}
  end
end

Here, we’re only considering a room with the topic rooms:lobby. The join/3 function takes the topic (“rooms:lobby”), an authentication message, and the socket. We return the tuple {:ok, socket} to indicate that the user has connected successfully.

Handling the :ok response

When the server responds with ok message, we need to handle the response on the client. We’ve previously seen how we can respond to the :error message. We can similarly handle the :ok response. Append the new receive hook to the channel in App.init():

channel.join()
  .receive( "error", () => console.log("Failed to connect") )
  .receive( "ok",    () => console.log("Connected") )

This time you will see the message “Connected” on the browser console. (You might need to restart the Phoenix server first).

Pushing messages from client to server

The next thing we want to do is to push the data to the server when the user submits a message. We will modify the keypress event handler like this:

msgBody.off("keypress")
  .on("keypress", e => {
    if (e.keyCode == 13) {
      channel.push("new:message", {
        user: username.val(),
        body: msgBody.val()
      })
      msgBody.val("")
    }
  })

Instead of logging the username and message body to the browser console, we are pushing the message to the server with the name “new:message” and a payload that contains the username and message body.

Handling incoming messages on server

We can handle an incoming message in RoomChannel using a handle_in/3 function. In this case, we will broadcast the incoming message to all subscribers.

# web/channels/room_channel.ex
def handle_in("new:message", msg, socket) do
  broadcast! socket, "new:message", %{user: msg["user"], body: msg["body"]}
  {:noreply, socket}
end

Receiving broadcast messages from the client

In order to handle the broadcast message, we will add the following code at the end of App.init() function in app.js:

channel.on( "new:message", msg => console.log(msg.body) )

To see this working, open localhost:4000 in two separate tabs, and submit a message. You will see the message body logged to the console in both tabs.

Rendering the messages to the page

So far we’ve been logging the messages to the console. Let’s start displaying the messages on the browser and make this look like a real chat app.

For this, we will add a renderMessage function and call that instead of console.log when we receive a new message. We already have a #messages div in the template, and we can append the messages to it by making these changes:

static init() {
  // ...
  channel.on( "new:message", msg => this.renderMessage(msg) )
}

static renderMessage(msg) {
  var messages = $("#messages")
  messages.append(`<p><b>[${msg.user}]</b>: ${msg.body}</p>`)
}

At this point, you will be able to send chat messages between the two tabs and see the messages displayed in the page.

Sanitizing input

Since we are appending the messages directly to the #messages div, someone could include malicious Javascript in their message and have it executed in the browser of all the subscribers of the channel.

As an example, try sending a message containing the text <script>alert("LOLOLOL!")</script> from one of the tabs. This will result in an alert box being opened in all the tabs that are connected to the channel.

To prevent this, we make the following changes to the renderMessage function:

static renderMessage(message) {
  var messages = $("#messages")
  var user = this.sanitize(message.user || "New User")
  var body = this.sanitize(message.body)

  $messages.append(`<p><b>[${user}]</b>: ${body}</p>`)
}

static sanitize(str) { return $("<div/>").text(str).html() }

Now you can try sending the same message and you will see that the text is displayed exactly as you sent it, and the script doesn’t get executed.

Next steps

Now that we have created a common lobby for our chatroom, you could also allow users to connect to rooms with a different names. Or handle other cases like broadcasting a message when a new user joins. If you wish to explore Phoenix channels and try adding such features, take a look at the links below.

Links

Hi, I’m Nithin! This is my blog about programming. Ruby is my programming language of choice and the topic of most of my articles here, but I occasionally also write about Elixir, and sometimes about the books I read. You can use the atom feed if you wish to subscribe to this blog or follow me on Mastodon.