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
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.
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
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 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.