Subscribe to access all episodes. View plans →
Published August 19, 2019
Elixir 1.8
Phoenix 1.4
Source code on GitHub
In this episode we’re going to make a dynamic chat room application. This will be a little different from the chat room application we built in episode #38 - where we had a single room. When we’re done we’ll be able to go to a URL ‘localhost:4000/chat/
For our chat rooms, we’ll want to persist our messages, so to start let’s use the Phoenix context generator to quickly create a context and schema for us.
We’ll call our context “Chats” and our schema module will be “Message” with “messages” as the plural for the name of the database table.
Let’s give our messages a name field for the user that posted the message, and a field for the body of the message. For this episode let’s keep things simple and store the name of the room the message belongs to. If this were a production application maybe we’d do something different, like create a “Room” table and associate all the messages with that. But what we have here will work for our application.
$ mix phx.gen.context Chats Message messages name:string body:text room:string
* creating lib/teacher/chats/message.ex
* creating priv/repo/migrations/{timestamp}_create_messages.exs
* creating lib/teacher/chats/chats.ex
* injecting lib/teacher/chats/chats.ex
* creating test/teacher/chats/chats_test.exs
* injecting test/teacher/chats/chats_test.exs
Remember to update your repository by running migrations:
$ mix ecto.migrate
Now before we run our migration, let’s open the migration file that was generated. Because we’re storing the name of the chat room the message belongs to, let’s add an index on our “room” column since we’ll be using this often to lookup our all the messages for a given room.
priv/repo/migrations/{timestamp}_create_messages.exs
defmodule Teacher.Repo.Migrations.CreateMessages do
use Ecto.Migration
def change do
create table(:messages) do
add :name, :string
add :body, :text
add :room, :string
timestamps()
end
create index(:messages, [:room])
end
end
Then we’ll go back to the command line and run the migration.
$ mix ecto.migrate
...
Now let’s add our chat room page. We’ll open our router.ex
and let’s use the resources
macro to create a new route for a chat using a ChatController
that we’ll need to create and with only the show
action.
lib/teacher_web/router.ex
...
resources "/chat", ChatController, only: [:show]
...
Then let’s create our chat_controller.ex
and we’ll create our show
function, taking our connection and then pattern matching on the params to get the name of the room. Then inside the function we’ll render the show template and let’s put our room name in the assigns so we can display it on the page.
lib/teacher_web/controllers/chat_controller.ex
defmodule TeacherWeb.ChatController do
use TeacherWeb, :controller
def show(conn, %{"id" => room}) do
render(conn, "show.html", room: room)
end
end
Now we’ll need to create the corresponding chat_view.ex
. Then we’ll define our view module.
lib/teacher_web/views/chat_view.ex
defmodule TeacherWeb.ChatView do
use TeacherWeb, :view
end
With that we can create the template for our chat room. We’ll create a new directory in “templates” named “chat”. Then inside that we’ll define our show.html.eex
template. To start let’s display the room name to make sure it’s is coming in correctly from the URL.
Template path:lib/teacher_web/templates/chat/show.html.eex
<h2>Room: <%= @room%></h2>
Now let’s go to the command line and start up our server.
$ mix phx.server
...
Then if we open our new page in our browser: http://localhost:4000/chat/movies. We see that our page loads and the name of our chat room is being displayed.
Now that we have our page working, we need to create create a way for users to post messages to the room. Because we’re using Elixir with Phoenix, let’s use Phoenix Channels, which will make it easy to add some pretty cool realtime functionality to our chatroom.
Let’s go to the command line and generate a new channel named “chat”.
$ mix phx.gen.channel Chat
* creating lib/teacher_web/channels/chat_channel.ex
* creating test/teacher_web/channels/chat_channel_test.exs
Add the channel to your `lib/teacher_web/channels/user_socket.ex` handler, for example:
channel "chat:lobby", TeacherWeb.ChatChannel
And you can see in the instructions printed by Phoenix that we’ll want to add our channel to our user_socket.ex
handler, so let’s do that now.
We’ll open user_socket.ex
and we’ll add our new channel, sending any events with “chat” to our ChatChannel
. And since our application will handle multiple rooms, we’ll want to include a wildcard here to handle all subtopics.
lib/teacher_web/channels/user_socket.ex
...
channel "chat:*", TeacherWeb.ChatChannel
...
Great now let’s open the chat_channel.ex
that was generated for us. The channel is generated with some functions that make it easier to authenticate and build with channels, but for our application we wont worry about authorization so let’s remove the authorized?
function, and the corresponding logic from our join
callback.
Our join
callback was generated with the “chat:lobby” topic/subtopic. We’ll want to update this to pattern match on any subtopic, which will be our room name that comes through, but since we wont use the room name here we’ll ignore it. Then we’ll also ignore our payload since we wont use it. Then let’s remove our handle_in("ping", payload, socket)
callback.
I’ll also remove the comments that are generated just to reduce the text on the screen.
lib/teacher_web/channels/chat_channel.ex
defmodule TeacherWeb.ChatChannel do
use TeacherWeb, :channel
def join("chat:" <> _room, _payload, socket) do
{:ok, socket}
end
def handle_in("shout", payload, socket) do
broadcast socket, "shout", payload
{:noreply, socket}
end
end
Now let’s start adding some JavaScript that lets us join our channel. We’ll start by opening socket.js
and we can see that the generated JavaScript is showing how to connect and join to a topic. We’ll comment the code out here, but we’ll want to do something similar to join our channel.
assets/js/socket.js
...
// let channel = socket.channel("topic:subtopic", {})
// channel.join()
// .receive("ok", resp => { console.log("Joined successfully", resp) })
// .receive("error", resp => { console.log("Unable to join", resp) })
...
Let’s create file in “assets/js” to handle all our clientside chat logic named chat.js
. Inside it let’s create a Chat
object and then we’ll create a function named init
that will take our socket. We’ll get our path, splitting it on the slash “/“. To get our room let’s grab the last element in the path array.
Now we can use the room to initiate a new channel for our topic, using the room we got from the path. Then we can join the channel and to check that everything’s setup correctly, if we join successfully let’s log a message. Finally, we’ll export our Chat
.
assets/js/chat.js
let Chat = {
init(socket) {
let path = window.location.pathname.split('/')
let room = path[path.length -1]
let channel = socket.channel('chat:' + room, {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
}
}
export default Chat
Then let’s open our app.js
and we’ll uncomment our socket import. Then let’s import our Chat and then call Chat.init
passing in our socket
.
assets/js/app.js
...
import socket from "./socket"
import Chat from "./chat"
Chat.init(socket)
Now let’s re-start our server.
$ mix phx.server
...
If we go to our chat page again and then inspect the browser console, we see our message is logged letting us know we joined our channel successfully.
Now that we’ve joined our channel we can implement the rest of our chat application. Let’s create a chat form in our UI that we can use to post messages with.
We’ll open our chat show.html.eex
template. And I’ll paste in our form, but let’s walk through what we have. There’s a div with the ID of “chat-box” - this is where we’ll want to display our messages. Then we have a form with the ID of “chat-form” which has two fields a “username”, and a “message”.
Template path: lib/teacher_web/templates/chat/show.html.eex
<h2>Room: <%= @room%></h2>
<div id="chat-box">
</div>
<%= form_for @conn, "#", [id: "chat-form"], fn f -> %>
<%= text_input f, :username, placeholder: "Username", id: "user-name" %>
<br>
<%= textarea f, :message, placeholder: "Your comment", id: "user-msg" %>
<br>
<%= submit "Post" %>
<% end %>
Now let’s update our JavaScript to grab the username and message when our form is submitted and push them to our server so we can save them to the database and then broadcast them to any other clients in the same chatroom. We’ll go back to chat.js
and let’s create a function named listenForChats
that takes our channel. Thenlet’s add an event listener to our form so that when it’s submitted we can push the data to our channel. We want to prevent the default behavior of the form and then send our form data to the channel in another function that we’ll call submitForm
. Let’s define our function and get the “username” from the form, and then the “message”.
Once we have those we can send them to the server with channel.push
using the “shout” event since this is what was used in our ChatChannel
and for our payload we’ll use the “username” and the “message”. Then let’s save our user some time by keeping the username field populated with their username. Also let’s clear the message field. Now we can use channel.on
to subscribe to channel events, matching on the “shout” event we used above. Here we’ll want to update the user’s chat box with the message they just sent.
Let’s get our “#chat-box” from the page and create a new message block. Then we’ll update our message block with the name and body from the payload and append it to our chat box. We’ll go back to our init
function and remove our success message we were logging to the console. Then we’ll want to call our new listenForChats
function passing in the channel.
assets/js/chat.js
let Chat = {
init(socket) {
let path = window.location.pathname.split('/')
let room = path[path.length -1]
let channel = socket.channel('chat:' + room, {})
channel.join()
this.listenForChats(channel)
},
listenForChats(channel) {
function submitForm(){
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 = userName
document.getElementById('user-msg').value = ''
}
document.getElementById('chat-form').addEventListener('submit', function(e){
e.preventDefault()
submitForm();
})
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 Chat
Now let’s open the same chat room from two browser windows. If we post a message in one, we see that it’s broadcasted to other clients and displayed on our other window. This is great, but since we aren’t persisting our messages to the database. If we refresh the page - our messages are lost. Let’s fix that now.
We’ll open our chat_channel.ex
and in our handle_in
“shout” callback let’s take our payload, update it with its room, and then save it to the database.
Since the topic is stored as a field on our socket, we can pattern match on it to get the room. Then let’s add our room our payload. We can create our message from our payload using the Chats
context module create_message function. Let’s also add an alias for our Chats
module so we can call it without the prefix.
lib/teacher_web/channels/chat_controller.ex
...
alias Teacher.Chats
...
def handle_in("shout", payload, socket) do
"chat:" <> room = socket.topic
payload = Map.merge(payload, %{"room" => room})
Chats.create_message(payload)
broadcast socket, "shout", payload
{:noreply, socket}
end
...
Now we’ll also need to retrieve all the messages for our room. Let’s open our chats.ex
module and we’ll create a new public function named list_messages_by_room
that takes the room.
Then inside the function we’ll query for all messages for our room and then let’s order them ascending by when they were created.
lib/teacher/chats/chats.ex
...
def list_messages_by_room(room) do
qry = from m in Message,
where: m.room == ^room,
order_by: [asc: m.inserted_at]
Repo.all(qry)
end
...
With that we can open our chat_controller.ex
and call our new Chats.list_messages_by_room
function passing in the room. Then we’ll include it in our assigns so we can display them in our template. Let’s also alias our module here to so we can call it without the prefix.
lib/teacher_web/controllers/chat_controller.ex
...
alias Teacher.Chats
def show(conn, %{"id" => room}) do
messages = Chats.list_messages_by_room(room)
render(conn, "show.html", room: room, messages: messages)
end
...
Our last piece will be to update our template to load any existing messages for the room. We’ll go to our chat’s show.html.eex
template. And inside our chat box, let’s loop over our messages, displaying the name, and the body in the same format we use in our chat.js
when a message is posted.
Template path: lib/teacher_web/templates/chat/show.html.eex
...
<div id="chat-box">
<%= for message <- @messages do %>
<p><b><%= message.name %>:</b> <%= message.body %></p>
<% end %>
</div>
...
Alright now let’s go back to our chat room and now if we create a message, we see they are only being sent to the chat room and if we refresh the page our messages are not disappearing anymore.
Great, and we can also test that our messages are pushed out only to people in that specific chat room. We’ll go to another room and post a message to it. We only see the messages for that specific room are displayed. They don’t appear in the other chat room.
Jack W
3 years agoIs there a potential problem here if a message from A is received after the request for B has queried all of the persisted messages, but before B has connected to the topic? How could that be mitigated?