#38: Chat Room in 8 Minutes

March 25, 2018 0 Comments

#38: Chat Room in 8 Minutes

 

 

Phoenix is the most popular Framework for web development in Elixir.

And one of it’s most exciting parts is the ability to add realtime functionality, which we can do with Channels.

In this episode let’s see how can build a simple chat application with Phoenix using websockets.

Let’s start this from project from the ground-up, so the first thing we’ll need is a fresh Phoenix app - let’s create a new one with the very unoriginal name ‘chat’.

$ mix phx.new chat

And we’ll install the dependencies.

Then we’ll cd into the ‘chat’ directory and create our database:

$ mix ecto.create

And let’s start our app to make sure everything looks good.

$ mix phx.server

And great - we see the familiar “Welcome to Phoenix!” page.

Now that we know everything is working let’s get started on building our chat room.

We’ll need to generate a new channel.

Phoenix provides a generator we can use, we’ll just need to give it a name. Let’s play with the idea of a chat-room and call ours water_cooler

$ mix phx.gen.channel water_cooler

And it creates a water_cooler_channel.ex module

We’ll look at it in a minute, but first let’s add our channel to the user socket handler.

We’ll send any events with the water_cooler topic to our new ‘WaterCoolerChannel’

The asterisk is a wildcard, catching any events coming into the water_cooler topic - regardless of the subtopic - and sending them to our WaterCoolerChannel

lib/chat_web/channels/user_socket.ex

defmodule ChatWeb.UserSocket do use Phoenix.Socket ##channels channel "water_cooler:*", ChatWeb.WaterCoolerChannel ...
end

With that updated let’s open the water_cooler_channel.ex that we generated.

It has a join callback that will handle events for the water_cooler:lobby topic/subtopic.

In this example that Phoenix creates, it’s giving us an authorized? function that will always return true. It’s a nice guide for where we could put authorization logic.

In our example we won’t do any authorization, so let’s remove the logic and have it always return the OK tuple with our socket. And since we won’t be using the payload we can ignore it with the underscore.

The handle_in ping function we wont use, so let’s remove it.

The handle_in shout function is in charge of broadcasting any chat events to everyone that’s joined our channel.

Notice that this is set to pattern match on “shout” - we’ll need to remember to use this when we push events out from the client .

And we’ll remove the authorized? function since we removed our authorization logic in the join callback .

lib/chat_web/channels/water_cooler_channel.ex

defmodule ChatWeb.WaterCoolerChannel do use ChatWeb, :channel def join("water_cooler:lobby", _payload, socket) do {:ok, socket} end #It is also common to receive messages from the client and #broadcast to everyone in the current topic (water_cooler:lobby). def handle_in("shout", payload, socket) do broadcast socket, "shout", payload {:noreply, socket} end
end

Now let’s go to our assets/js/socket.js

And we see some code has already been populated that imports and connects our socket to the socket path in our endpoint.ex module which is then handled by our UserSocket.

Our default code also shows how we can join channels with a topic. The topic we defined in our WaterCoolerChannel is water_cooler:lobby so let’s update that here.

It then logs out a message to our browser’s console letting us know if we’ve joined successfully or not.

assets/js/socket.js

...
let channel = socket.channel("water_cooler:lobby", {})
...

Then we’ll open app.js and import our socket.

assets/js/app.js

import "phoenix_html" import socket from "./socket"

Now let’s go to the command line and start our server.

$ mix phx.server

Let’s give it a shot. We’ll open our browser and in the console - we see our success message is printed.

Now that we’re joining successfully let’s create a chat window and form we’ll use to post messages with.

But Let’s first customize our site a bit.

We’ll remove the phoenix.css stylesheet. And in the app.css I’ll paste in some custom css, which is available in the linked GitHub repo.

Then we’ll open our app.html.eex template and let’s remove the Phoenix logo.

Template path: lib/chat_web/templates/layout/app.html.eex

 ... <div class="container"> <p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> ...

Then in our page’s index.html.eex template we’ll paste in the chat window and and form that will have a username and message field.

Template path: lib/chat_web/templates/page/index.html.eex

 <h2>The Water Cooler</h2> <div id="chat-box"> </div> <form id="chat-form"> <input type="text" placeholder="Username" id="user-name"></input> <textarea placeholder="Your comment" id="user-msg"></textarea> <button type="submit">Post</button> </form>

Then let’s open our browser and see what it looks like.

And we see our chat box and form.

