Can you hear me now? - Using Phoenix Presence
Welcome to the third installment of our series on building a 'Single Page App' with Phoenix LiveView. In this series we are exploring building a highly interactive application that most devs would assume has to be coded in javascript in the browser. In our previous installment we built the stock stock generator based LiveView app. This app shows a list of documents and allows the user to edit documents from the list in a javascript rich text editor. In this installment we will be exploring using Channels and Presence to display avatars for all the users who have the document open in the editor.
Channels - What are they?
One of the elegant parts of Phoenix is how one feature builds on top of another feature, but those underlying features are exposed and useful on their own. In this particular case, Phoenix.LiveView leans on Phoenix.Channels and Channels lean on Phoenix.PubSub. All three are useful on their own but work together to build up convenient functionality. As mentioned in the previous post, Elixir is built on Erlang and the BEAM (the Erlang VM, also known as OTP). Channels leverage the BEAM's ability to communicate between millions of lightweight processes and nodes to provide a standardized way to send and receive messages. Many Phoenix features are built on top of Channels, including file uploads and Phoenix's live-reload while in dev feature. You can generate a custom channel using the mix phx.gen.channel
command. Your new custom channel will have a default join
method that gets called when a client joins your channel. You will need to add one or more handle_on
functions to respond to incomming messages. As you do all over the place in Elixir, you can easily pattern match on the message topic to partition your message handling code. Other frameworks offer message passing and can support things like real-time chat, but none can do so as efficiently right now as the BEAM. There's a reason Discord is built with Elixir. Phoenix Channels are built on that same foundation. As usual, check the Channels guide for the definitive word on Channels. The team is excellent about keeping the docs up to date.
OK, enough theory. How do we use Channels in our app? As we found last time, "there's a generator for that." Let's create a 'room' where our documents can publish and subscribe to events when users enter or leave a document.
mix phx.gen.channel Document
# ---- output ----
* creating lib/editor_web/channels/document_channel.ex
* creating test/editor_web/channels/document_channel_test.exs
The default socket handler - EditorWeb.UserSocket - was not found.
Do you want to create it? [Yn]
* creating lib/editor_web/channels/user_socket.ex
* creating assets/js/user_socket.js
Add the socket handler to your `lib/editor_web/endpoint.ex`, for example:
socket "/socket", EditorWeb.UserSocket,
websocket: true,
longpoll: false
For the front-end integration, you need to import the `user_socket.js`
in your `assets/js/app.js` file:
import "./user_socket.js"
So let's follow the instructions. We make lib/editor_web/endpoint.ex
look like this:
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
socket "/socket", EditorWeb.UserSocket, # new code here
websocket: true,
longpoll: false
And we add the import "./user_socket.js"
line near the top of our assets/js/app.js file. Notice that the generator created a new channel directory with the document channel we requested as well as a related test file and the default users socket. There's a nice writeup around using Channels at CodeCast if you'd like to dig deeper.
Channels and Presence
Channels are the documented way to work with Phoenix.Presence. There's a nice writeup there about how to build a Presence module and link it to a custom channel. I recently noticed in Sophie DeBennedetto's Programming Phoenix LiveView book that she skipped the channels implementation and linked the presence module to Phoenix.PubSub instead. I'm going to use that approach here because it is one less setup step to demoing Presence.
Presence
Phoenix.Presence is a module that you can use to keep track of which users are on particular 'pages' of your app. In our case we want to show icons for all the users who are editing a particular document. Let's walk through the process of setting up a Presence subscription and the code to display that list of users.
First we need to create a custom presence module. As with many things in Phoenix, there's a generator for that. The generator creates two files, the presence module and the related test file. Here's the command and it's output.
$ mix phx.gen.presence DocumentPresence
# ------ output --------
* creating lib/editor_web/channels/document_presence.ex
Add your new module to your supervision tree,
in lib/editor/application.ex:
children = [
...
EditorWeb.DocumentPresence
]
Step 1 is to follow the instructions and add it to the supervision tree by adding it to the list of children. Next let's look at the generated file at lib/editor/channels/document_presence.ex
Notice that it sets up the link to Phoenix.Presence and names the otp_app and pubsub_server. Using the presence module involves two main functions, track/3
and list/1
. Track is used to mark a session as present and list is used to get a list of users who are present for a given topic. The Phoenix.Presence module uses the __using__
macro to insert these methods into your custom presence module. The default implementation looks like this:
defmodule EditorWeb.DocumentPresence do
use Phoenix.Presence,
otp_app: :editor,
pubsub_server: Editor.PubSub
end
So you could just alias EditorWeb.DocumentPresence
in your LiveView component and use track
and list
directly, but doing it that way misses an opportunity to make our lives a bit easier. I added custom functions to DocumentPresence to make it more convenient to use throughout the app.
@activity_topic "document_activity"
@spec topic() :: String.t()
def topic(), do: @activity_topic
@spec track_user(pid, Integer.t(), String.t()) :: {:error, any} | {:ok, binary}
def track_user(pid, document_id, user_email) do
track(pid, @activity_topic, document_id, %{users: [%{email: user_email}]})
end
@spec list_users_for(Integer.t()) :: List.t()
def list_users_for(document_id) do
users = list(@activity_topic)
users
|> Map.get(to_string(document_id), %{metas: []})
|> Map.get(:metas)
|> users_from_metas
end
defp users_from_metas(metas) do
Enum.map(metas, &get_in(&1, [:users]))
|> List.flatten()
|> Enum.map(&Map.get(&1, :email))
|> Enum.uniq()
end
There are two major takeaways from this. First I wrapped track
so the caller doesn't have to remember what the correct topic is. Users for a given document id are stored as an array of maps. This is because I can easily imagine needing to add other keys in the future. You want your presence data to be as skinny as reasonably possible, but not so skinny that it requires lots of processing to use since it will likely run frequently. Second, I wrapped the list
call to gain a consistent topic and consistent output that is easy for the client to use. Notice that list
can return an empty map when the first user comes to the first document. To handle that, Map.get has a 3rd parameter to set a default value. Otherwise list
will return a map with the stringified document ids as keys and metas:
which is an array of maps, something like this:
%{
"1" => %{
metas: [
%{
phx_ref: "FutMLIV9mPikhAPG",
users: [%{email: "sample@example.com"}]
}
]
},
"2" => %{
metas: [
%{
phx_ref: "FutM_ESb5i6khAUB",
users: [%{email: "sample@example.com"}]
},
%{
phx_ref: "FutNloEoWTCkhAVm",
users: [%{email: "another_user@example.com"}]
}
]
}
}
Since we know via our default value that metas will always exist, we can simply loop through the metas pulling out the users and then their emails. This list of emails can be attached to the socket and used to display a list of avatars of users who are viewing a document. I wanted to be able to share the PubSub topic name with users of this module so I added a topic
function as the simplest way to make that value public.
Now let's see how these two functions are called to make the whole scheme work. If you remember the editor LiveView module from part 2 of this series, we will be adding to it now. We need to include the new DocumentPresence module and use it to set up tracking and responding to others joining and leaving a document.
defmodule EditorWeb.DocumentLive.Edit do
# ...
alias EditorWeb.DocumentPresence # <---- new code
@impl true
def mount(_params, session, socket) do
EditorWeb.Endpoint.subscribe(DocumentPresence.topic()) # <---- new code
{:ok, socket |> assign(user: Accounts.get_user_by_session_token(session["user_token"]))}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
document = Documents.get_document!(id)
maybe_track_user(document, socket)
...
end
# new code
@spec maybe_track_user(Map.t(), Map.t()) :: nil | {:error, any} | {:ok, binary}
def maybe_track_user(
document,
%{assigns: %{live_action: :edit, user: user}} = socket
) do
if connected?(socket) do
DocumentPresence.track_user(self(), document.id, user.email)
end
end
def maybe_track_user(_docment, _socket), do: nil
@impl true
def handle_info(%{event: "presence_diff"}, socket) do
document = socket.assigns.document
send_update(EditorWeb.DocumentActivityLive, id: "doc#{document.id}", document: document)
{:noreply, socket}
end
# the rest
end
We have to complete several steps to wire in our presence module. When our LiveView mounts, we need to subscribe to presence events. Since we are using PubSub, we can just subscribe
to the topic via the Endpoint module. This module calls through to PubSub with the correct PubSub id. Our LiveView module also has to mark that the user is viewing this document. Since we already have a handle_params function, that seems like the best place to add that. We add a new function maybe_track_user
to accomplish that. The function is called maybe_track_user
because it only does so if the circumstances are right. This maybe_
prefix is an Elixir convention you will see in other places as well. So if we have a document and our socket is connected, our track_user
function gets called to let presence know that the user is viewing that document. If any other users are also viewing that document, their process will receive a presence_diff
event that will trigger an update event to our LiveComponent. That component will process the update and reread the users list and update the UI. We will be discussing LiveComponents in more detail in a future installment. For now we just need to know that the update function calls assign(socket, users: DocumentPresence.list_users_for(document.id))
which triggers a rerender.
We have covered a lot of ground in this post! In our next gathering we'll be talking about LiveComponents and going deeper with javascript integration with phx-hooks
. As a reminder, I've created a repo with all this code. The code as it exists in this article is in the blog-post-3 branch.