Grove.Presence (Grove v0.1.1)
View SourcePresence tracking for collaborative editing.
Tracks per-replica state: cursor position, selection, focus, and user metadata. Built on Phoenix.Presence with automatic conflict resolution.
Requirements
Configure a PubSub module in your config:
config :grove, pubsub: MyApp.PubSubAnd add Grove.Presence to your supervision tree after your PubSub:
children = [
{Phoenix.PubSub, name: MyApp.PubSub},
{Grove.Presence, pubsub: MyApp.PubSub},
# ...
]Usage
Track presence in a LiveView using the on_mount hook:
defmodule MyAppWeb.DocumentLive do
use MyAppWeb, :live_view
on_mount {Grove.LiveView.Presence, :track_presence}
def mount(%{"id" => doc_id}, session, socket) do
socket = Grove.LiveView.mount_tree(socket, doc_id,
replica_id: session["user_id"] || socket.id
)
{:ok, socket}
end
endOr track manually:
Grove.Presence.track_replica(socket, doc_id, replica_id, %{
user_name: "Alice",
cursor: %{node_id: "field_1", offset: 10}
})Automatic Color Assignment
Each replica is assigned a deterministic color based on its ID:
color = Grove.Presence.assign_color(replica_id)
# => "#4CAF50"
Summary
Functions
Generates a deterministic color from a replica ID.
Gets presence for a specific replica.
Lists all presences for a document.
Starts the Presence tracker.
Subscribes the current process to presence updates for a document.
Synchronizes local presence state with a diff received from PubSub.
Synchronizes local presence state with a full state message.
Returns the PubSub topic for presence tracking on a document.
Tracks a replica's presence in a document.
Unsubscribes the current process from presence updates.
Updates a replica's presence metadata.
Functions
Generates a deterministic color from a replica ID.
The same replica ID always produces the same color, providing consistent visual identification across sessions.
Example
iex> Grove.Presence.assign_color("user_123")
"#4CAF50"
iex> Grove.Presence.assign_color("user_456")
"#2196F3"
Gets presence for a specific replica.
Returns %{metas: [meta]} or an empty map if not found.
Example
case Grove.Presence.get_replica("doc_123", "replica_1") do
%{metas: [meta | _]} -> meta.cursor
_ -> nil
end
Lists all presences for a document.
Returns a map of %{replica_id => %{metas: [meta]}}.
Example
presences = Grove.Presence.list_replicas("doc_123")
# %{
# "replica_1" => %{metas: [%{cursor: %{node_id: "f1", offset: 10}, ...}]},
# "replica_2" => %{metas: [%{cursor: nil, ...}]}
# }
Starts the Presence tracker.
Options
:pubsub- The PubSub server to use. Defaults to configured value.:name- The name to register the presence server. Defaults toGrove.Presence.
Example
children = [
{Phoenix.PubSub, name: MyApp.PubSub},
{Grove.Presence, pubsub: MyApp.PubSub}
]
Subscribes the current process to presence updates for a document.
After subscribing, the process will receive presence messages:
def handle_info({Grove.Presence, {:join, key, meta}}, socket) do
# Handle user join
{:noreply, socket}
end
def handle_info({Grove.Presence, {:leave, key, meta}}, socket) do
# Handle user leave
{:noreply, socket}
end
Synchronizes local presence state with a diff received from PubSub.
Call this in your handle_info when receiving presence_diff events.
Example
def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff", payload: diff}, socket) do
presences = Grove.Presence.sync_diff(socket.assigns.presences, diff)
{:noreply, assign(socket, :presences, presences)}
end
Synchronizes local presence state with a full state message.
Useful for initial presence state synchronization.
Returns the PubSub topic for presence tracking on a document.
Uses a separate topic namespace from document sync for clean separation.
Example
iex> Grove.Presence.topic("doc_123")
"grove:presence:doc_123"
Tracks a replica's presence in a document.
Parameters
pid- The process to trackdoc_id- Document identifierreplica_id- Unique replica identifier (e.g., socket.id)meta- Metadata map (cursor, selection, user info, etc.)
Example
Grove.Presence.track_replica(self(), "doc_123", "replica_1", %{
user_name: "Alice",
color: Grove.Presence.assign_color("replica_1"),
cursor: nil,
selection: nil,
focus: nil
})
@spec unsubscribe(String.t()) :: :ok
Unsubscribes the current process from presence updates.
@spec update_replica(pid(), String.t(), term(), (map() -> map()) | map()) :: {:ok, binary()} | {:error, term()}
Updates a replica's presence metadata.
Accepts either a function that transforms the metadata or a map to merge.
Examples
# With update function
Grove.Presence.update_replica(self(), "doc_123", "replica_1", fn meta ->
Map.put(meta, :cursor, %{node_id: "field_1", offset: 42})
end)
# With map (merges with existing metadata)
Grove.Presence.update_replica(self(), "doc_123", "replica_1", %{
cursor: %{node_id: "field_1", offset: 42}
})