Live user cursors with Phoenix Presence

Koen van Gilst

Koen van Gilst / February 1, 2022

13 min read––– views

Don't forget to also read the second part of this tutorial, where I implement a similar functionality using Phoenix LiveView!

For the past few months, I've been learning about Phoenix and Elixir. Since I already spent most of my time staring at a computer screen for my day job, this means reading lots of books, listening to podcasts and watching conference talks. It's been very interesting so far, but I hadn't had a chance to get my hands dirty and write some actual Elixir code.

This tutorial is my way of getting better at Phoenix, especially Phoenix Channels and Presence. What I'll be building is a simple, but compelling software feature I've seen in the design tool Figma (and other collaborative tools). In Figma, you can see the mouse cursors of others people working on the document. It's a cool feature and Phoenix was made for this stuff!

What we're building

The feature works like this: when users visit the website, they're automatically added to a channel, get assigned a user name (with a corresponding color) and can send messages to other users using the input field. The result of this tutorial will look like this:

Screenshot of the end result

Getting started

Create a new Phoenix project using the command line. We only need the bare minimum to get started, so we'll not add Ecto, LiveView or Mailer.[^1]

mix phx.new phoenix_cursors --no-ecto --no-dashboard --no-live --no-mailer

I'll be using Tailwind CSS for styling and you can quickly add that to the project using the official installer. You can read about the steps to follow in my earlier tutorial. All the changes I made to get Tailwind to work are also in this commit.

I've also added a nice background and some basic HTML. Replace the contents of the template lib/phoenix_cursors_web/templates/page/index.html.heex with the following:

<section
  class="flex flex-col w-screen h-screen justify-center items-center text-center"
>
  <form
    id="msgform"
    class="rounded-xl bg-gradient-to-r to-pink-100 from-pink-50 p-8 drop-shadow-xl flex w-xs mx-auto space-x-3"
  >
    <input
      class="flex-1 appearance-none border border-transparent py-2 px-4 bg-white text-gray-600 placeholder-gray-400 shadow-md rounded-lg text-base focus:outline-none focus:ring-2 focus:ring-pink-600 focus:border-transparent"
      maxlength="30"
      aria-label="Your message"
      type="text"
      id="msg"
      name="msg"
      placeholder="Say something"
    />
    <input
      id="submit-msg"
      type="submit"
      class="flex-shrink-0 bg-pink-600 text-white text-base font-semibold py-2 px-4 rounded-lg shadow-md hover:bg-pink-700 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 focus:ring-offset-pink-200"
      value="Change"
    />
  </form>
  <ul id="cursor-list" />
</section>

You can see the results so far by running:

mix phx.server

It should look like this:

Screenshot of the end result

Now that we have a working Phoenix project with Tailwind CSS, let's start building the application.

Note: you can also follow along with this tutorial on GitHub. Visit the list of commits here: https://github.com/vnglst/phoenix_cursors/commits/main

Tracking mouse movements

First, we'll add a channel that we can use to send mouse position updates to:

mix phx.gen.channel Cursor

Phoenix added some files to our repo. Make sure to follow any instructions that this command gives you and add the socket handler to your lib/phoenix_cursors_web/endpoint.ex. I added it just above the socket for LiveView (disabled in my case):

  socket "/socket", PhoenixCursorsWeb.UserSocket,
    websocket: true,
    longpoll: false

  # socket "/live", Phoenix.LiveView.Socket, WebSocket: [connect_info: [session: @session_options]]

For the front-end integration, you need to import the user_socket.js in your assets/js/app.js file. Uncomment the following line:

// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
import './user_socket.js';

// You can include dependencies in two ways.
//

To get a feeling for working with this channel (and sending updates to it) we'll start by tracking cursor movements server side. First, we need to start sending events from the client. In the file assets/js/user_socket.js add the following:

let channel = socket.channel('cursor:lobby', {});
channel
  .join()
  .receive('ok', (resp) => {
    console.log('Joined successfully', resp);
    document.addEventListener('mousemove', (e) => {
      const x = e.pageX / window.innerWidth;
      const y = e.pageY / window.innerHeight;
      channel.push('move', { x, y });
    });
  })
  .receive('error', (resp) => {
    console.log('Unable to join', resp);
  });

export default socket;

