Tracking online users with Presence

Hi, I’m struggling with case of tracking online users. I went through LiveView book where topic was covered but from other perspective than I need (or I just do not understand yet this). In book it’s written how to track users being on specific page (here: product show page) and only on this page.

I’m creating learning project with admin panel where I put List of all users and one column I just want to leave to Green/Red circle showing if specific user is online or offline.

I found out that Presence/PubSub is really good handling similar cases, but almost all of posts/videos are about tracking users watching specific page what doesnt please me. I didnt find anything about tracking users right after being logged in.

If its important auth system I use is built in phx.gen.auth so It works with conn.

I’m thinking if its possible to use user_socket.ex for it. There is automatically created function:

@impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

I tried put in params session token and then pass it into Presence.track, but while debugging it seems nothing happens.

Another way I was thinking was putting Presence.track in every mount function if session token is present and then publish it and after that broadcast list of active users in admin panel. I tried to avoid putting it in every function and use it once on > connect but if there is not another way I will do it.

Example:

@topic "users_online"

@impl true
  def mount(_params, %{"user_token" => user_token}, socket) do
    user = Accounts.get_user_by_session_token(user_token)
    Presence.track(socket, @topic, user.id, %{online_at: inspect(System.system_time(:second))})

    {:ok,
      socket
      |> assign(:current_user, user)
      |> assign(:products, Catalog.list_available_products())
      |> assign(:brands, Catalog.list_brands())
      |> assign(:categories, Catalog.list_categories())}
  end

  def mount(_params, _session, socket) do
    {:ok,
      socket
      |> assign(:products, Catalog.list_available_products())
      |> assign(:brands, Catalog.list_brands())
      |> assign(:categories, Catalog.list_categories())
    }
  end

I’d suggest using a channel you let all clients join to do presence for all users. No need to track any LV processes for that.

1 Like

As I thought at first, putting function in every mount is overkill. I found a repo that looks like a guide for me and seems to be a solution. I think You meant something like that.

I’d try it, but at first look I think in raises error if token is not present at all.

For now I did:

user_socket.ex

channel "users", EshopyWeb.UserChannel
  
  @impl true
  def connect(%{"user_token" => user_token}, socket, _connect_info) do
    case Phoenix.Token.verify(socket, "user", user_token) do
      {:ok, user_id} ->
        {:ok, assign(socket, :user_id, user_id)}

      {:error, _} ->
        :error
    end
  end

user_channel.ex

@user_presence "user_presence"

  @impl true
  def join("users", _payload, socket) do
      send(self(), :after_join)
      {:ok, socket}
  end

  @impl true
  def handle_info(:after_join, socket) do
    user_id = socket.assigns.user_id
    track_user_presence(user_id, socket)
    {:noreply, socket}
  end

  defp track_user_presence(user_id, socket) do
    {:ok, _} =
      Presence.track(socket, user_id, %{online_at: inspect(System.system_time(:second))})

    push(socket, "user_presence", Presence.list(socket))
    {:noreply, socket}
  end

  # Channels can be used in a request/response fashion
  # by sending replies to requests from the client
  @impl true
  def handle_in("ping", payload, socket) do
    {:reply, {:ok, payload}, socket}
  end

  # It is also common to receive messages from the client and
  # broadcast to everyone in the current topic (users).
  @impl true
  def handle_in("shout", payload, socket) do
    broadcast(socket, "shout", payload)
    {:noreply, socket}
  end

Next I think should be function that gets and/or subscribe to presence list, and after that function invoking that list in admin panel?

Instead of pushing an event named "user_presence", consider pushing a "presence_state" or "presence_diff" event since the client-side Presence library listens for both those events and provides an onSync callback for them.

Finally, we can use the client-side Presence library included in phoenix.js to manage the state and presence diffs that come down the socket. It listens for the "presence_state" and "presence_diff" events and provides a simple callback for you to handle the events as they happen, with the onSync callback.
source: Presence | Phoenix docs

Meaning once you’ve updated the event name like so:

defp track_user_presence(user_id, socket) do
    {:ok, _} =
      Presence.track(socket, user_id, %{online_at: inspect(System.system_time(:second))})

    push(socket, "presence_state", Presence.list(socket))
    {:noreply, socket}
end

You can then set a callback function like so:

import {Socket, Presence} from "phoenix"

let socket = new Socket("/socket", {params: {token: window.userToken}})
let channel = socket.channel("users", {})
let presence = new Presence(channel)

// update presence column to reflect current presence state
function updatePresenceColumn(presence) {
  presence.list((id, {metas: [first, ...rest]}) => {
    ...
  })
}

socket.connect()

presence.onSync(() => renderOnlineUsers(presence))

channel.join()

Alternatively, if you’re using LiveView for the admin panel, you could even subscribe to the Presence events and update the column from the server. See this Phoenix Presence with Phoenix LiveView article for more on this approach.

I’m fully into Elixir, so I want to put into my learning app many even small features just to touch new topic, but this overhelmed me (even if it looks really simple while reading) or I just need to be more focused, make step back and find good learning resource.

I paste my code with changes I’ve made, maybe there is small thing I just missed that make feature doesnt work as I want.

