Grove.Presence (Grove v0.1.1)

View Source

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

And 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
end

Or 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

assign_color(replica_id)

@spec assign_color(term()) :: String.t()

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"

get_replica(doc_id, replica_id)

@spec get_replica(String.t(), term()) :: map()

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

list_replicas(doc_id)

@spec list_replicas(String.t()) :: map()

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, ...}]}
# }

start_link(opts \\ [])

Starts the Presence tracker.

Options

  • :pubsub - The PubSub server to use. Defaults to configured value.
  • :name - The name to register the presence server. Defaults to Grove.Presence.

Example

children = [
  {Phoenix.PubSub, name: MyApp.PubSub},
  {Grove.Presence, pubsub: MyApp.PubSub}
]

subscribe(doc_id)

@spec subscribe(String.t()) :: :ok | {:error, term()}

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

sync_diff(presences, diff)

@spec sync_diff(map(), map()) :: map()

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

sync_state(presences, state)

@spec sync_state(map(), map()) :: map()

Synchronizes local presence state with a full state message.

Useful for initial presence state synchronization.

topic(doc_id)

@spec topic(String.t()) :: String.t()

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"

track_replica(pid, doc_id, replica_id, meta)

@spec track_replica(pid(), String.t(), term(), map()) ::
  {:ok, binary()} | {:error, term()}

Tracks a replica's presence in a document.

Parameters

  • pid - The process to track
  • doc_id - Document identifier
  • replica_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
})

unsubscribe(doc_id)

@spec unsubscribe(String.t()) :: :ok

Unsubscribes the current process from presence updates.

update_replica(pid, doc_id, replica_id, update_fn)

@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}
})