Jeremy Bellows - Creating a Chatroom Using Phoenix, Elm, and Websockets

Creating a Chatroom Using Phoenix, Elm, and Websockets

-


Creating a chatroom using the Phoenix Web Framework as the backend service and Elm as the frontend is simple.

The first half of this post will cover creating a web socket channel using the Phoenix Web Framework. Phoenix is an excellent framework for newcomers to Elixir.
Want to learn more about Phoenix? Click here!

The second half of this post will cover the elm components of the chatroom application. Elm is a functional programming language that compiles into javascript. The syntax and type safety is similar to F#.
Want to learn more about Elm? Try this guide!

Changelog

03/17 - Updated blog to use Elm 0.18. Thanks Christopher for the pull request!

This post will cover

  • Creating a channel in Phoenix to handle websocket traffic
  • Connecting to a web channel and handling messages using a websocket in Elm

This post assumes the following

Creating a Channel in Phoenix

The easiest way to get started with channels is to use a phoenix generator.

Execute the following command in the root folder of the project.

mix phoenix.gen.channel Room

This will generate a default channel room_channel.ex and a couple of tests room_channel_test.exs to accompany it.

The output for the command looks like

jeremy@jeremy-UX305UA ~/temp/testelm $ mix phoenix.gen.channel Room
* creating web/channels/room_channel.ex
* creating test/channels/room_channel_test.exs

Add the channel to your `web/channels/user_socket.ex` handler, for example:

    channel "room:lobby", Testelm.RoomChannel

Adding the channel to the Socket Handler

Phoenix multiplexes channels over a single connection. The channel that was generated needs to be added to a socket handler. There is a default handler that is defined by phoenix located at ./web/channels/user_socket.ex .

Edit the file ./web/channels/user_socket.ex

Find the code block that looks like the following

defmodule Testelm.UserSocket do
  use Phoenix.Socket

  ## Channels
  # channel "room:*", Testelm.RoomChannel

Add

channel "room:lobby", Testelm.RoomChannel

The code block should look like

defmodule Testelm.UserSocket do
  use Phoenix.Socket

  ## Channels
  # channel "room:*", Testelm.RoomChannel
  channel "room:lobby", Testelm.RoomChannel

Creating the Elm Chatroom Interface

The interface

A simple chatroom consists of 2 main components; the message log and the chat input.

The chat input engages users by allowing interaction with the system. The interface must consist of elements that serve to optimize the user experience so that user engagement is encouraged.

The target minimal interface consists of a textbox to input a message, a button to send the message, and a dynamically generated list of messages.

A possible minimal interface looks like

import Html exposing (Html, div, li, ul, text, form, input, button)

view : Model -> Html Msg
view model =
  div [] [
    ul [] [
     li [] [
      text model
     ]
    ],
    form [] [
     input [] [
     ],
     button [] [
       text "Submit"
     ]
    ]
  ]

Creating the model

The message that is crafted by the user and the messages that are received needs to be stored within a model in the elm application.

The model needs to store a string for the message being constructed and a list of strings for the messages received.

type alias Model =
  {
   messageInProgress : String,
   messages : List String
  }

Initial model

The new model needs to be defined when the application starts.

Change the init function to handle the new model.

init : ( Model, Cmd Msg )
init =
  let
   model =
     {
       messageInProgress = "",
       messages = [ "Test message" ]
     }
  in
   ( model, Cmd.none )

Rendering the messages

Since the messages are stored in a list, the view needs to dynamically render the messages.

Create the function drawMessage to handle message rendering.

drawMessage : String -> Html Msg
drawMessage message =
  li [] [
   text message
  ]


view : Model -> Html Msg
view model =
  let
   drawMessages messages =
    messages |> List.map drawMessage
  in
    div [] [
      ul [] (model.messages |> drawMessages),
      form [] [
       input [] [
       ],
       button [] [
         text "Submit"
       ]
      ]
    ]

At this point the application should render the following screen.

Elm Chatroom with only messages rendering

Using a Web Socket in Elm

There are multiple libraries for Elm that assist with connecting to phoenix web sockets. elm-phoenix-socket is a pure Elm interpretation of the Phoenix Socket library and integrates well into the Elm ecosystem.

Add elm-phoenix-socket as a dependency by running the following command in ./web/elm

elm package install fbonetti/elm-phoenix-socket

The output of the console should look like

jeremy@jeremy-UX305UA ~/temp/testelm/web/elm $ elm package install fbonetti/elm-phoenix-socket

To install fbonetti/elm-phoenix-socket I would like to add the following
dependency to elm-package.json:

    "fbonetti/elm-phoenix-socket": "2.0.0 <= v < 3.0.0"

May I add that to elm-package.json for you? [Y/n] Y

