defmodule PhoenixKit.Modules.Sync.Web.Sender do
@moduledoc """
Sender-side LiveView for DB Sync.
This is the site that has data to share with another site.
## Flow
1. Generate a connection code
2. Share the code with the receiver (along with this site's URL)
3. Wait for receiver to connect
4. Serve data requests from the receiver
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
alias PhoenixKit.Modules.Sync
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
require Logger
@impl true
def mount(params, _session, socket) do
locale = params["locale"] || "en"
project_title = Settings.get_project_title()
site_url = Settings.get_setting("site_url", "")
socket =
socket
|> assign(:page_title, "Send Data")
|> assign(:project_title, project_title)
|> assign(:current_locale, locale)
|> assign(:current_path, Routes.path("/admin/sync/send", locale: locale))
|> assign(:site_url, site_url)
|> assign(:step, :generate_code)
|> assign(:session, nil)
# Multiple receivers support - map of pid => receiver_data
|> assign(:receivers, %{})
|> assign(:connection_status, nil)
{:ok, socket}
end
@impl true
def handle_params(_params, _url, socket) do
{:noreply, socket}
end
@impl true
def terminate(_reason, socket) do
# Clean up session on unmount
if socket.assigns.session do
Sync.delete_session(socket.assigns.session.code)
end
:ok
end
# ===========================================
# EVENT HANDLERS
# ===========================================
@impl true
def handle_event("generate_code", _params, socket) do
case Sync.create_session(:send) do
{:ok, session} ->
socket =
socket
|> assign(:session, session)
|> assign(:step, :waiting_for_receiver)
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to generate code: #{inspect(reason)}")}
end
end
@impl true
def handle_event("regenerate_code", _params, socket) do
if socket.assigns.session do
Sync.delete_session(socket.assigns.session.code)
end
case Sync.create_session(:send) do
{:ok, session} ->
socket =
socket
|> assign(:session, session)
|> assign(:receivers, %{})
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to generate code: #{inspect(reason)}")}
end
end
@impl true
def handle_event("cancel", _params, socket) do
if socket.assigns.session do
Sync.delete_session(socket.assigns.session.code)
end
socket =
socket
|> assign(:session, nil)
|> assign(:step, :generate_code)
|> assign(:receivers, %{})
{:noreply, socket}
end
@impl true
def handle_event("disconnect", _params, socket) do
if socket.assigns.session do
Sync.delete_session(socket.assigns.session.code)
end
socket =
socket
|> assign(:session, nil)
|> assign(:step, :generate_code)
|> assign(:receivers, %{})
|> assign(:connection_status, nil)
{:noreply, socket}
end
@impl true
def handle_event("disconnect_receiver", %{"pid" => pid_string}, socket) do
# Find and remove the receiver by PID string
pid = string_to_pid(pid_string)
if pid && Map.has_key?(socket.assigns.receivers, pid) do
# The channel will terminate when process exits, but we can also
# remove it from our tracking immediately
receivers = Map.delete(socket.assigns.receivers, pid)
socket =
socket
|> assign(:receivers, receivers)
|> maybe_update_step_for_receivers()
{:noreply, put_flash(socket, :info, "Receiver disconnected")}
else
{:noreply, socket}
end
end
# ===========================================
# MESSAGE HANDLERS (from Channel)
# ===========================================
@impl true
def handle_info({:sync, {:receiver_joined, channel_pid, full_info}}, socket) do
Logger.info("Sync.Sender: Receiver connected - #{inspect(full_info)}")
# Extract receiver_info and connection_info from the full_info map
# Handle both new format (with :receiver_info/:connection_info keys) and old format
{receiver_info, connection_info} =
case full_info do
%{receiver_info: ri, connection_info: ci} -> {ri, ci}
info when is_map(info) -> {info, %{}}
end
# Add this receiver to our map
receiver_data = %{
receiver_info: receiver_info,
connection_info: connection_info,
connected_at: UtilsDate.utc_now()
}
receivers = Map.put(socket.assigns.receivers, channel_pid, receiver_data)
socket =
socket
|> assign(:receivers, receivers)
|> assign(:step, :connected)
|> assign(:connection_status, nil)
{:noreply, socket}
end
# Handle old message format (backwards compatibility)
@impl true
def handle_info({:sync, {:receiver_joined, channel_pid}}, socket) do
Logger.info("Sync.Sender: Receiver connected (no info)")
receiver_data = %{
receiver_info: %{},
connection_info: %{},
connected_at: UtilsDate.utc_now()
}
receivers = Map.put(socket.assigns.receivers, channel_pid, receiver_data)
socket =
socket
|> assign(:receivers, receivers)
|> assign(:step, :connected)
|> assign(:connection_status, nil)
{:noreply, socket}
end
@impl true
def handle_info({:sync, {:receiver_disconnected, channel_pid}}, socket) do
Logger.info("Sync.Sender: Receiver disconnected - #{inspect(channel_pid)}")
receivers = Map.delete(socket.assigns.receivers, channel_pid)
socket =
socket
|> assign(:receivers, receivers)
|> maybe_update_step_for_receivers()
|> put_flash(:info, "A receiver disconnected")
{:noreply, socket}
end
# Handle old message format (backwards compatibility) - removes all receivers
@impl true
def handle_info({:sync, :receiver_disconnected}, socket) do
Logger.info("Sync.Sender: Receiver disconnected (old format)")
# For old format, we don't know which receiver, so just flash a message
# The channel terminate will send the new format with PID
socket = put_flash(socket, :info, "A receiver disconnected")
{:noreply, socket}
end
@impl true
def handle_info({:sync, msg}, socket) do
Logger.debug("Sync.Sender: Received message - #{inspect(msg)}")
{:noreply, socket}
end
@impl true
def handle_info(_msg, socket) do
{:noreply, socket}
end
# ===========================================
# RENDER
# ===========================================
@impl true
def render(assigns) do
~H"""
Share your data with another site
Send Data
Click the button below to generate a connection code. Share this code and your site URL with the site that wants to receive your data.
Connection Code
{@session.code}
Your Site URL
{@site_url}
Share with the receiver:
The receiver will enter these on their "Receive Data" page to connect.
<%= if @receiver_count == 1 do %> A receiver is connected and can browse your data. <% else %> Multiple receivers are connected and can browse your data. <% end %>
Session Code
{@session.code}
Keep this page open!
Your data is being served to receivers while this page remains open. Closing this page will disconnect all transfer sessions.
from {@receiver_info["project_title"]}
<% end %>User Agent
{@connection_info[:user_agent]}
<%= if @icon do %> <.icon name={@icon} class="w-3 h-3" /> <% end %> {@label}
<%= if @value do %>{@value}
<% else %>Not provided
<% end %>