user_socket.ex I left without changes.

user_channel.ex I changed according to your tips:

@user_presence "user_presence"

  @impl true
  def join("users", _payload, socket) do
      send(self(), :after_join)
      {:ok, socket}
  end

  @impl true
  def handle_info(:after_join, socket) do
    user_id = socket.assigns.user_id

    track_user_presence(user_id, socket)

    push(socket, "presence_state", Presence.list(socket))

    {:noreply, socket}
  end

  defp track_user_presence(user_id, socket) do
    {:ok, _} =
      Presence.track(socket, user_id, %{online_at: inspect(System.system_time(:second))})
  end

In admin panel users module - I’ve got something like this. I used also code from LiveView Sophie’s book.
Fast explain - if user is recognized as an admin it shows users list and invokes view resposible for showing online users (later I want to push all of it into just green/red dot in table, but for now I just want to see component that shows me active users)

@user_presence "user_presence"

  @impl true
  def mount(_params, %{"user_token" => user_token}, socket) do
    user = Accounts.get_user_by_session_token(user_token)

    case user.role do
      :user ->
        {:ok,
          socket
          |> assign(:current_user, user)
          |> put_flash(:info, "Unauthorized")
          |> redirect(to: Routes.home_index_path(socket, :index))}

      :admin ->
        EshopyWeb.Endpoint.subscribe(@user_presence)

        {:ok,
          socket
          |> assign(:current_user, user)
          |> assign(:user_presence_component_id, "user_presence")
          |> assign(:users, Accounts.list_users())}
    end
  end

  @impl true
  def handle_info(%{event: "presence_state"}, socket) do
    send_update(
      UserPresenceLive,
      id: socket.assigns.user_presence_component_id)

    {:noreply, socket}
  end

According to Sophie’s book I created helpers to extract infos from metas:
presence.ex:

@user_presence "user_presence"
  def list_online_users() do
    Presence.list(@user_presence)
    |> Enum.map(&extract_users/1)
  end

  defp extract_users({user_id, %{metas: metas}}) do
    {user_id, users_from_metas_list(metas)}
  end

  defp users_from_metas_list(metas_list) do
    Enum.map(metas_list, &users_from_meta_map/1)
    |> List.flatten()
    |> Enum.uniq()
  end

  defp users_from_meta_map(meta_map) do
    get_in(meta_map, [:users])
  end

I created live_component user_presence.ex:

defmodule EshopyWeb.UserPresenceLive do
  use EshopyWeb, :live_component

  alias EshopyWeb.Presence

  @user_presence "user_presence"

  def update(_assigns, socket) do
    {:ok,
      socket
      |> assign_user_presence()}
  end

  defp assign_user_presence(socket) do
    assign(socket, :user_presence, Presence.list_online_users())
  end
end

user_presence.html.heex:

<div class="user-presence-component">
    <div>WITAM
        <%= for {user_id, users} <- @user_presence do %>
        <h3> <%= user_id %> </h3>
            <%= for user <- users do %>
            <%= user.email %>
                    <p>ONLINE</p>
            <% end %>
        <% end %>
    </div>
</div>

I know the view is messed up for know. I just played with different settings to show anything. I can see that admin panel invokes component (“WITAM” shows up on screen) but thats all.

What I think I messed up:

  1. My first idea was that user_socket doesnt work well and doesnt verify token, but as this is code from docs I dont think so.
  2. In user_channel I saw also function that uses self() instead of socket while tracking user, but its also code directly from docs, so It must work :smiley: Maybe user just do not enter channel and he cant be visible
  3. Now comes part that I had to write by myself so somewhere there are placed mistakes.
    a) presence.ex - I probably misspelled what I want to extract from metas or I try to dig too deep into it.
    b) what leads to big mess in live_component that I clean up, It doesnt show anything because I try to find something that doesnt exist.
    c) handle_info doesnt have enough informations to update/create full component

Sorry for many posts about simple stuff, but as It worked for me while I was working with specific view (without channels) and in more general way it doesnt that makes me sick and wasted :smiley:

Thanks for all your help - its more clear for me now :slight_smile:

1 Like

Things look simple once you know it, but getting there isn’t always simple. Great to see someone having a go at a problem, taking feedback on board and working through their findings. Hope you’re loving Elixir!

3 Likes

Hmm, from a quick look, one potential problem is that the global topic that all users join is named "users" while what looks to be a wrapper for Phoenix.PubSub.subscribe/3 in your LiveView is subscribing to a topic named "user_presence". These should match, otherwise the the message will not reach the LiveView handle_info callback. It looks like you’ve also declared @user_presence "user_presence" in your user channel, but aren’t using it. Did you mean to do this instead?
def join(@user_presence, _payload, socket) do

A side note, I’d encourage renaming here for clarity, for example @topic "users_online" that you had mentioned earlier. I’d also suggest confirming that you are joining the right channel from the client side e.g.
let channel = socket.channel("user_presence", {}).

