[011.2] elm-phoenix-socket

Building an Elm client to an Elixir Phoenix-based chat application using the elm-phoenix-socket package.

Subscribe now

elm-phoenix-socket [06.06.2016]

In today's episode, we're going to look at elm-phoenix-socket, which is a pure Elm interpretation of the Phoenix.Socket library that comes bundled with the Phoenix web framework.

Hey, this is an unusually long episode, enjoy!

Project

I've tagged a Phoenix.Presence-based chat application with before_episode_011.2. You'll need this pulled down and running in order to use it as your communications endpoint. Here's how you can get it running:

git clone git@github.com:knewter/presence_chat
cd presence_chat
git checkout before_episode_011.2
mix deps.get
mix ecto.create
# NOTE: The above assumes that you have a postgres server running, and that the credentials in config/dev.exs are valid for interacting with it.  If they aren't, there are a host of different possible configurations, so you'll just want to ensure that you can connect to the server and create a database.  If you have specific issues with your configuration, please ping me (josh) in our slack: http://join-community.dailydrip.com.  We'll help get it sorted.
npm install
# NOTE: It looks like you also need to install a couple of babel presets that in my opinion should have been listed as peerDependencies on whatever version change has happened.  So do this:
npm install --save-dev babel-preset-es2015
npm install --save-dev babel-preset-es2016
mix phoenix.server

For what it's worth, we built this application in this episode in the Elixir topic.

Now it's running. You can see the chat application in your browser, by visiting http://localhost:4000. enter a username and start chatting. Open it up in multiple tabs, on other devices, etc. and check it out. We'll leave that running in a tab.

Next, we'll start a new elm project to interact with it:

mkdir elm_presence_chat
cd elm_presence_chat
elm package install -y fbonetti/elm-phoenix-socket
elm package install -y elm-lang/html

Now we'll build out an application. I'm not going to build up the standard application structure just out of expediency, and because we can use the reactor here.

vim Main.elm

We'll start out just getting a basic app structure in place. I'll just paste it in and chat through it so we can get on to the phoenix bits more quickly:

module Main exposing (..)

import Html.App as App
import Html exposing (..)
import Html.Attributes exposing (value, placeholder, class)
import Html.Events exposing (onInput, onClick)


-- Our model will track a list of messages and the text for our new message to
-- send.  We only support chatting in a single channel for now.
type alias Model =
    { newMessage : String
    , messages : List String
    }


-- We can either set our new message or join our channel
type Msg
    = SetNewMessage String
    | JoinChannel


-- Basic initial model is straightforward
initialModel : Model
initialModel =
    { newMessage = ""
    , messages = []
    }


-- We'll handle either setting the new message or joining the channel.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SetNewMessage string ->
            { model | newMessage = string } ! []

        JoinChannel ->
            model ! []


-- Our view will consist of a button to join the lobby, a list of messages, and
-- our text input for crafting our message
view : Model -> Html Msg
view model =
    div []
        -- Clicking the button joins the lobby channel
        [ button [ onClick JoinChannel ] [ text "Join lobby" ]
        , div [ class "messages" ]
            [ text "fake incoming message"
            ]
          -- On input, we'll SetNewMessage
        , input [ placeholder "Message...", onInput SetNewMessage, value model.newMessage ] []
        ]


-- Wire together the program
main : Program Never
main =
    App.program
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }


-- No subscriptions yet
subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none


-- And here's our init function
init : ( Model, Cmd Msg )
init =
    ( initialModel, Cmd.none )

OK, so nothing here is new. Let's fire up the elm-reactor and make sure that it's all working.

elm-reactor # and visit http://localhost:8000

Next, we'll initialize the elm-phoenix-socket component:

-- Add the imports we need
import Phoenix.Socket
import Phoenix.Channel
import Phoenix.Push

-- Modify our model to track the socket data
type alias Model =
    { newMessage : String
    , messages : List String
    , phxSocket : Phoenix.Socket.Socket Msg
    }


-- We'll define initializing a phoenix socket in its own function
initialModel : Model
initialModel =
    { newMessage = ""
    , messages = []
    , phxSocket = initPhxSocket
    }


-- We need the URL for the websocket.  This will be the phoenix server url, then
-- the route for the socket, then "websocket" because that's the transport we're
-- communicating with.
socketServer : String
socketServer =
    "ws://localhost:4000/socket/websocket"


-- initPhxSocket uses Phoenix.Socket.init on the socketServer, and we pipe it
-- through Phoenix.Socket.withDebug so we can get debug information out of it.
-- This will print every incoming Phoenix message to the console.
initPhxSocket : Phoenix.Socket.Socket Msg
initPhxSocket =
    Phoenix.Socket.init socketServer
        |> Phoenix.Socket.withDebug

OK, so this is enough to get us initializing the socket server. Let's run it and see if it connects.

((( test it, look in the phoenix server logs )))

Well, if it had connected, we'd see a notice in the logs here. Let's keep moving on.

-- We'll add the code necessary to join the channel
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- ...
        JoinChannel ->
            let
                channel =
                    Phoenix.Channel.init "room:lobby"

                ( phxSocket, phxCmd ) =
                    Phoenix.Socket.join channel model.phxSocket
            in
                ( { model | phxSocket = phxSocket }
                , Cmd.map PhoenixMsg phxCmd
                )

-- We need to add the `PhoenixMsg` message type
type Msg
    = SetNewMessage String
    | JoinChannel
    | PhoenixMsg (Phoenix.Socket.Msg Msg)

-- We have to handle the `PhoenixMsg` in our update function:
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
                )

-- And we also need to include the subscriptions for Phoenix:
subscriptions : Model -> Sub Msg
subscriptions model =
    Phoenix.Socket.listen model.phxSocket PhoenixMsg

Alright, let's check it out again and see if this is sufficient to connect.

((( split with the terminal on the left and the browser on the right )))

Refresh the page but don't bother clicking the Join Lobby button yet. That's got us working. So I guess you have to have wired up the subscriptions in order to successfully connect.

Now if you click the join button, we should see our socket connect to that channel...and it works.

Let's open up another tab to see the non-elm interface in the phoenix chat and see if we can see our user joining:

((( open up http://localhost:4000, fill in a username )))

Now if we join, we can see in the phoenix interface that our user's in the channel. So we've got this...working...but we probably want to see messages and be able to send them. Let's get that done next.

We'll start out by sending messages. To do that, we'll introduce a SendMessage Msg and wire it in:

-- We'll wrap our input in a form so we can just use the form's onSubmit for our
-- message.
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick JoinChannel ] [ text "Join lobby" ]
        , div [ class "messages" ]
            [ text "fake incoming message"
            ]
        , form [ onSubmit SendMessage ]
            [ input [ placeholder "Message...", onInput SetNewMessage, value model.newMessage ] [] ]
        ]


-- Which means we need to expose onSubmit
import Html.Events exposing (onInput, onClick, onSubmit)


-- We'll add a SendMessage Msg
type Msg
    = SetNewMessage String
    | JoinChannel
    | PhoenixMsg (Phoenix.Socket.Msg Msg)
    | SendMessage


-- Sending a message needs to encode it first, so we pull in Json.Encode and
-- alias it so we don't have to type so much
import Json.Encode as JE


-- We add a case branch for SendMessage
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- ...
        SendMessage ->
            let
                -- We'll build our message out as a json encoded object
                payload =
                    (JE.object [ ( "body", JE.string model.newMessage ) ])

                -- We prepare to push the message
                push' =
                    Phoenix.Push.init "new:msg" "room:lobby"
                        |> Phoenix.Push.withPayload payload

                -- We update our `phxSocket` and `phxCmd` by passing this push
                -- into the Phoenix.Socket.push function
                ( phxSocket, phxCmd ) =
                    Phoenix.Socket.push push' model.phxSocket
            in
                -- And we clear out the `newMessage` field, update our model's
                -- socket, and return our Phoenix command
                ( { model
                    | newMessage = ""
                    , phxSocket = phxSocket
                  }
                , Cmd.map PhoenixMsg phxCmd
                )

That's sufficient to send a message. Let's go ahead and check it out, verifying that we receive the message on the phoenix side.

((( do that )))

OK, so all we have left to do is add the ability to receive messages. To do this, we'll add a new Msg and handle it in our update:

-- When the message comes in we'll have to decode it
-- We'll also expose := to use in our decoder
import Json.Decode as JD exposing ((:=))


type Msg =
    -- ...
    | ReceiveChatMessage JE.Value


-- We want to introduce a ChatMessage type. it has a user and a body
type alias ChatMessage =
    { user : String
    , body : String
    }


-- We'll modify our Model to contain a list of these rather than strings
type alias Model =
    { newMessage : String
    , messages : List ChatMessage
    , phxSocket : Phoenix.Socket.Socket Msg
    }


-- In our update, we'll gather the chat messages after decoding them.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- ...
        ReceiveChatMessage raw ->
            case JD.decodeValue chatMessageDecoder raw of
                Ok chatMessage ->
                    ( { model | messages = chatMessage :: model.messages }
                    , Cmd.none
                    )

                Err error ->
                    ( model, Cmd.none )


-- Our decoder will just decode a 2-field json object
chatMessageDecoder : JD.Decoder ChatMessage
chatMessageDecoder =
    JD.object2 ChatMessage
        ("user" := JD.string)
        ("body" := JD.string)


-- Finally, we need to tell our Phoenix integration that we want to emit this
-- Msg when we receive a "new:msg" on the channel
initPhxSocket : Phoenix.Socket.Socket Msg
initPhxSocket =
    Phoenix.Socket.init socketServer
        |> Phoenix.Socket.withDebug
        |> Phoenix.Socket.on "new:msg" "room:lobby" ReceiveChatMessage

-- With that, we should be receiving the messages and placing them in our model.
-- Now we just need to show them in the view:

-- We'll define a function that turns a ChatMessage into Html
viewMessage : ChatMessage -> Html Msg
viewMessage message =
    div [ class "message" ]
        [ span [ class "user" ] [ text (message.user ++ ": ") ]
        , span [ class "body" ] [ text message.body ]
        ]


-- And we'll use that view in place of our fake messages
view : Model -> Html Msg
view model =
    div []
        [ button [ onClick JoinChannel ] [ text "Join lobby" ]
        , div [ class "messages" ]
            (List.map viewMessage model.messages)
        , form [ onSubmit SendMessage ]
            [ input [ placeholder "Message...", onInput SetNewMessage, value model.newMessage ] [] ]
        ]

If we refresh and type something...we don't see any messages on our side. Let's go to the phoenix side and send a message.

OK, we see messages from the Phoenix side. Why is this? The use of Phoenix.Socket.withDebug is helpful here. We'll open up the developer console and we can see the messages. We can see that the message from the Phoenix app has a user:

Phoenix message: { event = "new:msg", topic = "room:lobby", payload = { user = "knewter", body = "heya" }, ref = Nothing }

However, our message does not:

Phoenix message: { event = "phx_reply", topic = "room:lobby", payload = { status = "ok", response = { msg = "wat" } }, ref = Just 2 }

This is because our Phoenix application expects us to initiate the socket with an object that tells it what our user is. We can't easily do that for now, so we'll modify our chatMessageDecoder to default to an anonymous user if the user field is missing, rather than have it reject the message:

chatMessageDecoder : JD.Decoder ChatMessage
chatMessageDecoder =
    JD.object2 ChatMessage
        (JD.oneOf
            [ ("user" := JD.string)
            , JD.succeed "anonymous"
            ]
        )
        ("body" := JD.string)

If we try it now, we can see our messages as well.

Summary

So that's it! In today's episode, we saw how to build a chat system that talks to Phoenix from scratch, using elm-phoenix-socket. We only support a single channel for now, but it's just a matter of extending the model a bit to be able to easily track messages across multiple channels. Go out and build awesome stuff with Elm and Phoenix Channels. See you soon!

Resources

Code Listing

Here's the completed Main.elm module:

module Main exposing (..)

import Html.App as App
import Html exposing (..)
import Html.Attributes exposing (value, placeholder, class)
import Html.Events exposing (onInput, onClick, onSubmit)
import Phoenix.Socket
import Phoenix.Channel
import Phoenix.Push
import Json.Encode as JE
import Json.Decode as JD exposing ((:=))


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


type Msg
    = SetNewMessage String
    | JoinChannel
    | PhoenixMsg (Phoenix.Socket.Msg Msg)
    | SendMessage
    | ReceiveChatMessage JE.Value


type alias ChatMessage =
    { user : String
    , body : String
    }


initialModel : Model
initialModel =
    { newMessage = ""
    , messages = []
    , phxSocket = initPhxSocket
    }


socketServer : String
socketServer =
    "ws://localhost:4000/socket/websocket"


initPhxSocket : Phoenix.Socket.Socket Msg
initPhxSocket =
    Phoenix.Socket.init socketServer
        |> Phoenix.Socket.withDebug
        |> Phoenix.Socket.on "new:msg" "room:lobby" ReceiveChatMessage


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SetNewMessage string ->
            { model | newMessage = string } ! []

        JoinChannel ->
            let
                channel =
                    Phoenix.Channel.init "room:lobby"

                ( phxSocket, phxCmd ) =
                    Phoenix.Socket.join channel model.phxSocket
            in
                ( { model | phxSocket = phxSocket }
                , Cmd.map PhoenixMsg phxCmd
                )

        PhoenixMsg msg ->
            let
                ( phxSocket, phxCmd ) =
                    Phoenix.Socket.update msg model.phxSocket
            in
                ( { model | phxSocket = phxSocket }
                , Cmd.map PhoenixMsg phxCmd
                )

        SendMessage ->
            let
                payload =
                    (JE.object [ ( "body", JE.string model.newMessage ) ])

                push' =
                    Phoenix.Push.init "new:msg" "room:lobby"
                        |> Phoenix.Push.withPayload payload

                ( phxSocket, phxCmd ) =
                    Phoenix.Socket.push push' model.phxSocket
            in
                ( { model
                    | newMessage = ""
                    , phxSocket = phxSocket
                  }
                , Cmd.map PhoenixMsg phxCmd
                )

        ReceiveChatMessage raw ->
            case JD.decodeValue chatMessageDecoder raw of
                Ok chatMessage ->
                    ( { model | messages = chatMessage :: model.messages }
                    , Cmd.none
                    )

                Err error ->
                    ( model, Cmd.none )


chatMessageDecoder : JD.Decoder ChatMessage
chatMessageDecoder =
    JD.object2 ChatMessage
        (JD.oneOf
            [ ("user" := JD.string)
            , JD.succeed "anonymous"
            ]
        )
        ("body" := JD.string)


view : Model -> Html Msg
view model =
    div []
        [ button [ onClick JoinChannel ] [ text "Join lobby" ]
        , div [ class "messages" ]
            (List.map viewMessage model.messages)
        , form [ onSubmit SendMessage ]
            [ input [ placeholder "Message...", onInput SetNewMessage, value model.newMessage ] [] ]
        ]


viewMessage : ChatMessage -> Html Msg
viewMessage message =
    div [ class "message" ]
        [ span [ class "user" ] [ text (message.user ++ ": ") ]
        , span [ class "body" ] [ text message.body ]
        ]


main : Program Never
main =
    App.program
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }


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


init : ( Model, Cmd Msg )
init =
    ( initialModel, Cmd.none )