Skip to content

Instantly share code, notes, and snippets.

@yaycode
Last active April 29, 2023 02:57
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save yaycode/58ff8213ea54d7272ae89d0b9165be16 to your computer and use it in GitHub Desktop.
Save yaycode/58ff8213ea54d7272ae89d0b9165be16 to your computer and use it in GitHub Desktop.
Elixir + Phoenix : Creating a simple chatroom

Instachat

The Phoenix Framework was built with realtime communication as a first class priority. Using its built in socket handling and channels we can implement a basic, realtime chat application with little effort.

For this video we’re going to assume that you already have Elixir and Phoenix Setup. You will not need a database as the messages will not be persisted. This tutorial is taken pretty much directly from the Phoenix Documentation.

Setting up the app

To start let’s generate a standard phoenix application:

$> mix phoenix.new instachat

And get it running:

$> cd instachat
$> mix phoenix.server

Now in a web browser hitting http://localhost:4000 should give us the phoenix start page.

Setting Up the Socket

When we ran $> mix phoenix.new it created a default socket module for us and attached it to the url /socket. Let's open up lib/instachat/endpoint.ex and check it out:

# in file: lib/instachat/endpoint.ex  

socket "/socket", Instachat.UserSocket

This is telling Phoenix that all socket connections hitting /socket should be handled by the Instachat.UserSocket module. This UserSocket module is where we handle all the configuration for the socket itself like connecting and routing messages. It lives at web/channels/user_socket.ex. Let's open it up and have a look.

Up at the top we see some commented out code referencing channels:

# in file: web/channels/user_socket.ex

## Channels
  # channel "rooms:*", Instachat.RoomChannel

The channel "rooms:*", Instachat.RoomChannel line is boiler plate example code for handling messages coming over this socket. It says, send any messages that come in starting with "rooms:" and ending with anything to the Instachat.RoomChannel module. This is good enough for our purposes so let's uncomment that line:

# in file: web/channels/user_socket.ex

## Channels
  channel "rooms:*", Instachat.RoomChannel

Setting up the Channel

The channel module wasn't created for us automatically so let's create it ourselves. It is going to live at web/channels/room_channel.ex and here's the boilerplate:

#in file: web/channels/room_channel.ex

defmodule Instachat.RoomChannel do
  use Phoenix.Channel
end

The first thing a channel needs to do is handle connections. We do this by implementing a function called join that either returns {:ok, socket} on a successful join or {:error, message} otherwise. Let's write code that lets users join only if they try to join the lobby, otherwise we'll deny them:

#in file: web/channels/room_channel.ex

defmodule Instachat.RoomChannel do
  use Phoenix.Channel
  def join("rooms:lobby", _message, socket) do
    {:ok, socket}
  end
  def join(_room, _params, _socket) do
    {:error, %{reason: "you can only join the lobby"}}
  end
end

Connecting From Javascript

The boilerplate javascript for connecting to our socket from a web browser has already been written for us but is not being loaded by default. If we open up web/static/js/app.js and look down at the bottom we can see that the code to do this is commented out. Let's un-comment that line:

//in file: web/static/js/app.js

import socket from "./socket"

Now with our web browser pointed to http://localhost:4000/ and the developer console open we can see the message:

Unable to join Object {reason: "unmatched topic"}

This is because our javascript is trying to connect to our socket over a topic that we aren't handling. Let's open up the javascript and set it to the right topic. This javascript file lives at web/static/js/socket.js and the code in concern is down at the bottom:

//in file: web/static/js/socket.js

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("topic:subtopic", {})

This code is trying to connect to a channel called "topic" with a sub-topic of "subtopic" but we want to connect to "rooms:lobby" Let's go ahead and change that:

//in file: web/static/js/socket.js

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("rooms:lobby", {})

And now if we check in our browser's console we should see:

Joined successfully Object {}

This means that we've both connected to the Socket and Joined the Channel

Adding the HTML

To interact with the chat we're going to need some user interface. Let's add places to input and display messages. Open up web/templates/page/index.html.eex and replace its entire contents with:

<!-- in file: web/templates/page/index.html.eex -->

<div id="messages"></div>
<input id="chat-input" type="text"></input>

Hooking up the HTML

For this demo we're gonna keep it simple and use jQuery. Let's add a CDNd version to the application layout which is located at web/templates/layout/app.html.eex right above the application js file:

<!-- in file: web/templates/layout/app.html.eex -->

</div> <!-- /container -->
    <!-- add the following line -->
    <script src="//code.jquery.com/jquery-1.11.3.min.js"></script>
    <script src="<%= static_path(@conn, "/js/app.js") %>"></script>
  </body>

Back in the javascript file for working with this socket let's add some code to hook up the HTML we just added. Down at the bottom of the file add:

// in file: web/static/js/socket.js

// UI Stuff
let chatInput = $("#chat-input");
let messagesContainer = $("#messages");

chatInput.on("keypress", event => {
  if(event.keyCode === 13){
    channel.push("new_message", {body:chatInput.val()});
    chatInput.val("");
  }
});

All this code does is call the push method on channel when we press the enter key. It gives push two arguments, an event name of "new_message" and a payload which is an object containing our message. Channel is going to send this back to our phoenix app. So let's handle it.

Handling Channel Events

Back in our RoomChannel module we need to handle events coming in and broadcast them to all our connected clients. All we have to do is implement a handle_in function. Let's add it below our join functions:

# in file: web/channels/room_channel.ex

  def handle_in("new_message", body, socket) do
    broadcast! socket, "new_message", body
    {:noreply, socket}
  end