This listens for changes in the mouse cursor position and sends those as events to the Phoenix channel. Events for mouse coordinate changes are made independent of the viewport (between 0 and 1). This makes sure cursors stay within the viewport of all users (esp. those with smaller screens). To display them we need to scale them to the viewport.

Next step: handle the updates in Phoenix. We'll update the file lib/phoenix_cursors_web/channels/cursor_channel.ex for this:

defmodule PhoenixCursorsWeb.CursorChannel do
  use PhoenixCursorsWeb, :channel

  @impl true
  def handle_in("move", %{"x" => x, "y" => y}, socket) do
    broadcast(socket, "move", %{"x" => x, "y" => y})
    {:noreply, socket}
  end

  @impl true
  def join("cursor:lobby", payload, socket) do
  # ...etc

If you start the Phoenix server and move around with the mouse, you'll see the cursor position updates in the console. Yeah, it works!

Let's also display those updates on the page. For this, we'll need a bit of JavaScript. We'll add a template function to the file user_socket.js:

function cursorTemplate({ x, y, name }) {
  const li = document.createElement('li');
  li.classList =
    'flex flex-col absolute pointer-events-none whitespace-nowrap overflow-hidden text-pink-300';
  li.style.left = x + 'px';
  li.style.top = y + 'px';

  li.innerHTML = `
    <svg
      version="1.1"
      width="25px"
      height="25px"
      xmlns="http://www.w3.org/2000/svg"
      xmlns:xlink="http://www.w3.org/1999/xlink"
      viewBox="0 0 21 21">
        <polygon
          fill="black"
          points="8.2,20.9 8.2,4.9 19.8,16.5 13,16.5 12.6,16.6" />
        <polygon
          fill="currentColor"
          points="9.2,7.3 9.2,18.5 12.2,15.6 12.6,15.5 17.4,15.5"
        />
    </svg>
    <span class="mt-1 ml-4 px-1 text-sm text-pink-300" />
  `;

  li.lastChild.textContent = name;

  return li;
}

And we listen for 'move' events on the channel:

let channel = socket.channel('cursor:lobby', {});
channel
  .join()
  .receive('ok', (resp) => {
    console.log('Joined successfully', resp);

    document.addEventListener('mousemove', (e) => {
      const x = e.pageX / window.innerWidth;
      const y = e.pageY / window.innerHeight;
      channel.push('move', { x, y });
    });

    channel.on('move', ({ x, y }) => {
      const ul = document.createElement('ul');
      const cursorLi = cursorTemplate({
        x: x * window.innerWidth,
        y: y * window.innerHeight,
        name: '???'
      });
      ul.appendChild(cursorLi);
      document.getElementById('cursor-list').innerHTML = ul.innerHTML;
    });
  })
  .receive('error', (resp) => {
    console.log('Unable to join', resp);
  });

If you open up two browser tabs next to each other you can see the cursor moving around. However, the moment you leave the tab the cursor will disappear. To fix this we'll need a way to keep track if users are still present in the channel. For this, we need the Presence feature.

Adding names

However, before we can track who's online, we need to add a way to identify users. Normally you would use some kind of authentication (signup/sign in) and a database for that, but I want to keep this tutorial as simple as possible. So instead I'll assign each user a unique name the moment they join the channel.

To generate unique names we'll add the following function to the project.

# lib/phoenix_cursors/names.ex
defmodule Cursor.Names do
  def generate do
    title = ~w(Sir Sr Prof Saint Ibn Lady Madam Mistress Herr Dr) |> Enum.random()
    name =
      [
        ~w(B C D F G H J K L M N P Q R S T V W X Z),
        ~w(o a i ij e ee u uu oo aj aa oe ou eu),
        ~w(b c d f g h k l m n p q r s t v w x z),
        ~w(o a i ij e ee u uu oo aj aa oe ou eu)
      ]
      |> Enum.map(fn l -> Enum.random(l) end)
      |> Enum.join()

    "#{title} #{name}"
  end
end

We'll generate this name on the server and send a signed token containing the name to the client. We sign it to make sure users can't tamper with it. The idea is that this prevents users from changing their name to someone else's.

In page_controller.ex add the following:

defmodule PhoenixCursorsWeb.PageController do
  use PhoenixCursorsWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html",
      user_token: Phoenix.Token.sign(PhoenixCursorsWeb.Endpoint, "user socket", Cursor.Names.generate())
    )
  end
