Awesome
<div align="center">LiveView
Chat Tutorial
</div>
Why? 馃し
We really wanted a Free & Open Source
real-world example
with full code,
tests and auth. <br />
We wrote this
so we could point
people in our team/community
learning Phoenix LiveView
to it. <br />
This LiveView
example/tutorial takes you from zero
to fully functioning app
in 20 minutes.
What? 馃挰
Here is the table of contents of what you can expect to cover in this example/tutorial:
LiveView
Chat Tutorial- Why? 馃し
- What? 馃挰
- Who? 馃懁
- How? 馃捇
- 0. Prerequisites
- 1. Create
Phoenix
App - 2. Create
live
Directory,LiveView
Controller and Template - 3. Update
router.ex
- 4. Update Tests
- 5. Migration and Schema
- 6 Update
mount/3
function - 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?
Who? 馃懁
Anyone learning Phoenix LiveView
wanting a self-contained tutorial
including:
Setup
, Testing
, Authentication
, Presence
,
How? 馃捇
0. Prerequisites
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!
1. Create Phoenix
App
Start by creating the new liveview_chat
Phoenix
application:
mix phx.new liveview_chat --no-mailer --no-dashboard
We 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.setup
You should see output similar to the following:
The database for LiveviewChat.Repo has been created
14:20:19.71 [info] Migrations already up
Once that command succeeds You should now be able to start the application by running the command:
mix phx.server
You 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:
2. Create live
Directory, LiveView
Controller and Template
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
end
Note: 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. <br />
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
end
This 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>
3. Update router.ex
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, :index
with MessageLive
controller:
scope "/", LiveviewChatWeb do
pipe_through :browser
live "/", MessageLive
end
Now if you refresh the page you should see the following:
4. Update Tests
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 test
You 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 failure
This 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
end
We 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.exs
rm lib/liveview_chat_web/controllers/page_controller.ex
rm test/liveview_chat_web/views/page_view_test.exs
rm lib/liveview_chat_web/views/page_view.ex
rm -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 841084
5. Migration and Schema
With 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:string
Note: don't forget to run
mix ecto.migrate
to create the newmessages
table 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
end
We 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.
6 Update mount/3
function
Open the
lib/liveview_chat_web/live/message_live.ex
file
and add the following line at line 3:
alias LiveviewChat.Message
Next 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)}
end
mount/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.
7. Update Template
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
assigns
map as argument and returns a renderedstruct
built with the~H
sigil.
7.1 Update the Test Assertion
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.
8. Handle Message Creation Events
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
end
The 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.
8.1 Test Message Creation Validation
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)"
end
We 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.
9. PubSub
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.PubSub
Next 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.
9.1 Notify Connected Clients of New Messages
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)
end
9.2 Update mount/3
We 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.PubSub
Then 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)}
end
mount/3
now checks the socket is connected
then calls the new Message.subscribe/0
function.
9.3 Update handle_event/3
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
end
9.4 Create handle_info/2
The 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)}
end
When 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.
9.5 Test Messages are Displaying
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"
end
You should now have a functional chat application using liveView!
Run the Phoenix
App with:
mix phx.server
Visit the App localhost:4000
in 2 or more browsers,
and send yourself some messages!
10. Hooks
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,
LiveView
will 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: Hooks
The 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!
11. Optional: Temporary assigns
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
end
Then 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)}
end
This 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: []]}
end
The 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])}
end
Finally 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
12. Authentication
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.
12.1 Create AUTH_API_KEY
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.dev
Note: for security reasons, this is not a valid API key. Please create your own, it's free and takes less than a minute.
12.2 Install auth_plug
猬囷笍
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. <br />
You can recompile the dependencies with mix deps.compile --force
.
Now we can start adding the authentication feature.
12.3 Create the Optional Auth Pipeline in router.ex
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
end
We are now allowing authentication to be optional for all the routes in the router. Easy, hey? 馃槈
12.4 Create AuthController
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
end
The 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.
12.5 Create on_mount/4
functions
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}
end
assign_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: []]}
end
You can now run the application and be able to login/logout!
<!-- Yes, we know we skipped step 13 ... mostly to check if you're paying attention. 馃槣 But also because some people find it "unlucky" ... https://en.wikipedia.org/wiki/13_(number)#Luck 馃檮 -->14. Presence
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
end
Then 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. <br />
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: []]}
end
Let'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"}
end
Secondly 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())}
end
Finally, 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"
end
15. Tailwind CSS Stylin'
If 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.
What's Next?
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!