Most chat apps have a feature
where you can check who is currently online.
We can use Presence
to track processes
that are connected to our public channel room.
By using Presence, we can show which processes are actively connected to the channel and show it to the person!
It isn't as difficult as you may think. Let's do it! 🏃♂️
Presence
is a Phoenix module
that needs to be set up before being used.
Firstly, in lib/chat_web
,
create a file called presence.ex
and add the following code.
defmodule ChatWeb.Presence do
use Phoenix.Presence,
otp_app: :chat,
pubsub_server: Chat.PubSub
end
We are populating the :otp_app
and :pubsub_server
with already pre-existent configurations.
For example, :chat
is configured
inside the config/config.exs
file
and being fed to :otp_app
.
Next, we need to add this new supervisor
to the supervision tree
in lib/chat/application.ex
.
Make sure to add ChatWeb.Presence
before ChatWeb.Endpoint
and after the PubSub
child.
children = [
ChatWeb.Telemetry,
Chat.Repo,
{Phoenix.PubSub, name: Chat.PubSub},
ChatWeb.Presence, # add this line
ChatWeb.Endpoint
]
And we're done! 🎉
We can now freely use Presence
by importing alias ChatWeb.Presence
in lib/chat_web/channels/room_channel.ex
.
We are ready to track people
in room_channel.ex
!
We have to note that not all people are authenticated. Ideally, we would begin tracking people/processes whenever they join the channel. However, when people are not using authentication (via Github or Google, for example), we don't know their names until they send a message.
Additionally, a person using the app can have different names throughout their visit. So different names must be properly removed or added according to the processes that are associated to it.
By allowing people to have multiple names, there might be some cases where two different people can have the same name. Therefore, we need to properly remove one name pertaining to one process whenever it leaves the room, while maintaining the other same name for the other process in the online people list.
Let's start implementing Presence
into our application
so it satisfies these needs!
In lib/chat_web/channels/room_channel.ex
,
let's import Presence
and start tracking processes.
alias ChatWeb.Presence
Change the handle_info(:after_join, socket)
function
so it looks like the following.
def handle_info(:after_join, socket) do
# Get messages and list them
Chat.Message.get_messages()
|> Enum.reverse() # reverts the enum to display the latest message at the bottom of the page
|> Enum.each(fn msg ->
push(socket, "shout", %{
name: msg.name,
})
end)
# Send currently online people in lobby
push(socket, "presence_state", Presence.list("room:lobby"))
{:noreply, socket}
end
We have added the push/3
function
that the current presence information for the socket's topic
is pushed to the client as a "presence_state"
event.
Next,
in handle_in("shout", payload, socket)
,
change it like so:
def handle_in("shout", payload, socket) do
# Insert message in database
{:ok, msg} = Chat.Message.changeset(%Chat.Message{}, payload) |> Chat.Repo.insert()
# Assigning name to socket assigns and tracking presence
socket
|> assign(:person_name, msg.name)
|> track_presence()
|> broadcast("shout", Map.put_new(payload, :id, msg.id))
{:noreply, socket}
end
defp track_presence(%{assigns: %{person_name: person_name}} = socket) do
Presence.track(socket, person_name, %{
online_at: inspect(System.system_time(:second))
})
socket
end
Every time the person sends a message
(sends a "shout"
event),
the :person_name
if assigned to the socket assigns,
the process is tracked using Presence
(by calling do_track
,
which uses the
Presence.track/3
function)
and broadcasts the event to other people.
e.g.
lib/chat_web/channels/room_channel.ex
Now that we are tracking each process, we are now ready to show to each person who is online!
Let's first add a space in our page dedicated to show the list of online people. We want to make our page responsive, meaning we want our page to be properly resized according to any viewport dimension, be it a mobile device or desktop.
Open
lib/chat_web/controllers/page_html/home.html.heex
and change it.
<!-- Shown only on mobile devices -->
<div class="mt-[5rem] p-4 ml-4 mr-4 mb-4 border-2 radius rounded-md lg:hidden">
<div class="flex justify-start flex-col overflow-hidden whitespace-nowrap">
<h5 class="text-md leading-tight font-medium mb-2 text-green-700">people online</h5>
<ul id="people_online-list-mobile" phx-update="append" class="pa-1"></ul>
</div>
</div>
<div id="chat-container" class="max-h-screen flex flex-col lg:flex-row lg:min-h-screen ">
<div class="flex flex-col lg:flex-grow">
<!-- The list of messages will appear here: -->
<ul id="msg-list" phx-update="append" class="pa-1 lg:mt-[4rem] overflow-y-auto"></ul>
<footer class="bg-slate-800 p-2 h-[3rem] bottom-0 w-full flex justify-center sticky mt-[auto]">
<div class="w-full flex flex-row items-center text-gray-700 focus:outline-none font-normal">
<%= if @loggedin do %>
<input
type="text"
disabled
class="hidden"
id="name"
placeholder={person_name(@person)}
value={person_name(@person)}
/>
<% else %>
<input
type="text"
id="name"
placeholder="Name"
required
class="grow-0 w-1/6 px-1.5 py-1.5"
/>
<% end %>
<input
type="text"
id="msg"
placeholder="Your message"
required
class="grow w-2/3 mx-1 px-2 py-1.5"
/>
<button
id="send"
class="text-white bold rounded px-3 py-1.5 w-fit
transition-colors duration-150 bg-sky-500 hover:bg-sky-600"
>
Send
</button>
</div>
</footer>
</div>
<!-- Online people will only be shown here on desktop devices -->
<div class="hidden lg:flex">
<div class="mt-[5rem] p-4 ml-4 mr-4 mb-4 border-2 radius rounded-md max-w-[20vw]">
<div class="flex justify-start flex-col overflow-hidden whitespace-nowrap">
<h5 class="text-md leading-tight font-medium mb-2 text-green-700">people online</h5>
<ul id="people_online-list-desktop" phx-update="append" class="pa-1"></ul>
</div>
</div>
</div>
</div>
We have made some changed to the layout of the image so it works better on both mobile and desktop devices.
What's important is
that we've added two <ul>
elements with id
people_online-list
,
one for both mobile and another for desktop.
We've done it like this because this list needs to be in
different places depending on the device.
We've achieved this by hiding one list on mobile devices
and showing it on desktops,
and vice-versa.
This is why we have two lists:
- one for mobile,
with
id
"people_online-list-mobile"
. - one for desktop,
with
id
"people_online-list-desktop"
.
The names of the online people will be appended to both lists dynamically.
Your page should now look like this on your desktop.
In a mobile device, the list gets "pushed" to the top of the page.
The only thing that's left
is populating the list
with <li>
elements (online people) inside the <ul>
list.
We are going to make these changes
in assets/js/app.js
.
Add these functions to the file.
const peopleListMobile = document.getElementById('people_online-list-mobile'); // online people list mobile
const peopleListDesktop = document.getElementById('people_online-list-desktop'); // online people list desktop
// This function will be probably caught when the person first enters the page
channel.on('presence_state', function (payload) {
// Array of objects with id and name
const currentlyOnlinePeople = Object.entries(payload).map(elem => ({name: elem[0], id: elem[1].metas[0].phx_ref}))
updateOnlinePeopleList(currentlyOnlinePeople)
})
// Listening to presence events whenever a person leaves or joins
channel.on('presence_diff', function (payload) {
if(payload.joins && payload.leaves) {
// Array of objects with id and name
const currentlyOnlinePeople = Object.entries(payload.joins).map(elem => ({name: elem[0], id: elem[1].metas[0].phx_ref}))
const peopleThatLeft = Object.entries(payload.leaves).map(elem => ({name: elem[0], id: elem[1].metas[0].phx_ref}))
updateOnlinePeopleList(currentlyOnlinePeople)
removePeopleThatLeft(peopleThatLeft)
}
});
function updateOnlinePeopleList(currentlyOnlinePeople) {
// Add joined people
for (var i = currentlyOnlinePeople.length - 1; i >= 0; i--) {
const name = currentlyOnlinePeople[i].name
const id = name + "-" + currentlyOnlinePeople[i].id
if (document.getElementById(name) == null) {
var liMobile = document.createElement("li"); // create new person list item DOM element for mobile
var liDesktop = document.createElement("li"); // create new person list item DOM element for desktop
liMobile.id = id + '_mobile'
liDesktop.id = id + '_desktop'
liMobile.innerHTML = `<caption>${sanitizeString(name)}</caption>`
liDesktop.innerHTML = `<caption>${sanitizeString(name)}</caption>`
peopleListMobile.appendChild(liMobile); // append to peoplelist
peopleListDesktop.appendChild(liDesktop); // append to peoplelist
}
}
}
function removePeopleThatLeft(peopleThatLeft) {
// Remove people that left
for (var i = peopleThatLeft.length - 1; i >= 0; i--) {
const name = peopleThatLeft[i].name
const id = name + "-" + peopleThatLeft[i].id
const personThatLeftMobile = document.getElementById(id + '_mobile')
const personThatLeftDesktop = document.getElementById(id + '_desktop')
if (personThatLeftMobile != null && personThatLeftDesktop != null) {
peopleListMobile.removeChild(personThatLeftMobile); // remove the person from list mobile
peopleListDesktop.removeChild(personThatLeftDesktop); // remove the person from list desktop
}
}
}
function sanitizeString(str){
str = str.replace(/[^a-z0-9áéíóúñü \.,_-]/gim,"");
return str.trim();
}
e.g.
assets/js/app.js
That's a lot!
To understand the code,
we need to understand
what presence_state
and presence_diff
are.
These are
documented in the Presence
docs,
but making long story short,
they are objects that are sent to the client
with information about the processes within the channel.
presence_state
is pushed whenever the person joins the channel, which is useful to see who already is online in the channel on the get-go. It has a structure like:
{
"name": {
"metas": [
{
"online_at": "1676345588",
"phx_ref": "F0OTbkPqt3DqDALG"
}
]
}
}
presence_diff
is sent to the client whenever a person joins or leaves the channel. It's a diff structure, a map of:joins
and:leaves
.
%{
joins: %{"123" => %{metas: [%{status: "away", phx_ref: ...}]}},
leaves: %{"456" => %{metas: [%{status: "online", phx_ref: ...}]}}
}
With the information of both of these objects, we can construct our online person list!
We use metadata like phx_ref
(which uniquely identifies the metadata for a given key)
and the name of the person
to act as an id
of the element to be added to the list.
This way,
whenever a person leaves,
we remove the element from the list
by this id
.
Do notice we are adding these elements, technically, two lists, one that is shown on mobile devices and another that is shown on desktops.
When a person sends a message, the browser should scroll down.
This behaviour already existed. But now that we've changes the UI, scrolling no longer works. This is because scrolling is now done on the list of messages element in mobile devices.
To fix this,
we just need to add a last line
in sendMessage
in assets/js/app.js
.
function sendMessage() {
channel.push('shout', {
name: name.value || "guest",
message: msg.value,
inserted_at: new Date()
});
msg.value = '';
window.scrollTo(0, document.documentElement.scrollHeight) // scroll to the end of the page on send for desktop
ul.scrollTo(0, ul.scrollHeight) // scroll to the end of the page on send for mobile (THIS IS THE NEW LINE)
}
If you run mix test
,
you will notice one test is broken.
More specifically in
test/chat_web/channels/room_channel_test.exs
.
We can quickly fix it!
Locate the shout broadcasts to room:lobby
test,
and change it.
test "shout broadcasts to room:lobby a message with name", %{socket: socket} do
push(socket, "shout", %{"name" => "test_name", "message" => "hey all"})
assert_broadcast "shout", %{"name" => "test_name", "message" => "hey all"}
end
If you run mix test
,
all tests should pass!
............
Finished in 0.7 seconds (0.6s async, 0.05s sync)
12 tests, 0 failures
We should all be done!
If you run the app
(mix phx.server
)
in two different windows,
you will notice that the list of online people
will be shown!
This also works for authenticated people.
Check the gif below for a quick demo!
Notice how the anonymonus person goes under two different names. Both disappear when he leaves!