Now let’s add some JavaScript to get our chat working, so that when we post a message it’s displayed.

We’ll create a new file in our assets/js named water_cooler.js

And we’ll create our WaterCooler object.

The we’ll create a function init that will take our socket.

And with our socket we’ll join the channel with our “water_cooler:lobby” topic. Now that we’ve joined our channel, we need to send and receive events.

Let’s create a new function listenForChats that will take our channel.

I’ll paste in our code for sending events.

But let’s walk through this.

We’re get our chat form with the id chat-form and then listening for when it’s submitting.

When the form is submitted we’re preventing any further action.

then we’re getting the username from the form

And the message from the form.

Then we’re calling channel.push with “shout” and payload of our username and the message. This will send our event to the server, where it will be picked up by our WaterCoolerChannel.

Now that we’re setup to send events, we need to receive them.

We’ll use channel.on to subscribe to channel events, matching on “shout”

Then we’ll get our chatBox

And our create a new message block

Then we’ll build up our message by getting the name and message from the received payload.

We’ll then append that message to our chatBox.

assets/js/water_cooler.js

let WaterCooler = { init(socket) { let channel = socket.channel('water_cooler:lobby', {}) channel.join() this.listenForChats(channel) }, listenForChats(channel) { document.getElementById('chat-form').addEventListener('submit', function(e){ e.preventDefault() let userName = document.getElementById('user-name').value let userMsg = document.getElementById('user-msg').value channel.push('shout', {name: userName, body: userMsg}) document.getElementById('user-name').value = '' document.getElementById('user-msg').value = '' }) channel.on('shout', payload => { let chatBox = document.querySelector('#chat-box') let msgBlock = document.createElement('p') msgBlock.insertAdjacentHTML('beforeend', `${payload.name}: ${payload.body}`) chatBox.appendChild(msgBlock) }) }
} export default WaterCooler

Now let’s open our app.js

And we’ll import WaterCooler

Then we’ll call init, passing in our socket to use.

assets/js/app.js

...
import WaterCooler from "./water_cooler" WaterCooler.init(socket)

And if we go back to our browser and try typing a message.

We see our message is received and broadcasted out to everyone that’s joined.

And let’s try sending a message back - and that works too!

This is great, but when we refresh the page - our chat disappears because it’s not being persisted.

We’ll fix that by saving our chats to the database.

Let’s have Phoenix do the heavy lifting for us here and use the context generator to build out the modules and database migration that we’ll need.

We’ll go to the command line - and we’ll stop our server.

And let’s our new context ‘Chats’

our ecto schema module will be ‘Message’

And our table will be messages with a name column that’s a string

and body column that’s a text.

$ mix phx.gen.context Chats Message messages name:string body:text

Then we’ll migrate our database.

$ mix ecto.migrate

And let’s take a quick look at the message module that was created.

We see our schema with our body and name fields.

and our changeset.

then let’s open the chats context

And it’s been populated with some functions to save and get our messages.

Great, now we need to save our messages when someone posts one in the chat. Let’s do that in our channel.

Now we’ll open our water_cooler_channel.ex

Then let’s alias our ‘chats’ context.

And in our handle_in function we’ll save the message before it’s broadcasted out.

Now we could make this asynchronous, but let’s keep it simple for the purposes of this episode.

lib/chat_web/channels/water_cooler_channel.ex

...
alias Chat.Chats
...
def handle_in("shout", payload, socket) do Chats.create_message(payload) broadcast socket, "shout", payload {:noreply, socket}
end
...

Now that we’re saving our messages we need to load any existing ones into the chat.

Let’s open our page_controller.ex

We’ll alias our Chat.Chats module

Then let’s get all our messages

And we’ll pass them in our assigns.

lib/chat_web/controllers/page_controller.ex

defmodule ChatWeb.PageController do use ChatWeb, :controller alias Chat.Chats def index(conn, _params) do messages = Chats.list_messages() render conn, "index.html", messages: messages end
end

Now we can open our index.html.eex template and render any messages that were returned in our chat box.

Template path: lib/chat_web/templates/page/index.html.eex

 <h2>The Water Cooler</h2> <div id="chat-box"> <%= for message <- @messages do %> <p><b><%= message.name %>:</b> <%= message.body %></p> <% end %> </div> ...

Then let’s restart our server.

And go back to the browser and post some messages.

Then if we do a quick check of the database - we see our messages are there.

And if we go back to our chat and refresh the page - we see our existing messages are loaded.


Tag cloud