Some new packages are needed. Here is the upgrade plan.

  Install:
    elm-lang/websocket 1.0.1
    fbonetti/elm-phoenix-socket 2.0.0

Do you approve of this plan? [Y/n] Y
Starting downloads...

  ● elm-lang/websocket 1.0.1
  ● fbonetti/elm-phoenix-socket 2.0.0

Packages configured successfully!

Boiler plate code for phoenix sockets

There is some mandatory boiler plate code for the elm-phoenix-socket library that was installed.

In App.elm import the following

import Phoenix.Socket
import Phoenix.Channel
import Phoenix.Push

Add a phoenix socket to the model

type alias Model =
  {
   phxSocket : Phoenix.Socket.Socket Msg,
   messageInProgress : String,
   messages  List : String
  }

Init the socket with the model

init : ( Model, Cmd Msg )
init =
  let
   model =
     {
       phxSocket = Phoenix.Socket.init "ws://localhost:4000/socket/websocket",
       messageInProgress = "",
       messages = [ "Test message" ]
     }
  in
   ( model, Cmd.none )

Add a PhoenixMsg to Msg

type Msg =
    HelloWorld
  | PhoenixMsg (Phoenix.Socket.Msg Msg)

Add the logic to the update case statement for Msg

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    HelloWorld ->
      ( model, Cmd.none )
    PhoenixMsg msg ->
      let
       ( phxSocket, phxCmd ) = Phoenix.Socket.update msg model.phxSocket
      in
       ( { model | phxSocket = phxSocket }
         , Cmd.map PhoenixMsg phxCmd
       )

Add a subscription for listening on the phoenix socket

subscriptions : Model -> Sub Msg
subscriptions model =
  Phoenix.Socket.listen model.phxSocket PhoenixMsg

Sending a message

Storing the user input

The message input box is being rendered but the data is not being stored. The state needs to track user input by storing the value generated from the OnInput event.

In App.elm add SetMessage to Msg, add logic to handle the updated value, and hook the Msg into the html event.

type Msg =
    HelloWorld
  | PhoenixMsg (Phoenix.Socket.Msg Msg)
  | SetMessage String

import Html.Events exposing (onInput)

view : Model -> Html Msg
view model =
  let
   drawMessages messages =
    messages |> List.map drawMessage
  in
    div [] [
      li [] (model.messages |> drawMessages),
      form [] [
       input [ onInput SetMessage ] [
       ],
       button [] [
         text "Submit"
       ]
      ]
    ]

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    HelloWorld ->
      ( model, Cmd.none )
    PhoenixMsg msg ->
      let
       ( phxSocket, phxCmd ) = Phoenix.Socket.update msg model.phxSocket
      in
       ( { model | phxSocket = phxSocket }
         , Cmd.map PhoenixMsg phxCmd
       )
    SetMessage message ->
      ( { model | messageInProgress = message }, Cmd.none )

Sending the message over the channel

In App.elm add a case for SendMessage to update.

type Msg =
    HelloWorld
  | PhoenixMsg (Phoenix.Socket.Msg Msg)
  | SetMessage String
  | SendMessage

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    HelloWorld ->
      ( model, Cmd.none )
    PhoenixMsg msg ->
      let
       ( phxSocket, phxCmd ) = Phoenix.Socket.update msg model.phxSocket
      in
       ( { model | phxSocket = phxSocket }
         , Cmd.map PhoenixMsg phxCmd
       )
    SetMessage message ->
      ( { model | messageInProgress = message }, Cmd.none )
    SendMessage ->
      ( model, Cmd.none )

Constructing the payload

SendMessage will contain the logic for constructing the payload and pushing the payload over the socket.

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    HelloWorld ->
      ( model, Cmd.none )
    PhoenixMsg msg ->
      let
       ( phxSocket, phxCmd ) = Phoenix.Socket.update msg model.phxSocket
      in
       ( { model | phxSocket = phxSocket }
         , Cmd.map PhoenixMsg phxCmd
       )
    SetMessage message ->
      ( { model | messageInProgress = message }, Cmd.none )
    SendMessage ->
      let
        payload =
          JsEncode.object
           [
            ("message", JsEncode.string model.messageInProgress)
           ]
      in
       ({ model }, Cmd.none)

Creating the push command

The push command tells the elm-phoenix-socket library what to send, how to handle errors, and handle successful responses.

The elm application must handle successful and unsuccessful scenarios of web socket communication. Add ReceiveMessage and HandleSendError to the pattern match in update.

I also deleted the HelloWorld case as it wasn’t needed anymore

import Json.Encode as JsEncode