end

We'll store the userToken in sessionStorage on the client. Add the following line in root.html.heex:

    <!-- ... -->
    <%= live_title_tag assigns[:page_title] || "Cursors", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
    <script>sessionStorage.userToken = sessionStorage.userToken || "<%= assigns[:user_token] %>"</script>
    <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
  </head>
  <!-- ... -->

In user_socket.js we'll update the code that initializes the socket to use the newly generated userToken:

let socket = new Socket('/socket', {
  params: { token: sessionStorage.userToken }
});

When a user connects to the socket we can verify the authenticity by checking the token. Once we've verified that a token is valid, we read the user name from it and assign that name to the socket connection. This is done server side in the file user_socket.ex. Replace the existing connect function with the following:

  def connect(%{"token" => token}, socket, _connect_info) do
    # max_age: 1209600 is equivalent to two weeks in seconds
    case Phoenix.Token.verify(socket, "user socket", token, max_age: 1_209_600) do
      {:ok, user_id} ->
        {:ok, assign(socket, :current_user, user_id)}

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

Now we know who's updating his or her cursor position and we can add this information to the move event we're broadcasting to everyone in the channel. Update in cursor_channel:

  def handle_in("move", %{"x" => x, "y" => y}, socket) do
    name = socket.assigns.current_user
    broadcast(socket, "move", %{"x" => x, "y" => y, "name" => name})
    {:noreply, socket}
  end

