Presence room

Open this page in multiple tabs to see visitors join, leave, and send collaborative room pings through Phoenix Presence and Dstar patches.

Demo

Lobby

Who is here right now?

Keep this tab open, then open another. Each tab becomes a distinct presence and receives live roster patches.

Online now

0

active browser tabs

Connecting to the room stream…

How it works

  • Phoenix Presence tracks each browser tab as a visitor.
  • handle_connect/2 and handle_info/2 drive the room stream.
  • Roster, online count, and activity are patched independently.
  • Renaming yourself repaints every open tab's roster.
  • The ping button broadcasts a tiny collaborative interaction.

handle_connect/2 subscribes to the room topic and tracks this tab with Phoenix.Presence, then patches the roster. handle_info/2 reacts to presence_diff broadcasts, and handle_disconnect/1 untracks on every exit from the receive loop — bookkeeping that a per-connection process would do for you.

lib/dstar_demo_web/pages/presence_page.ex
def handle_connect(conn, _params) do
  # ...

  {:ok, _} =
    Presence.track(self(), @topic, tab_id, %{
      name: name,
      color: color,
      status: "active",
      presence_instance: presence_instance,
      last_seen: System.system_time(:second)
    })

  presence_stream_topic = presence_stream_topic(registry_tab, presence_instance)
  Phoenix.PubSub.subscribe(DstarDemo.PubSub, @topic)
  subscribe_presence_stream(presence_stream_topic)

  # ...
end

# ...

# Sent by the pagehide beacon in app.js. The response body is never read
# by the browser, so the already-started SSE stream simply closes empty.
def handle_event(conn, "leave", signals) do
  # Same validation the stream registered under, so the lookup can't miss.
  registry_tab = Dstar.Utility.StreamRegistry.tab_id(conn)
  presence_instance = sanitize_token(signals["presenceInstance"])

  if registry_tab && presence_instance do
    notify_presence_stream(
      registry_tab,
      presence_instance,
      {:presence_leave, registry_tab, presence_instance}
    )
  end

  conn
end

# ...

def handle_info({:presence_leave, tab, presence_instance}, conn) do
  if tab != nil and tab == conn.assigns[:registry_tab] and
       presence_instance == conn.assigns.presence_instance do
    {:halt, conn}
  else
    conn
  end
end

# ...

# Runs on every exit from the library's receive loop. Dstar releases its
# stream claim; this page releases the Presence and PubSub state it acquired.
@impl Dstar.Page
def handle_disconnect(conn) do
  if tab_id = conn.assigns[:tab_id] do
    Presence.untrack(self(), @topic, tab_id)
  end

  unsubscribe_presence_stream(conn.assigns[:presence_stream_topic])
  Phoenix.PubSub.unsubscribe(DstarDemo.PubSub, @topic)
end

If this were LiveView

Both sides track each tab with Phoenix.Presence, react to presence_diff, and broadcast a room ping. They differ in what has to happen when a visitor leaves.

Dstar

lib/dstar_demo_web/pages/presence_page.ex
def handle_connect(conn, _params) do
  # ...

  {:ok, _} =
    Presence.track(self(), @topic, tab_id, %{
      name: name,
      color: color,
      status: "active",
      presence_instance: presence_instance,
      last_seen: System.system_time(:second)
    })

  presence_stream_topic = presence_stream_topic(registry_tab, presence_instance)
  Phoenix.PubSub.subscribe(DstarDemo.PubSub, @topic)
  subscribe_presence_stream(presence_stream_topic)

  # ...
end

# ...

# Sent by the pagehide beacon in app.js. The response body is never read
# by the browser, so the already-started SSE stream simply closes empty.
def handle_event(conn, "leave", signals) do
  # Same validation the stream registered under, so the lookup can't miss.
  registry_tab = Dstar.Utility.StreamRegistry.tab_id(conn)
  presence_instance = sanitize_token(signals["presenceInstance"])

  if registry_tab && presence_instance do
    notify_presence_stream(
      registry_tab,
      presence_instance,
      {:presence_leave, registry_tab, presence_instance}
    )
  end

  conn
end

# ...

def handle_info({:presence_leave, tab, presence_instance}, conn) do
  if tab != nil and tab == conn.assigns[:registry_tab] and
       presence_instance == conn.assigns.presence_instance do
    {:halt, conn}
  else
    conn
  end
end

# ...

# Runs on every exit from the library's receive loop. Dstar releases its
# stream claim; this page releases the Presence and PubSub state it acquired.
@impl Dstar.Page
def handle_disconnect(conn) do
  if tab_id = conn.assigns[:tab_id] do
    Presence.untrack(self(), @topic, tab_id)
  end

  unsubscribe_presence_stream(conn.assigns[:presence_stream_topic])
  Phoenix.PubSub.unsubscribe(DstarDemo.PubSub, @topic)
end

LiveView

illustrative
# Illustrative LiveView equivalent — not compiled or run.
def mount(_params, _session, socket) do
  # ...

  if connected?(socket) do
    Phoenix.PubSub.subscribe(DstarDemo.PubSub, @topic)

    # Same meta, minus presence_instance — and socket.id instead of a
    # client-sent tab_id, because the process itself is the identity.
    {:ok, _} =
      Presence.track(self(), @topic, socket.id, %{
        name: name,
        color: color,
        status: "active",
        last_seen: System.system_time(:second)
      })
  end

  # ...
end

# ... presence_diff, ping, and rename handlers elided — they mirror
# the Dstar side.

# Leaving takes zero parts. No untrack is written anywhere: Presence
# is bound to self(), and the LiveView process dying on disconnect
# removes the entry — including when it dies by crashing.