type Msg =
    PhoenixMsg (Phoenix.Socket.Msg Msg)
  | SetMessage String
  | SendMessage
  | Receivemessage JsEncode.Value
  | HandleSendError JsEncode.Value


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    PhoenixMsg msg ->
      let
       ( phxSocket, phxCmd ) = Phoenix.Socket.update msg model.phxSocket
      in
       ( { model | phxSocket = phxSocket }
         , Cmd.map PhoenixMsg phxCmd
       )
    SetMessage message ->
      ( { model | messageInProgress = message }, Cmd.none )
    SendMessage ->
      let
        payload =
          JsEncode.object
           [
            ("message", JsEncode.string model.messageInProgress)
           ]
        phxPush =
          Phoenix.Push.init "shout" "room:lobby"
            |> Phoenix.Push.withPayload payload
            |> Phoenix.Push.onOk ReceiveMessage
            |> Phoenix.Push.onError HandleSendError
      in
       ({ model }, Cmd.none)
    ReceiveMessage _ ->
      ( model, Cmd.none)
    HandleSendError _ ->
      let
       message = "Failed to Send Message"
      in
       ({ model | messages = message :: model.messages }, Cmd.none)

Executing the push command

The push command has been created but needs to be pushed over the socket.

Call the function Phoenix.Socket.push passing the push command and the phoenix socket as parameters.

import Json.Encode as JsEncode

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    HelloWorld ->
      ( model, Cmd.none )
    PhoenixMsg msg ->
      let
       ( phxSocket, phxCmd ) = Phoenix.Socket.update msg model.phxSocket
      in
       ( { model | phxSocket = phxSocket }
         , Cmd.map PhoenixMsg phxCmd
       )
    SetMessage message ->
      ( { model | messageInProgress = message }, Cmd.none )
    SendMessage ->
      let
        payload =
          JsEncode.object
           [
            ("message", JsEncode.string model.messageInProgress)
           ]
        phxPush =
          Phoenix.Push.init "shout" "room:lobby"
            |> Phoenix.Push.withPayload payload
            |> Phoenix.Push.onOk ReceiveMessage
            |> Phoenix.Push.onError HandleSendError
        (phxSocket, phxCmd) = Phoenix.Socket.push phxPush model.phxSocket
      in
       (
        {
          model |
           phxSocket = phxSocket
        },
        Cmd.map PhoenixMsg phxCmd
       )
    ReceiveMessage _ ->
      ( model, Cmd.none)
    HandleSendError _ ->
      let
       message = "Failed to Send Message"
      in
       ({ model | messages = message :: model.messages }, Cmd.none)

Joining the channel

The Phoenix socket is being initalized in the init function. In order for messages to be sent over the channel, a room must be joined.

Change the init function to pipe the socket after initialization into the join function

init : ( Model, Cmd Msg )
init =
  let
   initSocket =
     Phoenix.Socket.init "ws://localhost:4000/socket/websocket"
     |> Phoenix.Socket.withDebug
     |> Phoenix.Socket.on "shout" "room:lobby" ReceiveMessage
   model =
     {
       phxSocket = initSocket,
       messageInProgress = "",
       messages = [ "Test message" ]
     }
  in
   ( model, Cmd.none )

Hooking into the OnSubmit event for the chat input form

Hook the SendMessage Msg into the OnSubmit event for the chat input form so that user input is tracked in the state.

import Html.Events exposing (onInput, onSubmit)

view : Model -> Html Msg
view model =
  let
   drawMessages messages =
    messages |> List.map drawMessage
  in
    div [] [
      li [] (model.messages |> drawMessages),
      form [ onSubmit SendMessage] [
       input [ onInput SetMessage ] [
       ],
       button [] [
         text "Submit"
       ]
      ]
    ]

Receiving a Message in Elm

ReceiveMessage contains a Json.Encode.Value type that needs to be decoded then added to the message list.

import Json.Decode as JsDecode


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
....
    ReceiveMessage raw ->
      let
       someMessage = JsDecode.field "message" JsDecode.string
      in
       case someMessage of
         Ok message ->
           (
            { model | messages = message :: model.messages },
            Cmd.none
           )
         Err error ->
           ( model, Cmd.none )

Try it out!

At this point, the code should work. The next step is to write tests. There should be tests for the phoenix channel that was generated already (located in the file room_channel_test.exs

Summary

This post covered how to create a simple chatroom. The steps this post covered are

  • Generating a channel in Phoenix
  • Sending and Receiving messages over a websocket in elm

Questions?
Comment below or reach out over twitter!
@JeremyBellows
@elixirlang
@elmlang
@elixirphoenix

Sample code from this post is located on Github

https://github.com/JeremyBellows/ElmPhoenixChatroom

There’s also a prototype chatroom constructed when researching this post

https://github.com/JeremyBellows/chilixelm