We really wanted a Free & Open Source
real-world example
with full code,
tests and auth.
We wrote this
so we could point
people in our team/community
learning Phoenix LiveView to it.
This LiveView example/tutorial takes you from zero
to fully functioning app
in 20 minutes.
Here is the table of contents of what you can expect to cover in this example/tutorial:
LiveViewChat Tutorial- Why? π€·
- What? π¬
- Who? π€
- How? π»
- 0. Prerequisites
- 1. Create
PhoenixApp - 2. Create
liveDirectory,LiveViewController and Template - 3. Update
router.ex - 4. Update Tests
- 5. Migration and Schema
- 6 Update
mount/3function - 7. Update Template
- 8. Handle Message Creation Events
- 9. PubSub
- 10. Hooks
- 11. Optional: Temporary assigns
- 12. Authentication
- 14. Presence
- 15. Tailwind CSS Stylin'
- What's Next?
Anyone learning Phoenix LiveView
wanting a self-contained tutorial
including:
Setup, Testing, Authentication, Presence,
It's recommended,
though not required,
that you follow the
LiveView Counter Tutorial
as this one is more advanced.
At least, checkout the list of
prerequisites
so you know what you need to have
installed on your computer before
you start this adventure!
Provided you have
Elixir, Phoenix
and Postgres installed,
you're good to go!
Start by creating the new liveview_chat Phoenix application:
mix phx.new liveview_chat --no-mailer --no-dashboardWe don't need email or dashboard features
so we're excluding them from our app.
You can learn more about creating
new Phoenix apps by running:
mix help phx.new
Run mix deps.get to retrieve the dependencies.
then create the
liveview_chat_dev Postgres database
by running the command:
mix ecto.setupYou should see output similar to the following:
The database for LiveviewChat.Repo has been created
14:20:19.71 [info] Migrations already upOnce that command succeeds You should now be able to start the application by running the command:
mix phx.serverYou will see terminal output similar to the following:
[info] Running LiveviewChatWeb.Endpoint with cowboy 2.9.0 at 127.0.0.1:4000 (http)
[debug] Downloading esbuild from https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.29.tgz
[info] Access LiveviewChatWeb.Endpoint at http://localhost:4000
[watch] build finished, watching for changes...When you open the URL:
http://localhost:4000
in your web browser you should see something similar to:
Create the lib/liveview_chat_web/live folder
and the controller at
lib/liveview_chat_web/live/message_live.ex:
defmodule LiveviewChatWeb.MessageLive do
use LiveviewChatWeb, :live_view
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
LiveviewChatWeb.MessageView.render("messages.html", assigns)
end
endNote: neither the file name nor the code has the word "controller" anywhere. Hopefully it's not confusing. It's a "controller" in the sense that it controls what happens in the app.
A LiveView controller requires
the functions mount/3 and render/1 to be defined.
To keep the controller simple the mount/3
is just returning the {:ok, socket} tuple
without any changes.
The render/1
invokes
LiveviewChatWeb.MessageView.render/2 (included with Phoenix)
which renders the messages.html.heex template
which we will define below.
Create the
lib/liveview_chat_web/views/message_view.ex
file:
defmodule LiveviewChatWeb.MessageView do
use LiveviewChatWeb, :view
endThis is similar to regular Phoenix view;
nothing special/interesting here.
Next, create the
lib/liveview_chat_web/templates/message
directory,
then create
lib/liveview_chat_web/templates/message/messages.html.heex
file
and add the following line of HTML:
<h1>LiveView Message Page</h1>Finally, to make the root layout simpler,
open the
lib/liveview_chat_web/templates/layout/root.html.heex
file and
update the contents of the <body> to:
<body>
<header>
<section class="container">
<h1>LiveView Chat Example</h1>
</section>
</header>
<%= @inner_content %>
</body>Now that you've created the necessary files,
open the router
lib/liveview_chat_web/router.ex
replace the default route PageController controller:
get "/", PageController, :indexwith MessageLive controller:
scope "/", LiveviewChatWeb do
pipe_through :browser
live "/", MessageLive
endNow if you refresh the page you should see the following:
At this point we have made a few changes that mean our automated test suite will no longer pass ... Run the tests in your command line with the following command:
mix testYou will see output similar to the following:
Generated liveview_chat app
..
1) test GET / (LiveviewChatWeb.PageControllerTest)
test/liveview_chat_web/controllers/page_controller_test.exs:4
Assertion with =~ failed
code: assert html_response(conn, 200) =~ "Welcome to Phoenix!"
left: "<!DOCTYPE html><html lang=\"en\"> <head> <meta charset=\"utf-8\"> <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">
<title data-suffix=\" Β· Phoenix Framework\">LiveviewChat Β· Phoenix Framework</title> <link phx-track-static rel=\"stylesheet\" href=\"/assets/app.css\"> <script defer phx-track-static type=\"text/javascript\" src=\"/assets/app.js\"></script> </head>
<body> <header> <section class=\"container\">
<h1>LiveView Chat Example</h1></section> </header>
<h1>LiveView Message Page</h1></main></div> </body></html>"
right: "Welcome to Phoenix!"
stacktrace:
test/liveview_chat_web/controllers/page_controller_test.exs:6: (test)
Finished in 0.03 seconds (0.02s async, 0.01s sync)
3 tests, 1 failureThis is because the page_controller_test.exs
is still expecting the homepage to contain the
"Welcome to Phoenix!" text.
Let's update the tests!
Create the
test/liveview_chat_web/live
folder and the
message_live_test.exs
file within it:
test/liveview_chat_web/live/message_live_test.exs
Add the following test code to it:
defmodule LiveviewChatWeb.MessageLiveTest do
use LiveviewChatWeb.ConnCase
import Phoenix.LiveViewTest
test "disconnected and connected mount", %{conn: conn} do
conn = get(conn, "/")
assert html_response(conn, 200) =~ "LiveView Message Page"
{:ok, _view, _html} = live(conn)
end
endWe are testing that the / endpoint
is accessible and has the text
"LiveView Message Page" on the page.
See also the LiveViewTest module for more information about testing and liveView.
Finally you can delete all the default generated code linked to the PageController:
rm test/liveview_chat_web/controllers/page_controller_test.exsrm lib/liveview_chat_web/controllers/page_controller.exrm test/liveview_chat_web/views/page_view_test.exsrm lib/liveview_chat_web/views/page_view.exrm -r lib/liveview_chat_web/templates/page
You can now run the test again with mix test command.
You should see the following (tests passing):
Generated liveview_chat app
...
Finished in 0.1 seconds (0.06s async, 0.1s sync)
3 tests, 0 failures
Randomized with seed 841084With the LiveView structure defined,
we can focus on creating messages.
The database will save the message
and the name of the sender.
Let's create a new schema and migration:
mix phx.gen.schema Message messages name:string message:stringNote: don't forget to run
mix ecto.migrateto create the newmessagestable in the database.
We can now update the Message schema
to add functions for creating new messages
and listing the existing messages.
We'll also update the changeset
to add requirements
and validations on the message text.
Open the lib/liveview_chat/message.ex file
and update the code with the following:
defmodule LiveviewChat.Message do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias LiveviewChat.Repo
alias __MODULE__
schema "messages" do
field :message, :string
field :name, :string
timestamps()
end
@doc false
def changeset(message, attrs) do
message
|> cast(attrs, [:name, :message])
|> validate_required([:name, :message])
|> validate_length(:message, min: 2)
end
def create_message(attrs) do
%Message{}
|> changeset(attrs)
|> Repo.insert()
end
def list_messages do
Message
|> limit(20)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
endWe have added the validate_length function
on the message input to ensure
that messages have at least 2 characters.
This is just an example to show how
the changeset validation works
with the form on the LiveView page.
We then created the create_message/1
and list_messages/0 functions.
Similar to
phoenix-chat-example
we limit the number of messages returned
to the latest 20.
Open the
lib/liveview_chat_web/live/message_live.ex
file
and add the following line at line 3:
alias LiveviewChat.MessageNext update the mount/3 function in the
lib/liveview_chat_web/live/message_live.ex
file to use
the list_messages function:
def mount(_params, _session, socket) do
messages = Message.list_messages() |> Enum.reverse()
changeset = Message.changeset(%Message{}, %{})
{:ok, assign(socket, changeset: changeset, messages: messages)}
endmount/3 will now get the list of messages
and create a changeset
that will be used for the message form.
We then
assign
the changeset and the messages to the socket which will display them on the liveView page.
Update the
messages.html.heex
template to the following code:
<ul id='msg-list' phx-update="append">
<%= for message <- @messages do %>
<li id={"msg-#{message.id}"}>
<b><%= message.name %>:</b>
<%= message.message %>
</li>
<% end %>
</ul>
<.form let={f} for={@changeset} id="form" phx-submit="new_message" phx-hook="Form">
<%= text_input f, :name, id: "name", placeholder: "Your name", autofocus: "true" %>
<%= error_tag f, :name %>
<%= text_input f, :message, id: "msg", placeholder: "Your message" %>
<%= error_tag f, :message %>
<%= submit "Send"%>
</.form>It first displays the new messages
and then provides a form for people
to create a new message.
If you refresh the page, you should see the following:
The <.form></.form> syntax is how to use the form
function component.
A function component is any function that receives an
assignsmap as argument and returns a renderedstructbuilt with the~Hsigil.
Finally let's make sure the test are still passing by updating the assert
in the test/liveview_chat_web/live/message_live_test.exs file
to:
assert html_response(conn, 200) =~ "LiveView Chat"As we have deleted the LiveView Message Page h1 title,
we can instead test for the title in the root layout
and make sure the page is still displayed correctly.
At the moment if we run the Phoenix app mix phx.server
and submit the form in the browser nothing will happen.
If we look at the server log, we see the following:
** (UndefinedFunctionError) function LiveviewChatWeb.MessageLive.handle_event/3
is undefined or private
(liveview_chat 0.1.0) LiveviewChatWeb.MessageLive.handle_event("new_message",
%{"_csrf_token" => "fyVPIls_XRBuGwlkMhxsFAciRRkpAVUOLW5k4UoR7JF1uZ5z2Dundigv",
"message" => %{"message" => "", "name" => ""}}, #Phoenix.LiveView.Socket
On submit the form is creating a new event defined with phx-submit:
<.form let={f} for={@changeset} id="form" phx-submit="new_message">
However this event is not managed on the server yet,
we can fix this by adding the
handle_event/3 function in
lib/liveview_chat_web/live/message_live.ex:
def handle_event("new_message", %{"message" => params}, socket) do
case Message.create_message(params) do
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
{:ok, _message} ->
changeset = Message.changeset(%Message{}, %{"name" => params["name"]})
{:noreply, assign(socket, changeset: changeset)}
end
endThe create_message function is called with the values from the form.
If an error occurs while trying to save the information in the database,
for example the changeset can return an error if the name or the message is
empty or if the message is too short, the changeset is assigned again to the socket.
This will allow the form to display the error information:
If the message is saved without any errors, we are creating a new changeset which contains the name from the form to avoid people having to enter their name again in the form, and we assign the new changeset to the socket.
Now the form is displayed we can add the following tests
to test/liveview_chat_web/live/message_live_test.exs:
import Plug.HTML, only: [html_escape: 1]
test "name can't be blank", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert view
|> form("#form", message: %{name: "", message: "hello"})
|> render_submit() =~ html_escape("can't be blank")
end
test "message", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert view
|> form("#form", message: %{name: "Simon", message: ""})
|> render_submit() =~ html_escape("can't be blank")
end
test "minimum message length", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert view
|> form("#form", message: %{name: "Simon", message: "h"})
|> render_submit() =~ "should be at least 2 character(s)"
endWe are using the
form/3
function to select the form and trigger
the submit event with different values for the name and the message.
We are testing that errors are properly displayed.
Instead of having to reload the page to see the newly created messages, we can use PubSub (Publish Subscribe) to inform all connected clients that a new message has been created and to update the UI to display the new message.
Open the lib/liveview_chat/message.ex file
and add the following line near the top:
alias Phoenix.PubSubNext add the following 3 functions:
def subscribe() do
PubSub.subscribe(LiveviewChat.PubSub, "liveview_chat")
end
def notify({:ok, message}, event) do
PubSub.broadcast(LiveviewChat.PubSub, "liveview_chat", {event, message})
end
def notify({:error, reason}, _event), do: {:error, reason}subscribe/0 will be called when a client has properly displayed the liveView page
and listen for new messages.
It is just a wrapper function for
Phoenix.PubSub.subscribe.
notify/2 is invoked each time a new message is created
to broadcast the message to the connected clients.
Repo.insert can either returns {:ok, message} or {:error, reason},
so we need to define notify/2 handle both cases.
Update the create_message/1 function in message.ex
to invoke our newly created notify/2 function:
def create_message(attrs) do
%Message{}
|> changeset(attrs)
|> Repo.insert()
|> notify(:message_created)
endWe can now connect the client
when the LiveView page is rendered.
At the top of the
lib/liveview_chat_web/live/message_live.ex
file,
add the following line:
alias LiveviewChat.PubSubThen update the mount/3 function with:
def mount(_params, _session, socket) do
if connected?(socket), do: Message.subscribe()
messages = Message.list_messages() |> Enum.reverse()
changeset = Message.changeset(%Message{}, %{})
{:ok, assign(socket, messages: messages, changeset: changeset)}
endmount/3 now checks the socket is connected
then calls the new Message.subscribe/0 function.
Since the return value of create_message/1 has changed,
we need to update handle_event/3 to the following:
def handle_event("new_message", %{"message" => params}, socket) do
case Message.create_message(params) do
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset)}
:ok -> # broadcast returns :ok (just the atom!) if there are no errors
changeset = Message.changeset(%Message{}, %{"name" => params["name"]})
{:noreply, assign(socket, changeset: changeset)}
end
endThe last step
is to handle the :message_created event
by defining the handle_info/2 function
in lib/liveview_chat_web/live/message_live.ex:
def handle_info({:message_created, message}, socket) do
messages = socket.assigns.messages ++ [message]
{:noreply, assign(socket, messages: messages)}
endWhen the event is received, the new message is added to the list of existing messages. The new list is then assigned to the socket which will update the UI to display the new message.
Add the following tests to
test/liveview_chat_web/live/message_live_test.exs
to ensure that messages are correctly displayed on the page:
test "message form submitted correctly", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert view
|> form("#form", message: %{name: "Simon", message: "hi"})
|> render_submit()
assert render(view) =~ "<b>Simon:</b>"
assert render(view) =~ "hi"
end
test "handle_info/2", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert render(view)
# send :created_message event when the message is created
Message.create_message(%{"name" => "Simon", "message" => "hello"})
# test that the name and the message is displayed
assert render(view) =~ "<b>Simon:</b>"
assert render(view) =~ "hello"
endYou should now have a functional chat application using liveView!
Run the Phoenix App with:
mix phx.serverVisit the App localhost:4000
in 2 or more browsers,
and send yourself some messages!
One issue we can notice is that the message input doesn't always
reset to an empty value after sending a message using the Enter key
on the input field. This forces us to remove the
previous message manually before writing and sending a new one.
The reason is:
The JavaScript client is always the source of truth for current input values. For any given input with focus,
LiveViewwill never overwrite the input's current value, even if it deviates from the server's rendered updates. see: https://hexdocs.pm/phoenix_live_view/form-bindings.html#javascript-client-specifics
Our solution is to use phx-hook to run some javascript on the client
after one of the LiveView life-cycle callbacks
(mounted, beforeUpdated, updated,
destroyed, disconnected, reconnected).
Let's add a hook to monitor when the message form is updated.
In the message.html.heex file
add the phx-hook attribute to the <.form> element:
<.form let={f} for={@changeset} id="form" phx-submit="new_message" phx-hook="Form">Then in the assets/js/app.js file,
add the following JavaScript logic:
// get message input element
let msg = document.getElementById('msg');
// define "Form" hook, the name must match the one
// defined with phx-hoo="Form"
let Hooks = {}
Hooks.Form = {
// Each time the form is updated run the code in the callback
updated() {
// If no error displayed reset the message value
if(document.getElementsByClassName('invalid-feedback').length == 0) {
msg.value = '';
}
}
}
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks}) // Add hooks: HooksThe main logic to reset the message value is contained inside the updated()
callback function:
if(document.getElementsByClassName('invalid-feedback').length == 0) {
msg.value = '';
}Before setting the value to an empty string,
we check first that no errors are displayed
on the form by checking for the invalid-feedback CSS class.
(read more about feedback:
https://hexdocs.pm/phoenix_live_view/form-bindings.html#phx-feedback-for )
The final step is to set the hooks on the liveSocket
with hooks: Hooks.
The message input should now be reset when a new message is added!
At the moment the mount/3 function first initializes the list of messages
by loading the latest 20 messages from the database:
def mount(_params, _session, socket) do
if connected?(socket), do: Message.subscribe()
messages = Message.list_messages() |> Enum.reverse() # get the list of messages
changeset = Message.changeset(%Message{}, %{})
{:ok, assign(socket, messages: messages, changeset: changeset)} ## assigns messages to socket
endThen each time a new message is created the handle_info function append
the message to the list of messages:
def handle_info({:message_created, message}, socket) do
messages = socket.assigns.messages ++ [message] # append new message to the existing list
{:noreply, assign(socket, messages: messages)}
endThis can cause issues if the list of messages becomes too long as all the messages are kept in memory on the server.
To minimize the use of the memory,
we can define messages as a temporary assign:
def mount(_params, _session, socket) do
if connected?(socket), do: Message.subscribe()
messages = Message.list_messages() |> Enum.reverse()
changeset = Message.changeset(%Message{}, %{})
{:ok, assign(socket, messages: messages, changeset: changeset),
temporary_assigns: [messages: []]}
endThe list of messages is retrieved once, then it is reset to an empty list.
Now the handle_info/2 only needs to assign the new message to the socket:
def handle_info({:message_created, message}, socket) do
{:noreply, assign(socket, messages: [message])}
endFinally the heex messages template listens for any changes in the list of messages
with phx-update and appends the new message to the existing displayed list.
<ul id='msg-list' phx-update="append">
<%= for message <- @messages do %>
<li id={message.id}>
<b><%= message.name %>:</b>
<%= message.message %>
</li>
<% end %>
</ul>See also the Phoenix temporary-assigns documentation page:
https://hexdocs.pm/phoenix_live_view/dom-patching.html#temporary-assigns
Currently the name field
is left to the person to define manually
before they send a message.
This is fine in a basic demo app,
but we know we can do better.
In this section we'll add authentication
using
auth_plug.
That will allow people using the App
to authenticate with their GitHub or Google account
and then pre-fill the name in the message form.
As per the instructions first create a new API Key at https://authdemo.fly.dev/ e.g:
Then create an .env file
and add your new created api key:
export AUTH_API_KEY=88SwQGzaZoJYXs6ihvwMy2dRVtm6KVeg4tSCjRKtwDvMUYUbi/88SwQDatWtSTMd2rKPnaZsAWFNpbf4vv2ZK7JW2nwuSypMeg/authdemo.fly.devNote: for security reasons, this is not a valid API key. Please create your own, it's free and takes less than a minute.
Add the auth_plug package to your dependencies.
In mix.exs file update your deps function and add:
{:auth_plug, "~> 1.4.10"}This dependency will create new sessions for you
and communicate with the dwyl auth application.
Don't forget to:
- load your key:
source .env - get the dependencies:
mix deps.get
Make sure the AUTH_API_KEY is accessible
before the new dependency is compiled.
You can recompile the dependencies with mix deps.compile --force.
Now we can start adding the authentication feature.
To allow [unauthenticated] "guest" users
access to the chat
we use the AuthPlugOptional plug.
Read more at optional auth.
In the router.ex file,
we create a new Plug pipeline:
# define the new pipeline using auth_plug
pipeline :authOptional, do: plug(AuthPlugOptional)Next update the scope "/", LiveviewChatWeb do block
to the following:
scope "/", LiveviewChatWeb do
pipe_through [:browser, :authOptional]
live "/", MessageLive
get "/login", AuthController, :login
get "/logout", AuthController, :logout
endWe are now allowing authentication to be optional for all the routes in the router. Easy, hey? π
Create the AuthController
with both login/2 and logout/2 functions.
Create a new file:
lib/liveview_chat_web/controllers/auth_controller.ex
and add the following code:
defmodule LiveviewChatWeb.AuthController do
use LiveviewChatWeb, :controller
def login(conn, _params) do
redirect(conn, external: AuthPlug.get_auth_url(conn, "/"))
end
def logout(conn, _params) do
conn
|> AuthPlug.logout()
|> put_status(302)
|> redirect(to: "/")
end
endThe login/2 function
redirects to the dwyl auth app.
Read more about how to use the
AuthPlug.get_auth_url/2
function.
Once authenticated the user will be redirected to the / endpoint
and a jwt session is created on the client.
The logout/2 function invokes AuthPlug.logout/1
which removes the (JWT) session
and redirects back to the homepage.
LiveView provides the
on_mount
callback that lets us run code
before the mount.
We'll use this callback to verify the jwt session
and assign the person (Map)
and loggedin (boolean) values to the socket.
In the
lib/liveview_chat_web/controllers/auth_controller.ex file
add the following code
to define two versions of mount/4:
# import the assign_new function from LiveView
import Phoenix.LiveView, only: [assign_new: 3]
# pattern match on :default auth and check session has jwt
def on_mount(:default, _params, %{"jwt" => jwt} = _session, socket) do
# verify and retrieve jwt stored data
claims = AuthPlug.Token.verify_jwt!(jwt)
# assigns the person and the loggedin values
socket =
socket
|> assign_new(:person, fn ->
AuthPlug.Helpers.strip_struct_metadata(claims)
end)
|> assign_new(:loggedin, fn -> true end)
{:cont, socket}
end
# when jwt is not defined just returns the current socket
def on_mount(:default, _params, _session, socket) do
socket = assign_new(socket, :loggedin, fn -> false end)
{:cont, socket}
endassign_new/3 assigns a value to the socket if it doesn't exists.
Once the on_mount/2 callback is defined,
we can call it in our
lib/liveview_chat_web/live/message_live.ex
file:
defmodule LiveviewChatWeb.MessageLive do
use LiveviewChatWeb, :live_view
alias LiveviewChat.Message
# run authentication on mount
on_mount LiveviewChatWeb.AuthController
We now have all the logic to let people authenticate,
we just need to update our root layout file
lib/liveview_chat_web/templates/layout/root.html.heex
to display a login (or logout) link:
<body>
<header>
<section class="container">
<nav>
<ul>
<%= if @loggedin do %>
<li>
<img width="40px" src={@person.picture}/>
</li>
<li><%= link "logout", to: "/logout" %></li>
<% else %>
<li><%= link "Login", to: "/login" %></li>
<% end %>
</ul>
</nav>
<h1>LiveView Chat Example</h1>
</section>
</header>
<%= @inner_content %>
</body>If the person is not yet loggedin
we display a login link
otherwise the logout link is displayed.
The last step
is to display the name of the logged-in person
in the name field of the message form.
For that we can update the form changeset
in the mount function to set the name parameters:
def mount(_params, _session, socket) do
if connected?(socket), do: Message.subscribe()
# add name parameter if loggedin
changeset =
if socket.assigns.loggedin do
Message.changeset(%Message{}, %{"name" => socket.assigns.person["givenName"]})
else
Message.changeset(%Message{}, %{})
end
messages = Message.list_messages() |> Enum.reverse()
{:ok, assign(socket, messages: messages, changeset: changeset),
temporary_assigns: [messages: []]}
endYou can now run the application and be able to login/logout!
In this section we will use Phoenix Presence to display a list of people who are currently using the application.
The first step is to create the lib/liveview_chat/presence.ex file:
defmodule LiveviewChat.Presence do
use Phoenix.Presence,
otp_app: :liveview_chat,
pubsub_server: LiveviewChat.PubSub
endThen in lib/liveview_chat/application.ex
we add the newly created Presence
module to the list of applications
for the supervisor to start:
def start(_type, _args) do
children = [
# Start the Ecto repository
LiveviewChat.Repo,
# Start the Telemetry supervisor
LiveviewChatWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: LiveviewChat.PubSub},
# Presence
LiveviewChat.Presence,
# Start the Endpoint (http/https)
LiveviewChatWeb.Endpoint
# Start a worker by calling: LiveviewChat.Worker.start_link(arg)
# {LiveviewChat.Worker, arg}
]
...
We are now ready to use the Presence features in our liveview endpoint.
In the lib/liveview_chat_web/live/message_live.ex file,
update the mount function with the following:
@presence_topic "liveview_chat_presence"
def mount(_params, _session, socket) do
if connected?(socket) do
Message.subscribe()
{id, name} =
if socket.assigns.loggedin do
{socket.assigns.person["id"], socket.assigns.person["givenName"]}
else
{socket.id, "guest"}
end
{:ok, _} = Presence.track(self(), @presence_topic, id, %{name: name})
Phoenix.PubSub.subscribe(PubSub, @presence_topic)
end
changeset =
if socket.assigns.loggedin do
Message.changeset(%Message{}, %{"name" => socket.assigns.person["givenName"]})
else
Message.changeset(%Message{}, %{})
end
messages = Message.list_messages() |> Enum.reverse()
{:ok,
assign(socket,
messages: messages,
changeset: changeset,
presence: get_presence_names()
), temporary_assigns: [messages: []]}
endLet's recap the main changes to the mount/3 function:
First we create the module attribute @presence_topic
to define the topic we'll use with the Presence functions.
The following part of the code defines a tuple
containing an id of the person and their name.
The name will default to "guest" if the person is not loggedin.
{id, name} =
if socket.assigns.loggedin do
{socket.assigns.person["id"], socket.assigns.person["givenName"]}
else
{socket.id, "guest"}
endSecondly we use the track/4 function to let Presence knows that a new client is looking at the application:
{:ok, _} = Presence.track(self(), @presence_topic, id, %{name: name})Third we use PubSub to listen to Presence changes (person joining or leaving the application):
Phoenix.PubSub.subscribe(PubSub, @presence_topic)Finally we create a new presence assign in the socket:
presence: get_presence_names()get_presence_names function will return a list of loggedin users and if any
the number of "guest" users.
Add the following code at the end of the MessageLive module:
defp get_presence_names() do
Presence.list(@presence_topic)
|> Enum.map(fn {_k, v} -> List.first(v.metas).name end)
|> group_names()
end
# return list of names and number of guests
defp group_names(names) do
loggedin_names = Enum.filter(names, fn name -> name != "guest" end)
guest_names =
Enum.count(names, fn name -> name == "guest" end)
|> guest_names()
if guest_names do
[guest_names | loggedin_names]
else
loggedin_names
end
end
defp guest_names(0), do: nil
defp guest_names(1), do: "1 guest"
defp guest_names(n), do: "#{n} guests"The important function call in the code above is Presence.list(@presence_topic).
The list/1 function
returns the list of users using the application.
The function group_names and guest_names are just here to manipulate the
Presence data returned by list, see https://hexdocs.pm/phoenix/Phoenix.Presence.html#c:list/1-presence-data-structure
So far we've tracked new people using the chat page in the mount function and
we've been using PubSub to listen to presence changes.
The final step is to handle these changes by adding a handle_info function:
def handle_info(%{event: "presence_diff", payload: _diff}, socket) do
{ :noreply, assign(socket, presence: get_presence_names())}
endFinally, a diff of presence join and leave events will be sent to the clients as they happen in real-time with the "presence_diff" event.
The handle_info function catches the presence_diff event and reassigns to the socket
the presence value with the result of the get_presence_names function call.
To display the names we add the following in the
lib/liveview_chat_web/templates/message/messages.html.heex
template file:
<b>People currently using the app:</b>
<ul>
<%= for name <- @presence do %>
<li>
<%= name %>
</li>
<% end %>
</ul>You should now be able to run the application and see the loggedin users and the number of guest users.
We can test that the template has been properly updated by adding these two
tests in test/liveview_chat_web/live/message_live_test.exs :
test "1 guest online", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert render(view) =~ "1 guest"
end
test "2 guests online", %{conn: conn} do
{:ok, _view, _html} = live(conn, "/")
{:ok, view2, _html} = live(conn, "/")
assert render(view2) =~ "2 guests"
endIf you're new to Tailwind,
please see: https://github.com/dwyl/learn-tailwind
Replace the contents of lib/liveview_chat_web/templates/layout/root.html.heex
with:
<!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.0"/>
<meta name="csrf-token" content={csrf_token_value()}>
<%= live_title_tag assigns[:page_title] || "LiveviewChat", suffix: " Β· Phoenix Framework" %>
<script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<header class="bg-slate-800 w-full min-h-[15%] pt-5 pb-1 mb-2">
<section>
<nav>
<div class="text-white width-[10%] float-left ml-3 -mt-5 align-middle">
<b>People in Chat:</b>
<ul>
<%= for name <- @presence do %>
<li>
<%= name %>
</li>
<% end %>
</ul>
</div>
<ul class="float-right mr-3">
<%= if @loggedin do %>
<li>
<img width="42px" src={@person.picture} class="-mt-3"/>
</li>
<li class="text-white">
<%= link "logout", to: "/logout" %>
</li>
<% else %>
<li class="bg-green-600 text-white rounded-xl px-4 py-2 w-full mb-2 font-bold">
<%= link "Login", to: "/login" %>
</li>
<% end %>
</ul>
</nav>
<h1 class="text-3xl mb-4 text-center font-mono text-white">LiveView Chat Example</h1>
</section>
</header>
<%= @inner_content %>
</body>
</html>And then replace the contents of
lib/liveview_chat_web/templates/message/messages.html.heex
with:
<ul id='msg-list' phx-update="append">
<%= for message <- @messages do %>
<li id={"msg-#{message.id}"} class="px-5">
<small class="float-right text-xs align-middle ">
<%= message.inserted_at %>
</small>
<b><%= message.name %>:</b>
<%= message.message %>
</li>
<% end %>
</ul>
<footer class="fixed bottom-0 w-full bg-slate-300 pb-2 px-5 pt-2">
<.form let={f} for={@changeset} id="form" phx-submit="new_message" phx-hook="Form">
<%= if @loggedin do %>
<%= text_input f, :name, id: "name", value: @person.givenName,
class: "hidden" %>
<% else %>
<%= text_input f, :name, id: "name", placeholder: "Name", autofocus: "true",
class: "border p-2 w-9/12 mb-2 mt-2 mr2" %>
<span class="italic text-2xl ml-4">or</span>
<span class="bg-green-600 text-white rounded-xl px-4 py-2 mb-2 mt-3 float-right">
<%= link "Login", to: "/login" %>
</span>
<%= error_tag f, :name %>
<% end %>
<%= text_input f, :message, id: "msg", placeholder: "Message",
class: "border p-2 w-10/12 mb-2 mt-2 float-left" %>
<p class=" text-amber-600">
<%= error_tag f, :message %>
</p>
<%= submit "Send", class: "bg-sky-600 text-white rounded-xl px-4 py-2 mt-2 float-right" %>
</.form>
</footer>You should now have a UI/layout that looks like this:
If you have questions about any of the Tailwind classes used,
please spend 2 mins Googling
and then if you're still stuck,
open an issue.
If you found this example useful, please βοΈ the GitHub repository so we (and others) know you liked it!
Here are a few other repositories you might want to read:
- github.com/dwyl/phoenix-chat-example A chat application using Phoenix Socket
- github.com/dwyl/phoenix-liveview-counter-tutorial
- github.com/dwyl/phoenix-liveview-todo-list-tutorial
Any questions or suggestions? Do not hesitate to open new issues!
Thank you!








