[016.2] Componentizing Chat


Parent <-> Child communication by way of building the beginnings of a Chat module that's a TEA Component.

Subscribe now

Recently we built a basic Phoenix-based chat client. We'd like to add the ability to have our chat client in multiple channels at the same time. First, we're going to extract a Chat component that can be reused multiple times. Let's get started.

NOTE: You should definitely check out the guide on reuse. It wasn't available when this was written but lots of experimentation shows it to be the right first step for what we're calling components here.

Project

You can download the example repo that contains our starting point with tag before_episode_016.2. It's the same code that we used from before, with a tiny tweak to the backend.

Let's quickly review the elm client.

# in one terminal
cd presence_chat
iex -S mix phoenix.server

# in another
cd elm-client
elm-reactor

We'll visit http://localhost:4000 and http://localhost:8000 in two different tabs and navigate to the Main.elm file in the reactor. And here's our chat that we know and love.

Now we'd like to support multiple channels on the same page. Think of this as building a chat client like Hangouts. Let's make a clean separation - we'll have the Main module wire things up and maintain a top level model and the phoenix socket, and we'll mount multiple Chat components inside of it based on the chats you join.

First, let's just break out the Chat into its own module for the view bits.

vim src/Main.elm
# :e src/Chat.elm
module Chat exposing (view)

-- Move functions over from Main into here until the app works
import Html exposing (..)
import Html.Attributes exposing (value, placeholder, class)
import Html.Events exposing (onInput, onClick, onSubmit)


view model =
    div []
        [ messageListView model
        , messageInputView model
        , userListView model
        ]



--messageListView : Model -> Html Msg
messageListView model =
    div [ class "messages" ]
        (List.map viewMessage model.messages)



--messageInputView : Model -> Html Msg
messageInputView model =
    --form [ onSubmit SendMessage ]
    form []
        --[ input [ placeholder "Message...", onInput SetNewMessage, value model.newMessage ] [] ]
        [ input [ placeholder "Message...", value model.newMessage ] [] ]



--userListView : Model -> Html Msg
userListView model =
    ul [ class "users" ]
        (List.map userView model.users)



--userView : User -> Html Msg
userView user =
    li []
        [ text user.name
        ]



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

So this works, but we can't type annotate it. Why, you ask? Well, Main imports Chat, and in order to use the Main Msg type, Chat would need to import Main as well. This would lead to a circular reference, the gates of hell would open, etc., etc.

Let's keep refactoring. Next, we'd like the ability to have multiple Chats at the same time. Let's start out by removing the hard-coded room:lobby piece that connects the phoenix channel to a given topic on JoinChannel:

type Msg
    = SetNewMessage String
    | JoinChannel String <--
    -- ...

-- Then we have to handle it in the update:
-- ...
        JoinChannel channelName ->
            case model.phxSocket of
                -- ...
                Just modelPhxSocket ->
                    let
                        channel =
                            Phoenix.Channel.init channelName

-- And finally, in the lobbyManagementView
lobbyManagementView : Html Msg
lobbyManagementView =
    button [ onClick (JoinChannel "room:lobby") ] [ text "Join lobby" ]

So that was just a straight refactoring, nothing changed so we can't have introduced bugs. Now it's possible to specify which room we want to join. At this point, we'll move the state for the chat into a Chat.Model, and specify a single chat field in our Main.Model:

vim src/Main.elm
-- We'll  move the `newMessage`, `messages`, and `users` fields to the
-- `Chat.Model`.
type alias Model =
    { username : String
    , chat : Chat.Model
    , phxSocket : Maybe (Phoenix.Socket.Socket Msg)
    , phxPresences : PresenceState UserPresence
    }


-- `Chat` will also be able to `SetNewMessage`, `ReceiveChatMessage`, and
-- `SendMessage`, sort of.
type Msg
    = JoinChannel String
    | PhoenixMsg (Phoenix.Socket.Msg Msg)
    | SetUsername String
    | ConnectSocket
    | HandlePresenceState JE.Value
    | HandlePresenceDiff JE.Value


-- And we'll update the initialModel
initialModel : Model
initialModel =
    { username = ""
    , chat = Chat.initialModel
    , phxSocket = Nothing
    , phxPresences = Dict.empty
    }
vim src/Chat.elm
module Chat exposing (view, initialModel, Model)

type alias Model =
    { newMessage : String
    , messages : List ChatMessage
    , users : List User
    }


type Msg
    = SetNewMessage String
    | ReceiveChatMessage JE.Value
    | SendMessage


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

OK, so this is in a broken state we're pretty sure, but the next step isn't clear necessarily. Let's let the compiler be a friend.

We have no idea about the ChatMessage type. Let's pull that over, and the User type:

type alias User =
    { name : String
    }


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

Now it's just whining about JE.Value, so we can copy over the JSON bits because we know we'll need 'em both eventually.

import Json.Encode as JE
import Json.Decode as JD exposing ((:=))

So now if we refresh, we see an error about ReceiveChatMessage. Interestingly, this is another spot where we'd hardcoded our channel. We'll just remove this for now, and we'll no longer be told about messages on this topic from Phoenix.

initPhxSocket : String -> Phoenix.Socket.Socket Msg
initPhxSocket username =
    --|> Phoenix.Socket.on "new:msg" "room:lobby" ReceiveChatMessage -- <-- removed this
    Phoenix.Socket.init (socketServer username)
        |> Phoenix.Socket.withDebug
        |> Phoenix.Socket.on "presence_state" "room:lobby" HandlePresenceState
        |> Phoenix.Socket.on "presence_diff" "room:lobby" HandlePresenceDiff

Next, we have problems with our update function referencing Msg types we've moved to chat. We'll just introduce an update function for chat and move those over, as well as the chatMessageDecoder that ReceiveChatMessage needs:

vim src/Chat.elm
-- We'll comment out most of them for the time being
update : Msg -> Model -> Model
update msg model =
    case msg of
        SetNewMessage string ->
            { model | newMessage = string }

        SendMessage ->
            -- case model.phxSocket of
            --     Nothing ->
            --         model ! []
            --
            --     Just modelPhxSocket ->
            --         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' modelPhxSocket
            --         in
            --             ( { model
            --                 | newMessage = ""
            --                 , phxSocket = Just phxSocket
            --               }
            --             , Cmd.map PhoenixMsg phxCmd
            --             )
            model

        ReceiveChatMessage raw ->
            case JD.decodeValue chatMessageDecoder raw of
                Ok chatMessage ->
                    { model | messages = model.messages ++ [ chatMessage ] }

                Err error ->
                    model


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

We also need to open up the Main module and pass our chat model into the Chat.view function instead of our main model:

chatInterfaceView : Model -> Html Msg
chatInterfaceView model =
    div []
        [ lobbyManagementView
        , Chat.view model.chat
        ]

So now if we click through it, all that works is we can see the user join from the phoenix side of things. That doesn't feel like progress yet. But it really is. We have almost built a TEA component that encapsulates the idea of a chat channel, which means it would be reusable. Let's get it working to receive messages again now.

We stopped listening to new:msgs from the phoenix channel earlier. We'll move this down into the JoinChannel branch of the update statement, and use the channelName to determine what we want to listen to messages for.

-- We'll add a `Msg` to `ReceiveChatMsg` for a given channel name and trigger
-- that when messages come in over this phoenix channel.  It will get the
-- channel it's coming in on, and the value itself, for us to route it.
type Msg
    -- ...
    | ReceiveChatMessage String JE.Value


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        JoinChannel channelName ->
            case model.phxSocket of
                Nothing ->
                    model ! []

                Just modelPhxSocket ->
                    let
                        channel =
                            Phoenix.Channel.init channelName

                        ( phxSocket, phxCmd ) =
                            Phoenix.Socket.join channel modelPhxSocket

                        phxSocket2 =
                            phxSocket
                                |> Phoenix.Socket.on "new:msg" channelName (ReceiveChatMessage channelName)
                    in
                        ( { model | phxSocket = Just phxSocket2 } -- <-- Make sure you use the second one here!
                        , Cmd.map PhoenixMsg phxCmd
                        )

And now we have to handle the ReceiveChatMessage Msg:

        ReceiveChatMessage channelName chatMessage ->
            let
                newChat =
                    model.chat
                        |> Chat.update (Chat.ReceiveMessage chatMessage)
            in
                { model | chat = newChat } ! []

With that, we can receive chat messages in our Chat component, and we're almost to the point where we need to send messages back out of this component to the Root.

Summary

In today's episode we extracted a Chat component and plugged it into our Phoenix chat client application. We aren't finished yet, and it's not a full component in The Elm Architecture style just yet, but it mostly does what we need of it.

As it's been built, the Chat component is responsible for decoding incoming JSON. I typically prefer to turn inbound messages into my types as early as possible, and turn outbound messages into transfer forms as late as possible, so this design doesn't sit well with me. However, we also can't have the Main module being concerned with decoding all of these. I think the right move is probably to pass a decoder out of the module and pass the decoded values into the component when we ReceiveMessage.

Anyway, we'll soon have to send messages out of our components to complete the chat application rewrite, and we're going to start off with something like the OutMsg pattern. I'm looking forward to it. See you soon!

Resources

  • knewter/phoenix-elm-chat - An example repo that contains both the phoenix backend and the elm client that we built in earlier episodes. Tagged with before_episode_016.2

Published on 07.24.2016