And finally in user_socket.js:

  channel.on("move", ({ x, y, name }) => {
    const ul = document.createElement("ul");
    const cursorLi = cursorTemplate({
      x: x * window.innerWidth,
      y: y * window.innerHeight,
      name,
    });

Start your server and visit http://localhost:4000/ in your browser. Your cursor should now have a name attached to it!

Screenshot of names feature

Tracking who's online

Now that we're able to identify users, we're ready to track their online presence. This is a feature that Phoenix offers out of the box! Use the generator to get started:

mix phx.gen.presence

Follow the instructions that are displayed after running this command by adding the new module to your supervision tree in lib/phoenix_cursors/application.ex:

  def start(_type, _args) do
    children = [
      # Start the Telemetry supervisor
      PhoenixCursorsWeb.Telemetry,
      # Start the PubSub system
      {Phoenix.PubSub, name: PhoenixCursors.PubSub},
      # Start the Endpoint (http/https)
      PhoenixCursorsWeb.Endpoint,
      # Start a worker by calling: PhoenixCursors.Worker.start_link(arg)
      # {PhoenixCursors.Worker, arg}
      PhoenixCursorsWeb.Presence
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: PhoenixCursors.Supervisor]
    Supervisor.start_link(children, opts)
  end

In user_socket.js we're going to replace the update logic for the cursors. Instead of rendering user cursors based on the move event, we're going to render a list of users that are currently online/present.

import { Socket, Presence } from 'phoenix';

let socket = new Socket('/socket', {
  params: { token: sessionStorage.userToken }
});

socket.connect();

channel
  .join()
  .receive('ok', (resp) => {
    console.log('Joined successfully', resp);
    document.addEventListener('mousemove', (e) => {
      const x = e.pageX / window.innerWidth;
      const y = e.pageY / window.innerHeight;
      channel.push('move', { x, y });
    });
    // remove the code that updates cursor positions on the move event
  })
  .receive('error', (resp) => {
    console.log('Unable to join', resp);
  });

const presence = new Presence(channel);

presence.onSync(() => {
  const ul = document.createElement('ul');

  presence.list((name, { metas: [firstDevice] }) => {
    const { x, y } = firstDevice;
    const cursorLi = cursorTemplate({
      name,
      x: x * window.innerWidth,
      y: y * window.innerHeight
    });
    ul.appendChild(cursorLi);
  });

  document.getElementById('cursor-list').innerHTML = ul.innerHTML;
});

After users join the channel we set up their presence in the channel. When a user sends a move event, we update the Presence state. Update cursor_channel to the following:

defmodule PhoenixCursorsWeb.CursorChannel do
  alias PhoenixCursorsWeb.Presence
  use PhoenixCursorsWeb, :channel

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

  @impl true
  def handle_info(:after_join, socket) do
    {:ok, _} =
      Presence.track(socket, socket.assigns.current_user, %{
        online_at: inspect(System.system_time(:second))
      })

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

  @impl true
  def handle_in("move", %{"x" => x, "y" => y}, socket) do
    {:ok, _} =
      Presence.update(socket, socket.assigns.current_user, fn previousState ->
        Map.merge(
          previousState,
          %{
            online_at: inspect(System.system_time(:second)),
            x: x,
            y: y
          }
        )
      end)

    {:noreply, socket}
  end

Restart your server and visit http://localhost:4000/ in your browser. Every user should now have a live cursor that follows their mouse around.

Adding colors

To make the cursors more recognizable, we're going to add a unique color to each user. We'll generate a color based on the user name using the following Elixir code in the file lib/cursors/colors.ex:

defmodule PhoenixCursors.Colors do
  def getHSL(s) do
    hue = to_charlist(s) |> Enum.sum() |> rem(360)
    "hsl(#{hue}, 70%, 40%)"
  end
end

We assign this color to the user's presence in cursor_channel.ex:

  @impl true
  def handle_info(:after_join, socket) do
    {:ok, _} =
      Presence.track(socket, socket.assigns.current_user, %{
        online_at: inspect(System.system_time(:second)),
        color: PhoenixCursors.Colors.getHSL(socket.assigns.current_user)
      })

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

  @impl true
  def handle_in("move", %{"x" => x, "y" => y}, socket) do
    {:ok, _} =
      Presence.update(socket, socket.assigns.current_user, fn _previousState ->
        %{
          online_at: inspect(System.system_time(:second)),
          x: x,
          y: y,
          color: PhoenixCursors.Colors.getHSL(socket.assigns.current_user)
        }
      end)

    {:noreply, socket}
  end

And in the front end we use this color for rendering the list of user cursors. In user_socket.js:

presence.list((name, { metas: [firstDevice] }) => {
  const { x, y, color } = firstDevice;
  const cursorLi = cursorTemplate({
    name,
    x: x * window.innerWidth,
    y: y * window.innerHeight,
    color
  });
  ul.appendChild(cursorLi);
});

Update the function cursorTemplate by using the supplied color instead of pink-300:

function cursorTemplate({ x, y, name, color }) {
  const li = document.createElement('li');
  li.classList =
    'flex flex-col absolute pointer-events-none whitespace-nowrap overflow-hidden';
  li.style.left = x + 'px';
  li.style.top = y + 'px';
  li.style.color = color;

  li.innerHTML = `
    <svg
      version="1.1"
      width="25px"
      height="25px"
      xmlns="http://www.w3.org/2000/svg"
      xmlns:xlink="http://www.w3.org/1999/xlink"
      viewBox="0 0 21 21">
        <polygon
          fill="black"
          points="8.2,20.9 8.2,4.9 19.8,16.5 13,16.5 12.6,16.6" />
        <polygon
          fill="currentColor"
          points="9.2,7.3 9.2,18.5 12.2,15.6 12.6,15.5 17.4,15.5"
        />
    </svg>
    <span class="mt-1 ml-4 px-1 text-sm text-white" />
  `;

  li.lastChild.style.backgroundColor = color;
  li.lastChild.textContent = name;

  return li;
}

Restart the server et voilà! You should see a nicely colored cursor for every user present in the channel.

Screenshot of the end result

What's really cool about Phoenix Presence is that it automatically keeps track of who's online. You can verify this by opening a few browser tabs (to simulate multiple users) and then closing any one of them. That particular cursor will be removed from the screen!

Conclusion

This concludes my tutorial on Phoenix Channels and Presence. There's still one last piece of functionality missing: Letting users send messages using the input field displayed in the center. If you followed along in this tutorial you should be able to add this yourself. Give it a try! If you can't figure it out, take a look at my solution in the final commit on my repository. In the diff you'll find all the necessary code changes to get the chat functionality working.

Many thanks to Dmitri Don for giving this tutorial a try and reporting any mistakes and unclarities.

[^1]: I'm aware that the live cursor feature would've been an excellent fit for Phoenix LiveView. However, I first wanted to get a better feeling for the Phoenix primitives Channels and Presence. In the future, I would like to build a similar functionality again with LiveView to see how that would reduce complexity.