Packages

phoenix_kit

1.7.75
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules publishing web editor collaborative.ex
Raw

lib/modules/publishing/web/editor/collaborative.ex

defmodule PhoenixKit.Modules.Publishing.Web.Editor.Collaborative do
@moduledoc """
Collaborative editing functionality for the publishing editor.
Handles presence tracking, lock management, spectator mode,
real-time form sync, and lock expiration.
"""
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Publishing.PresenceHelpers
alias PhoenixKit.Modules.Publishing.PubSub, as: PublishingPubSub
alias PhoenixKit.Modules.Publishing.Web.Editor.Helpers
require Logger
# Lock expires after 30 minutes of inactivity
@lock_timeout_seconds 30 * 60
# Warn 5 minutes before expiration
@lock_warning_seconds 25 * 60
# Check every minute
@lock_check_interval_ms 60_000
# ============================================================================
# Setup Functions
# ============================================================================
@doc """
Sets up collaborative editing for initial load.
old_form_key and old_post_slug should be captured BEFORE the socket is updated.
"""
def setup_collaborative_editing(socket, form_key, opts) do
current_user = socket.assigns[:phoenix_kit_current_user]
if Phoenix.LiveView.connected?(socket) && form_key && current_user do
do_setup_collaborative_editing(socket, form_key, current_user, opts)
else
assign_default_editing_state(socket)
end
end
defp do_setup_collaborative_editing(socket, form_key, current_user, opts) do
old_form_key = Keyword.get(opts, :old_form_key)
old_post_slug = Keyword.get(opts, :old_post_slug)
try do
cleanup_old_presence(old_form_key, form_key, socket, old_post_slug)
track_and_subscribe(form_key, socket, current_user)
subscribe_to_post_translations(socket)
subscribe_to_post_versions(socket)
socket
|> assign_editing_role(form_key)
|> maybe_broadcast_editor_joined()
|> maybe_load_spectator_state(form_key)
|> maybe_start_lock_expiration_timer()
rescue
ArgumentError ->
Logger.warning(
"Publishing Presence not available - collaborative editing disabled. " <>
"Ensure PhoenixKit.Supervisor starts before your Endpoint in application.ex"
)
assign_default_editing_state(socket)
end
end
@doc """
Used when we know the old form_key (e.g., when switching languages/versions).
"""
def cleanup_and_setup_collaborative_editing(socket, old_form_key, new_form_key, opts) do
current_user = socket.assigns[:phoenix_kit_current_user]
old_post_slug = Keyword.get(opts, :old_post_slug)
if Phoenix.LiveView.connected?(socket) && new_form_key && current_user do
try do
# Clean up old presence tracking
if old_form_key && old_form_key != new_form_key do
PresenceHelpers.untrack_editing_session(old_form_key, socket)
PresenceHelpers.unsubscribe_from_editing(old_form_key)
PublishingPubSub.unsubscribe_from_editor_form(old_form_key)
# Unsubscribe from old post's translation and version topics
group_slug = socket.assigns[:group_slug]
if group_slug && old_post_slug do
PublishingPubSub.unsubscribe_from_post_translations(group_slug, old_post_slug)
PublishingPubSub.unsubscribe_from_post_versions(group_slug, old_post_slug)
end
# Broadcast editor left for the old post to update group dashboard
broadcast_editor_left_for_post(socket, old_post_slug)
end
# Track this user in Presence
case PresenceHelpers.track_editing_session(new_form_key, socket, current_user) do
{:ok, _ref} -> :ok
{:error, {:already_tracked, _pid, _topic, _key}} -> :ok
end
# Subscribe to presence changes and form events
PresenceHelpers.subscribe_to_editing(new_form_key)
PublishingPubSub.subscribe_to_editor_form(new_form_key)
# Subscribe to post-level topics for real-time translation/version updates
subscribe_to_post_translations(socket)
subscribe_to_post_versions(socket)
# Determine our role (owner or spectator) and broadcast if owner
socket
|> assign_editing_role(new_form_key)
|> maybe_broadcast_editor_joined()
|> maybe_load_spectator_state(new_form_key)
|> maybe_start_lock_expiration_timer()
rescue
ArgumentError ->
assign_default_editing_state(socket)
end
else
assign_default_editing_state(socket)
end
end
# ============================================================================
# Cleanup Functions
# ============================================================================
defp cleanup_old_presence(old_form_key, form_key, socket, old_post_slug) do
if old_form_key && old_form_key != form_key do
PresenceHelpers.untrack_editing_session(old_form_key, socket)
PresenceHelpers.unsubscribe_from_editing(old_form_key)
PublishingPubSub.unsubscribe_from_editor_form(old_form_key)
# Unsubscribe from old post's translation and version topics using the OLD slug
group_slug = socket.assigns[:group_slug]
if group_slug && old_post_slug do
PublishingPubSub.unsubscribe_from_post_translations(group_slug, old_post_slug)
PublishingPubSub.unsubscribe_from_post_versions(group_slug, old_post_slug)
end
# Broadcast editor left to group listing for the OLD post
broadcast_editor_left_for_post(socket, old_post_slug)
end
end
@doc """
Broadcast editor left for a specific post slug (used when we've already switched posts).
"""
def broadcast_editor_left_for_post(socket, post_slug) do
group_slug = socket.assigns[:group_slug]
if group_slug && post_slug do
user_info = build_user_info(socket, nil)
PublishingPubSub.broadcast_editor_left(group_slug, post_slug, user_info)
end
end
@doc """
Unsubscribe from current post's topics (used in terminate when LiveView closes).
"""
def unsubscribe_from_old_post_topics(socket) do
group_slug = socket.assigns[:group_slug]
post_slug = socket.assigns[:post] && socket.assigns.post[:slug]
if group_slug && post_slug do
PublishingPubSub.unsubscribe_from_post_translations(group_slug, post_slug)
PublishingPubSub.unsubscribe_from_post_versions(group_slug, post_slug)
end
end
# ============================================================================
# Tracking and Subscription
# ============================================================================
defp track_and_subscribe(form_key, socket, current_user) do
case PresenceHelpers.track_editing_session(form_key, socket, current_user) do
{:ok, _ref} -> :ok
{:error, {:already_tracked, _pid, _topic, _key}} -> :ok
end
PresenceHelpers.subscribe_to_editing(form_key)
PublishingPubSub.subscribe_to_editor_form(form_key)
end
defp subscribe_to_post_translations(socket) do
case socket.assigns[:post] && socket.assigns.post[:slug] do
post_slug when is_binary(post_slug) ->
PublishingPubSub.subscribe_to_post_translations(socket.assigns.group_slug, post_slug)
_ ->
:ok
end
end
defp subscribe_to_post_versions(socket) do
case socket.assigns[:post] && socket.assigns.post[:slug] do
post_slug when is_binary(post_slug) ->
PublishingPubSub.subscribe_to_post_versions(socket.assigns.group_slug, post_slug)
_ ->
:ok
end
end
# ============================================================================
# Role Management
# ============================================================================
@doc """
Assigns the editing role (owner or spectator) based on presence.
"""
def assign_editing_role(socket, form_key) do
current_user = socket.assigns[:phoenix_kit_current_user]
case PresenceHelpers.get_editing_role(form_key, socket.id, current_user.uuid) do
{:owner, _presences} ->
# I'm the owner - I can edit
socket
|> Phoenix.Component.assign(:lock_owner?, true)
|> Phoenix.Component.assign(:readonly?, false)
|> populate_presence_info(form_key)
{:spectator, _owner_meta, _presences} ->
# Different user is the owner - I'm read-only
socket
|> Phoenix.Component.assign(:lock_owner?, false)
|> Phoenix.Component.assign(:readonly?, true)
|> populate_presence_info(form_key)
end
end
@doc """
Assigns default editing state when collaborative editing is not available.
"""
def assign_default_editing_state(socket) do
socket
|> Phoenix.Component.assign(:lock_owner?, true)
|> Phoenix.Component.assign(:readonly?, false)
|> Phoenix.Component.assign(:lock_owner_user, nil)
|> Phoenix.Component.assign(:spectators, [])
|> Phoenix.Component.assign(:other_viewers, [])
end
defp populate_presence_info(socket, form_key) do
presences = PresenceHelpers.get_sorted_presences(form_key)
my_user_uuid =
socket.assigns[:phoenix_kit_current_user] && socket.assigns.phoenix_kit_current_user.uuid
{lock_owner_user, spectators, other_viewers} =
case presences do
[] ->
{nil, [], []}
[{_owner_socket_id, owner_meta} | spectator_list] ->
spectators =
Enum.map(spectator_list, fn {_socket_id, meta} ->
%{
user: meta.user,
user_uuid: meta.user_uuid,
user_email: meta.user_email
}
end)
# Other viewers = all presences from OTHER users (not just other sockets)
other_viewers =
presences
|> Enum.reject(fn {_socket_id, meta} -> meta.user_uuid == my_user_uuid end)
|> Enum.map(fn {_socket_id, meta} ->
%{
user: meta.user,
user_uuid: meta.user_uuid,
user_email: meta.user_email
}
end)
|> Enum.uniq_by(& &1.user_uuid)
{owner_meta.user, spectators, other_viewers}
end
socket
|> Phoenix.Component.assign(:lock_owner_user, lock_owner_user)
|> Phoenix.Component.assign(:spectators, spectators)
|> Phoenix.Component.assign(:other_viewers, other_viewers)
end
defp maybe_load_spectator_state(socket, form_key) do
if socket.assigns.readonly?, do: load_spectator_state(socket, form_key), else: socket
end
defp load_spectator_state(socket, form_key) do
# Request current state from the owner so we see their unsaved changes.
# The owner handles :editor_sync_request and responds with form + content.
PublishingPubSub.broadcast_editor_sync_request(form_key, socket.id)
socket
end
# ============================================================================
# Broadcasting
# ============================================================================
@doc """
Only broadcast editor_joined if user is the owner (not a spectator).
"""
def maybe_broadcast_editor_joined(socket) do
if socket.assigns[:lock_owner?] do
broadcast_editor_activity(socket, :joined)
end
socket
end
@doc """
Broadcast editor activity to group listing (for showing who's editing).
"""
def broadcast_editor_activity(socket, action, user \\ nil) do
group_slug = socket.assigns[:group_slug]
post = socket.assigns[:post]
broadcast_id = post && (post[:slug] || post[:uuid])
if group_slug && broadcast_id do
user_info = build_user_info(socket, user)
case action do
:joined ->
PublishingPubSub.broadcast_editor_joined(group_slug, broadcast_id, user_info)
:left ->
PublishingPubSub.broadcast_editor_left(group_slug, broadcast_id, user_info)
end
end
end
@doc """
Broadcast form changes to spectators (only owners broadcast).
"""
def broadcast_form_change(socket, type, payload) do
form_key = socket.assigns[:form_key]
is_owner = socket.assigns[:lock_owner?]
if form_key && is_owner do
PublishingPubSub.broadcast_editor_form_change(
form_key,
%{type: type, data: payload},
source: socket.id
)
end
end
@doc """
Build user info for broadcasts.
"""
def build_user_info(socket, user) do
user = user || socket.assigns[:phoenix_kit_current_user]
# Include role to distinguish editors from spectators
role = if socket.assigns[:lock_owner?], do: :owner, else: :spectator
if user do
%{
id: user.uuid,
email: user.email,
socket_id: socket.id,
role: role
}
else
%{socket_id: socket.id, role: role}
end
end
# ============================================================================
# Form Sync
# ============================================================================
@doc """
Apply remote form state (for spectators receiving initial sync).
"""
def apply_remote_form_state(socket, form_state) do
form = Map.get(form_state, :form) || Map.get(form_state, "form") || socket.assigns.form
content =
Map.get(form_state, :content) || Map.get(form_state, "content") || socket.assigns.content
socket
|> Phoenix.Component.assign(:form, form)
|> Phoenix.Component.assign(:content, content)
|> Phoenix.Component.assign(:has_pending_changes, true)
|> Phoenix.LiveView.push_event("set-content", %{content: content})
|> Phoenix.LiveView.push_event("form-updated", %{form: form})
end
@doc """
Apply remote form changes (for spectators receiving updates).
"""
def apply_remote_form_change(socket, %{type: :meta, data: new_form}) do
# Update language_statuses for current language when status changes
# This ensures the language switcher reflects the editor's changes
language = Helpers.editor_language(socket.assigns)
new_status = new_form["status"]
current_language_statuses = Map.get(socket.assigns.post, :language_statuses, %{})
updated_language_statuses = Map.put(current_language_statuses, language, new_status)
updated_post =
socket.assigns.post
|> Map.put(:metadata, Map.merge(socket.assigns.post.metadata, %{status: new_status}))
|> Map.put(:language_statuses, updated_language_statuses)
socket
|> Phoenix.Component.assign(:form, new_form)
|> Phoenix.Component.assign(:post, updated_post)
|> Phoenix.LiveView.push_event("form-updated", %{form: new_form})
end
def apply_remote_form_change(socket, %{type: :content, data: %{content: content, form: form}}) do
socket
|> Phoenix.Component.assign(:content, content)
|> Phoenix.Component.assign(:form, form)
|> Phoenix.LiveView.push_event("set-content", %{content: content})
|> Phoenix.LiveView.push_event("form-updated", %{form: form})
end
def apply_remote_form_change(socket, _payload) do
# Ignore unrecognized payload types
socket
end
# ============================================================================
# Lock Expiration
# ============================================================================
@doc """
Update activity timestamp on user interactions.
"""
def touch_activity(socket) do
socket
|> Phoenix.Component.assign(:last_activity_at, System.monotonic_time(:second))
|> Phoenix.Component.assign(:lock_warning_shown, false)
end
@doc """
Conditionally start lock expiration timer after role assignment.
"""
def maybe_start_lock_expiration_timer(socket) do
if socket.assigns[:lock_owner?] && !socket.assigns[:readonly?] do
socket
|> touch_activity()
|> start_lock_expiration_timer()
else
socket
end
end
@doc """
Start lock expiration timer (only for owners).
"""
def start_lock_expiration_timer(socket) do
if socket.assigns[:lock_owner?] do
cancel_lock_expiration_timer(socket)
timer_ref = Process.send_after(self(), :check_lock_expiration, @lock_check_interval_ms)
Phoenix.Component.assign(socket, :lock_expiration_timer, timer_ref)
else
socket
end
end
@doc """
Cancel lock expiration timer.
"""
def cancel_lock_expiration_timer(socket) do
if socket.assigns[:lock_expiration_timer] do
Process.cancel_timer(socket.assigns.lock_expiration_timer)
end
Phoenix.Component.assign(socket, :lock_expiration_timer, nil)
end
@doc """
Check if lock should expire or warn user.
"""
def check_lock_expiration(socket) do
if socket.assigns[:lock_owner?] do
now = System.monotonic_time(:second)
last_activity = socket.assigns[:last_activity_at] || now
inactive_seconds = now - last_activity
cond do
# Lock expired - release it
inactive_seconds >= @lock_timeout_seconds ->
release_lock_due_to_inactivity(socket)
# Approaching expiration - warn user
inactive_seconds >= @lock_warning_seconds && !socket.assigns[:lock_warning_shown] ->
minutes_left = div(@lock_timeout_seconds - inactive_seconds, 60)
socket
|> Phoenix.Component.assign(:lock_warning_shown, true)
|> Phoenix.LiveView.put_flash(
:warning,
gettext("Your editing lock will expire in %{minutes} minutes due to inactivity",
minutes: minutes_left
)
)
|> start_lock_expiration_timer()
# Still active - schedule next check
true ->
start_lock_expiration_timer(socket)
end
else
socket
end
end
@doc """
Release lock due to inactivity.
"""
def release_lock_due_to_inactivity(socket) do
form_key = socket.assigns[:form_key]
if form_key do
# Untrack from presence to release lock
PresenceHelpers.untrack_editing_session(form_key, socket)
# Broadcast editor left
broadcast_editor_activity(socket, :left)
# Keep subscribed but become a spectator
socket
|> Phoenix.Component.assign(:lock_owner?, false)
|> Phoenix.Component.assign(:readonly?, true)
|> Phoenix.Component.assign(:lock_warning_shown, false)
|> cancel_lock_expiration_timer()
|> Phoenix.Component.assign(:lock_released_by_timeout, true)
|> Phoenix.LiveView.put_flash(
:warning,
gettext("Your editing lock was released due to inactivity. Click anywhere to reclaim it.")
)
else
socket
end
end
@doc """
Attempts to reclaim the editing lock after it was released due to inactivity.
Called on user interaction when `lock_released_by_timeout` is true.
Re-tracks presence, reclaims ownership if no other user holds the lock,
and restarts the expiration timer.
"""
def try_reclaim_lock(socket) do
form_key = socket.assigns[:form_key]
current_user = socket.assigns[:phoenix_kit_current_user]
if form_key && current_user && socket.assigns[:lock_released_by_timeout] do
# Re-track in presence
case PresenceHelpers.track_editing_session(form_key, socket, current_user) do
{:ok, _ref} -> :ok
{:error, {:already_tracked, _pid, _topic, _key}} -> :ok
end
# Re-evaluate role (will be owner if no one else took the lock)
socket
|> assign_editing_role(form_key)
|> Phoenix.Component.assign(:lock_released_by_timeout, false)
|> then(fn s ->
if s.assigns[:lock_owner?] do
s
|> maybe_broadcast_editor_joined()
|> maybe_start_lock_expiration_timer()
|> Phoenix.LiveView.clear_flash()
else
# Someone else took the lock while we were idle
s
end
end)
else
socket
end
end
end