Current section
Files
Jump to
Current section
Files
lib/phoenix_kit_publishing/presence_helpers.ex
defmodule PhoenixKit.Modules.Publishing.PresenceHelpers do
@moduledoc """
Helper functions for collaborative post editing with Phoenix.Presence.
Provides utilities for tracking editing sessions, determining owner/spectator roles,
and syncing state between users.
"""
alias PhoenixKit.Modules.Publishing.Presence
# Presence topic prefix — kept in one place so an audit can grep
# `@editing_topic_prefix` to find every editor-form topic site.
@editing_topic_prefix "publishing_edit"
@doc """
Tracks the current LiveView process in a Presence topic.
## Parameters
- `form_key`: The unique key for the post being edited
- `socket`: The LiveView socket
- `user`: The current user struct
## Examples
track_editing_session("blog:my-post:en", socket, user)
# => {:ok, ref}
"""
def track_editing_session(form_key, socket, user) do
topic = editing_topic(form_key)
Presence.track(self(), topic, socket.id, %{
user_uuid: user.uuid,
user_email: user.email,
user: user,
joined_at: System.system_time(:millisecond),
phx_ref: socket.id,
pid: self(),
transport_pid: socket.transport_pid
})
end
@doc """
Untracks the current LiveView process from a Presence topic.
Call this when switching languages or versions to release the lock
on the previous form before tracking the new one.
## Parameters
- `form_key`: The unique key for the post that was being edited
- `socket`: The LiveView socket
## Examples
untrack_editing_session("blog:my-post:en", socket)
# => :ok
"""
def untrack_editing_session(form_key, socket) do
topic = editing_topic(form_key)
Presence.untrack(self(), topic, socket.id)
end
@doc """
Unsubscribes from presence events and editor form events for a form.
Call this when switching languages or versions to clean up subscriptions.
"""
def unsubscribe_from_editing(form_key) do
topic = editing_topic(form_key)
Phoenix.PubSub.unsubscribe(:phoenix_kit_internal_pubsub, topic)
end
@doc """
Determines if the current socket is the owner (first in the presence list).
Returns `{:owner, presences}` if this socket is the owner (or same user in different tab), or
`{:spectator, owner_meta, presences}` if a different user is the owner.
## Examples
case get_editing_role("blog:my-post", socket.id, current_user.uuid) do
{:owner, all_presences} ->
# I can edit!
{:spectator, owner_metadata, all_presences} ->
# I'm read-only, sync with owner's state
end
"""
def get_editing_role(form_key, socket_id, current_user_uuid) do
presences = get_sorted_presences(form_key)
case presences do
[] ->
# No one here (shouldn't happen since caller is here)
# But treat as owner to avoid blocking
{:owner, []}
[{^socket_id, _meta} | _rest] ->
# I'm first! I'm the owner
{:owner, presences}
[{_other_socket_id, owner_meta} | _rest] ->
# Check if same user (different tab) or different user
if owner_meta.user_uuid == current_user_uuid do
# Same user, different tab - treat as owner so both tabs can edit
{:owner, presences}
else
# Different user - spectator mode (FIFO locking)
{:spectator, owner_meta, presences}
end
end
end
@doc """
Gets all presences for a form, sorted by join time (FIFO).
Returns a list of tuples: `[{socket_id, metadata}, ...]`
"""
def get_sorted_presences(form_key) do
topic = editing_topic(form_key)
raw_presences = Presence.list(topic)
raw_presences
|> Enum.flat_map(fn {socket_id, %{metas: metas}} ->
valid_metas = Enum.filter(metas, &meta_alive?/1)
# Take the first valid meta (most recent)
case valid_metas do
[meta | _] -> [{socket_id, meta}]
[] -> []
end
end)
|> Enum.sort_by(fn {_socket_id, meta} -> meta.joined_at end)
end
# Process.alive?/1 raises ArgumentError on a REMOTE pid, and clustered presence
# metas can carry pids from other nodes — so only check locally. A remote pid is
# assumed alive (Presence's own node-down tracking cleans up dead nodes); this
# avoids crashing the presence-diff handler on a 2+ node cluster (M14).
defp meta_alive?(%{pid: pid}) when is_pid(pid) do
node(pid) != node() or Process.alive?(pid)
end
defp meta_alive?(_), do: true
@doc """
Gets the lock owner's metadata, or nil if no one is editing.
"""
def get_lock_owner(form_key) do
case get_sorted_presences(form_key) do
[{_socket_id, meta} | _] -> meta
[] -> nil
end
end
@doc """
Gets all spectators (everyone except the first person).
Returns a list of metadata for spectators only.
"""
def get_spectators(form_key) do
case get_sorted_presences(form_key) do
[] -> []
[_owner | spectators] -> Enum.map(spectators, fn {_id, meta} -> meta end)
end
end
@doc """
Counts total number of people editing (owner + spectators).
"""
def count_editors(form_key) do
get_sorted_presences(form_key) |> length()
end
@doc """
Subscribes the current process to presence events for a form.
After subscribing, the process will receive:
- `%Phoenix.Socket.Broadcast{event: "presence_diff", ...}` when users join/leave
"""
def subscribe_to_editing(form_key) do
topic = editing_topic(form_key)
Phoenix.PubSub.subscribe(:phoenix_kit_internal_pubsub, topic)
end
@doc """
Generates the Presence topic name for a form.
## Examples
editing_topic("docs:my-post:en")
# => "publishing_edit:docs:my-post:en"
"""
def editing_topic(form_key), do: "#{@editing_topic_prefix}:#{form_key}"
end