I’d also highly recommend going through the Phoenix Presence docs and taking the time to build a clearer mental model of what’s happenings. Then, I’d suggest adding some IO.puts and/or IO.inspects into the GenServer callbacks to see if/when/where messages are sent and received. A general approach would be to figure out what is confirmed to be working and then work your way up. In this case, is the client side JS successfully connecting to the socket on the server? Then are “presence_state” events being successfully pushed? Then are they successfully being received? So on and so forth.

1 Like

You need an update :slight_smile:

Actually, I works now. I need to figure out few things more, but ‘to do’ I wrote down works.

Of course name of room and topic should have been the same, but other reason was my negligence. I didnt generate socket, but wrote it manually, and this time I completely forgot about JS part (I hope It going to be possible make LiveView Javascript free in future ;D).

I had to calmly read everything again and do it step by step.

I have one last question about functionality. Is it possible to invoke live_component containing just result (true/false, online/offline, whatever similar) in cell for every user? Or whole component can be invoked only once?

I ask because as feature works perfectly as a separate div with component on the bottom of site (automatically shows new users or deletes those who left) I didnt have idea how to make it work in cell. I have written some id extractor and function checks if topic contains user_id.

users list view:

def mount(_params, %{"user_token" => user_token}, socket) do
    ...
    ...
    ...
      :admin ->
        EshopyWeb.Endpoint.subscribe(@topic)

        {:ok,
          socket
          |> assign(:current_user, user)
          |> assign(:user_presence_component_id, "user_presence")
          |> assign(:online_users, extract_online_user_ids())
          |> assign(:users, Accounts.list_users())}
    end
  end

defp extract_online_user_ids() do
    Enum.map(Presence.list_online_users(), fn {k, _v} -> k end)
  end

user.html.heex

<%= for user <- @users do %>
                    <tr id={"user-#{user.id}"}>
                        ...
                        <td class="border border-slate-300">
                            <%= if Enum.member?(@online_users, "#{user.id}") do %>
                                <div class="flex align-center justify-center">
                                    <ion-icon name="person-circle-outline"></ion-icon>
                                </div>
                            <% end %>
                        ....
                <% end %>

Instead of Enum.member I think how to put here live_comp to make it works automatically - without refreshing site (I know its super sugar version - practically not needed by anyone :D)

Thanks again for big help. This is more understandable for me, I need to play more with things like that :slight_smile:

UPDATE:

I needed to ask also about terminal logs. If I open private window (without being logged in) I receive a lot of stuff in terminal like this:

[info] REFUSED CONNECTION TO EshopyWeb.UserSocket in 34µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"token" => "", "vsn" => "2.0.0"}
[info] REFUSED CONNECTION TO EshopyWeb.UserSocket in 27µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"token" => "", "vsn" => "2.0.0"}
[info] REFUSED CONNECTION TO EshopyWeb.UserSocket in 31µs
  Transport: :websocket
  Serializer: Phoenix.Socket.V2.JSONSerializer
  Parameters: %{"token" => "", "vsn" => "2.0.0"}

I uderstand its socket which doesnt see token from anynomous user and connect has been refused, but if its normal that I receive every second messages like that :wink:

Greetings :slight_smile:

Not sure if I’m interpreting this correctly, but if you want to don’t want to handle this Enum.member? logic in the view, you could move it into the LiveView by decorating/adding an online attribute to each individual user such that you can use for user <- @users along with user.online in the view/heex template.

def mount(...) do
  ...
  online_user_ids = extract_online_user_ids()
  users = Enum.map(Accounts.list_users(), fn user ->
    online =  Enum.member?(online_user_ids, user.id)
    Map.put(user, :online, online)
  end)

  {:ok, socket |> assign(:users, users)}
end

At the moment, I don’t see any code that would make the online status update in real time. I see that you’re subscribing to changes to the topic, but not handling any messages that result from that subscription. To do that, I’d expect to see something like:

def handle_info(@topic, socket) do
  online_user_ids = extract_online_user_ids()
  users = Enum.map(socket.assigns.user, fn user ->
    online =  Enum.member?(online_user_ids, user.id)
    Map.put(user, :online, online)
  end)

  {:ok, socket |> assign(:users, users)}
end

Regarding the refused connection, I’d suggest revisiting how you’re handling websocket authentication as well as going through these docs on Using Token Authentication.

1 Like

I did step by step like docs said. Raising :error provides to logs in terminal every second, at first glance i refactored to this:

user_socket.ex

def connect(%{"token" => token}, socket, _connect_info) do
    case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
      {:ok, user_id} ->
        {:ok, assign(socket, :user_id, user_id)}
      {:error, _reason} ->
        {:ok, socket}
    end
  end

user_channel.ex

def handle_info(:after_join, socket) do
    case socket.assigns do
      %{user_id: user_id} ->
        track_user_presence(user_id, socket)
        push(socket, "presence_state", Presence.list(socket))
        {:noreply, socket}

      _ ->
        {:noreply, socket}
      end
  end

And now only log appears in terminal if user is authenticated and connected.

I’ll be working on updating status topic, and I have an issue that started to appear after using sockets/channels - I opened another topic about that, but still is missing responses ;D

Thanks for big help once again

Update: I figured out, that I can use case statement in def join to omit sending process.