We can see that we're pattern matching on events with the name of "new_message", then we simply broadcast the message out to all our connected clients, and we return {:noreply, socket} which is one of the required return values of handle_in and means that the client that sent the message doesn't get anything back from our channel directly. Now we need to receive the broadcast from our Javascript and display the message.

Receiving Events in Javascript

Back in our Javascript file we need to look out for our "new_message" event and update the messages display when we get one. Down at the bottom of web/static/js/socket.js lets add:

// in file: web/static/js/socket.js

channel.on("new_message", payload => {
  messagesContainer.append(`<br/>[${Date()}] ${payload.body}`)
})

This code simply tells the channel to look out for events named "new_message" and to run a function that adds the payload's body to the messages container when we get one. That's it, we should be all done! Let's open up the browser and give it a try.

Testing

Pointing our browser to http://localhost:4000/ , typing something into the input, and pressing enter we should now see the chat working. If we open up another tab we should be able to see any new messages in both tabs and in fact any connected web browsers should be able to see any new messages!

Wrapping it up

Phoenix makes it almost dead simple to write realtime applications for the modern web. With sockets we can handle routing of clients to channels and with channels we can handle receiving and broadcasting events to and from clients with ease. And we get to write this all with the power and clarity of Elixir!

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>Hello Instachat!</title>
<link rel="stylesheet" href="<%= static_path(@conn, "/css/app.css") %>">
</head>
<body>
<div class="container">
<header class="header">
<nav role="navigation">
<ul class="nav nav-pills pull-right">
<li><a href="http://www.phoenixframework.org/docs">Get Started</a></li>
</ul>
</nav>
<span class="logo"></span>
</header>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<main role="main">
<%= render @view_module, @view_template, assigns %>
</main>
</div> <!-- /container -->
<script src="//code.jquery.com/jquery-1.11.3.min.js"></script>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
<div id="messages"></div>
<input id="chat-input" type="text"></input>
defmodule Instachat.RoomChannel do
use Phoenix.Channel
def join("rooms:lobby", _message, socket) do
{:ok, socket}
end
def join(_room, _params, _socket) do
{:error, %{reason: "you can only join the lobby"}}
end
def handle_in("new_message", body, socket) do
broadcast! socket, "new_message", body
{:noreply, socket}
end
end
// NOTE: The contents of this file will only be executed if
// you uncomment its entry in "web/static/js/app.js".
// To use Phoenix channels, the first step is to import Socket
// and connect at the socket path in "lib/my_app/endpoint.ex":
import {Socket} from "phoenix"
let socket = new Socket("/socket", {params: {token: window.userToken}})
// When you connect, you'll often need to authenticate the client.
// For example, imagine you have an authentication plug, `MyAuth`,
// which authenticates the session and assigns a `:current_user`.
// If the current user exists you can assign the user's token in
// the connection for use in the layout.
//
// In your "web/router.ex":
//
// pipeline :browser do
// ...
// plug MyAuth
// plug :put_user_token
// end
//
// defp put_user_token(conn, _) do
// if current_user = conn.assigns[:current_user] do
// token = Phoenix.Token.sign(conn, "user socket", current_user.id)
// assign(conn, :user_token, token)
// else
// conn
// end
// end
//
// Now you need to pass this token to JavaScript. You can do so
// inside a script tag in "web/templates/layout/app.html.eex":
//
// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
//
// You will need to verify the user token in the "connect/2" function
// in "web/channels/user_socket.ex":
//
// def connect(%{"token" => token}, socket) do
// # max_age: 1209600 is equivalent to two weeks in seconds
// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
// {:ok, user_id} ->
// {:ok, assign(socket, :user, user_id)}
// {:error, reason} ->
// :error
// end
// end
//
// Finally, pass the token on connect as below. Or remove it
// from connect if you don't care about authentication.
socket.connect()
// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("rooms:lobby", {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })
export default socket
// UI Code
let chatInput = $("#chat-input");
let messagesContainer = $("#messages");
chatInput.on("keypress", event => {
if(event.keyCode === 13){
channel.push("new_message", {body:chatInput.val()});
chatInput.val("");
}
});
channel.on("new_message", payload => {
messagesContainer.append(`<br/>[${Date()}] ${payload.body}`)
})
@Roland4444
Copy link

Hello ! I am not understand all files from public/js/app.js (for example) automatically compiled on JavaScript? I have issues that if i insert that code directly template page web/templates/layout/app.html.eex for example i insert folowing code:
import {Socket} from "phoenix"
let socket = new Socket("ws://127.0.0.1:4000/socket", {params: {token: window.userToken}})

its not worked, in console i seen that not known word Import . Okay i wrote all code this way on app.js and socket js.
On socket.js i implemented function cross and exporting it this way:
export function cross( e){
let socket = new Socket("ws://127.0.0.1:4000/socket", {params: {token: window.userToken}})
socket.connect()
let channel = socket.channel("rooms:lobby", {})
channel.join()
.receive("ok", resp => { alert("ok!"); console.log("Joined successfully", resp) })
.receive("error", resp => { alert("error!"); console.log("Unable to join", resp) })
alert("o!!!k!");
channel.push("check!", {body:e});
}

On app.js i invoke (importing that function ) this way:

import * from "./socket"

On template page on section i include file app.js

<script src="<%= static_path(@conn, "/js/app.js") %>"></script>

Also on template page (web/templates/layout/app.html.eex)
I make button this way:

  • Отзывы
  • But when i run phoenix application and press this button console outs ->me

    (index):88 Uncaught ReferenceError: cross is not defined
    at HTMLAnchorElement.onclick ((index):88)

    How i must importing and exporting this function??

    @nelsonic
    Copy link

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment