Single File Chat in Elixir Phoenix
Meet PhonenixPlayground
I found this library on github suggestions and I always wanted to have something simple and quick to test out LiveView prototypes. What can be quicker than opening Livebook and trying something there? Folks behind PhonenixPlayground made both of my wishes come true!
If you would like jump straight into the code:
What’s the plan?
- Have chat with exchanging messages between clients in real time
- We will use Phoenix.LiveView
- We will have
MessageStorage
module which holds messages using GenServer - We will delete old messages once in a while
- We will use Phoenix.PubSub for updating clients via LiveView sockets
- Have all of it in a single LiveBook
Let’s jump into
Open empty LiveBook and add next block to Notebook Dependencies and setup
Mix.install([
{:phoenix_playground, "~> 0.1.3"}
])
Let’s create logic to hold our messages
First I created simple MessageStorage
module with three key functions,
init
defines our initial stateadd_message/1
I hope it’s self explanatoryget_messages/1
likewise
Now we need to handle GenServer events:
handle_cast/2
simply add new message to the beginning of our empty list which we initiated in init/1
.
and
handle_call
where I used Enum.reverse()
in order to return messages in reverse, because in the chat you read top down where old on top and newest at the bottom.
defmodule MessageStorage do
use GenServer
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_args) do
{:ok, []}
end
def add_message(message) do
GenServer.cast(__MODULE__, {:add_message, message})
end
def get_messages() do
GenServer.call(__MODULE__, :get_messages)
end
def handle_cast({:add_message, message}, state) do
{:noreply, [message | state]}
end
def handle_call(:get_messages, _from, state) do
{:reply, Enum.reverse(state), state}
end
end
Let’s add clean up and updates
We will define clean up interval and schedule clean up job every 5 seconds to delete the oldest message.
⁉️ This is not optimal way to delete old messages, only for educational purposes, as proper way would be to store created time stamp next to each message and delete based on time stamps, but just for quick night dirty prototyping it will do the job.
Improved version located on my GitHub 🔗
defmodule MessageStorage do
use GenServer
@cleanup_interval 5_000 # 5 seconds
def start_link(_opts) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_args) do
schedule_cleanup()
{:ok, []}
end
...
def handle_info(:cleanup, state) do
new_state = clean_oldest_message(state)
schedule_cleanup()
...
{:noreply, new_state}
end
defp schedule_cleanup do
Process.send_after(self(), :cleanup, @cleanup_interval)
end
defp clean_oldest_message([]), do: []
defp clean_oldest_message(state) do
List.delete_at(state, -1)
end
As for the updates as promised we will use Phoenix.PubSub as we will need to tell to our clients what’s going on like new message was added to the view and old ones were deleted.
To achieve that we will need two core functionalities: subscribe and broadcast.
subscribe
will let our clients to subscribe to our events from MessageStorage
module
and
broadcast
will enable us to send new updates into Phoenix.PubSub
.
Just jumping a bit further, once we will add Phoenix.PubSub
to our star tree we will define name of that PubSub like this:
Supervisor.child_spec({Phoenix.PubSub, name: :demo_pub_sub}, id: :demo_pub_sub),
That’s why here you can see that name:
Phoenix.PubSub.subscribe(:demo_pub_sub, "message_topic")
and message_topic
is just the name for our topic. Here is extended functionality of module:
defmodule MessageStorage do
use GenServer
...
def handle_info(:cleanup, state) do
new_state = clean_oldest_message(state)
schedule_cleanup()
Phoenix.PubSub.broadcast(:demo_pub_sub, "message_topic", {:message_last_deleted})
{:noreply, new_state}
end
def subscribe, do: Phoenix.PubSub.subscribe(:demo_pub_sub, "message_topic")
defp broadcast({:error, _reason} = error, _event), do: error
defp broadcast({:ok, message}, event) do
Phoenix.PubSub.broadcast(:demo_pub_sub, "message_topic", {event, message})
{:ok, message}
end
defp schedule_cleanup do
Process.send_after(self(), :cleanup, @cleanup_interval)
end
defp clean_oldest_message([]), do: []
defp clean_oldest_message(state) do
List.delete_at(state, -1)
end
end
What it does?
- Once client subscribed to our topic it will able to react to updates.
- Once cleanup happened we send update to the Phoenix.PubSub saying message deleted.
- Once new message added we tell to everyone who subscribed that new message was added.
Let’s move to our LiveView
We need to create another module which will hold Phoenix.LiveView logic.
Quite similar to what we’ve done in MessageStorage
we need to init the state. And subscribe to our message topic from Phoenix.PubSub
.
And as you can see we have socket
through which we able to send and receive updates from our page. At the return we will initiate messages
state and message_value
which we will use for our input value.
defmodule DemoTest do
use Phoenix.LiveView
def mount(_params, _session, socket) do
if connected?(socket), do: MessageStorage.subscribe()
messages = MessageStorage.get_messages()
{:ok, assign(socket, messages: messages, message_value: "")}
end
...
end
❤️ Huge thanks to the everyone who worked on erlang, BEAM, Elixir, Phoenix and LiveView that I don’t need to write a single line of javascript. (no offend, I’m a backend developer and you will notice it from my frontend skills)
In our render function we can write html and heex
annotations.
I simply added two css libraries as otherwise it would look completely boring and awful, which can be found in the <head></head>
.
Let’s display our messages, for that we will use for
annotation:
<div class="chat-bubble" :for={message <- @messages}><%= message %></div>
And in order to “submit” our message we will use simple form:
<form phx-submit="new_message">
<input
type="text"
name="text"
value="{@message_value}"
placeholder="Message..."
autofocus
/>
<input type="submit" />
</form>
In which you can find our @message_value
which we added in init state and phx-submit="new_message"
, this is an event which we will need to handle in order to send updates from our UI to MessageStorage
and than further to other clients.
defmodule DemoTest do
use Phoenix.LiveView
...
def render(assigns) do
~H"""
<head>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div class="h-screen flex items-center justify-center">
<div class="mockup-phone border-primary">
<div class="camera"></div>
<div class="display">
<div class="artboard artboard-demo phone-1">
<div class="chat chat-end w-full">
<div class="chat-bubble" :for={message <- @messages}><%= message %></div>
</div>
<form phx-submit="new_message" >
<input type="text" name="text" value={@message_value} placeholder="Message..." autofocus>
<input type="submit" />
</form>
</div>
</div>
</div>
</div>
</body>
"""
end
...
end
Now let’s wire our UI logic with the rest of the functionality which we developed.
What needs to be done?
- Handle
"new_message"
:- send new message to
MessageStorage
in order to store it inGenServer
and update other clients viaPhoenix.PubSub
- and we need to update socket state in order to display it here in our LiveView:
<div class="chat-bubble" :for={message <- @messages}><%= message %></div>
- send new message to
- Handle updates from
GenServer
:message_last_deleted
we need to delete last one from our view:message_created
display new message which came from GenServer which was sent from another client. (or by you from iex)
defmodule DemoTest do
use Phoenix.LiveView
...
def handle_event("new_message", %{"text" => message}, socket) do
MessageStorage.add_message(message)
# here i wanted to reset message once we hit enter, but found out it's not a bug but feature
# works only once (input is in focus and due to that it wont be updated)
# https://github.com/phoenixframework/phoenix_live_view/issues/624
{:noreply, socket}
end
def handle_info({:message_last_deleted}, socket) do
if length(socket.assigns.messages) == 0 do
{:noreply, socket}
else
[_|tail] = socket.assigns.messages
{:noreply, assign(socket, messages: tail )}
end
end
def handle_info({:message_created, message}, socket) do
{:noreply, assign(socket, messages: socket.assigns.messages ++ [message] )}
end
end
Let’s run
We need to start our Phoenix.PubSub
, GenServer
which we wrapped in MessageStorage
module and our Phoenix.LiveView
via PhoenixPlayground.start/2
children = [
Supervisor.child_spec({Phoenix.PubSub, name: :demo_pub_sub}, id: :demo_pub_sub),
MessageStorage,
]
PhoenixPlayground.start(live: DemoTest, child_specs: children)
You can run it and test yourself: