5.8 KiB · text History bfa2c00
defmodule DiogramosWeb.CanvasChannel do
  @moduledoc """
  Realtime canvas editing for native clients.

  This is the only write path for `document` outside the web LiveView —
  `Diogramos.Diagrams.Authority` owns the canonical document and the
  version counter, and this channel is a thin transport over it.

  Unlike a "something changed, go refetch" channel, this one pushes the
  **ops themselves**. `Authority` already broadcasts
  `{:canvas_op, %{op, version, actor_ref}}` as a versioned delta, the
  document is a single blob (so a refetch means the whole diagram), and
  `actor_ref` already exists so a client can recognize the echo of its own
  op. Wrapping that contract is cheaper than inventing a second one.

  ## Client protocol

      join("canvas:<id>")     -> {:ok, %{canvas:, document:, version:, role:, peers:, self_key:}}
      push("op", %{op:, actor_ref:})  -> {:ok, %{version:}} | {:error, %{code:}}
      push("cursor", %{x:, y:, lock:}) -> no reply
      <- push("op", %{op:, version:, actor_ref:})
      <- push("presence_diff", ...)

  ## Reconciliation

  `Authority.apply_op/4` takes no client version — ops apply in arrival
  order and the server increments `version` itself. There is no OT and no
  CRDT; concurrent edits to one field are last-writer-wins, exactly as the
  web editor behaves today. So a client applies an incoming op when its
  version is `local + 1`, and otherwise refetches
  `GET /api/v1/canvases/:slug` to resync. Drift is expected after a
  reconnect and should be silent.
  """
  use Phoenix.Channel

  # Presence diffs land on this channel's own topic, so they arrive as
  # outgoing broadcasts rather than plain messages. Intercepting lets us
  # replace the raw diff with the collapsed peer list the client wants.
  intercept ["presence_diff"]

  alias Diogramos.Accounts
  alias Diogramos.Accounts.Scope
  alias Diogramos.Diagrams.{Authority, Canvases, Permissions}
  alias DiogramosWeb.CanvasPresence

  @impl true
  def join("canvas:" <> canvas_id, _params, socket) do
    with {:ok, id} <- parse_id(canvas_id),
         %{} = user <- Accounts.get_user(socket.assigns.user_id),
         scope = Scope.for_user(user),
         {:ok, canvas} <- fetch_canvas(scope, id),
         :ok <- Permissions.authorize(scope, :read, canvas),
         {:ok, %{document: document, version: version}} <- Authority.snapshot(id) do
      # No explicit PubSub.subscribe here: `Authority.topic(id)` *is* this
      # channel's topic, and joining already subscribed us. Subscribing
      # again would deliver every op twice.
      self_key = "user:#{user.id}:#{:erlang.phash2(self())}"

      socket =
        socket
        |> assign(:canvas_id, id)
        |> assign(:scope, scope)
        |> assign(:self_key, self_key)
        |> assign(:topic, Authority.topic(id))

      send(self(), :after_join)

      {:ok,
       %{
         canvas: canvas_meta(canvas),
         document: document,
         version: version,
         role: Permissions.effective_role(scope, canvas),
         self_key: self_key
       }, socket}
    else
      {:error, :forbidden} -> {:error, %{code: "forbidden"}}
      {:error, :not_found} -> {:error, %{code: "not_found"}}
      _ -> {:error, %{code: "unavailable"}}
    end
  end

  def join(_topic, _params, _socket), do: {:error, %{code: "not_found"}}

  @impl true
  def handle_info(:after_join, socket) do
    user = socket.assigns.scope.user

    CanvasPresence.track(self(), socket.assigns.topic, %{
      key: socket.assigns.self_key,
      name: display_name(user),
      source: "android"
    })

    push(socket, "peers", %{
      peers: CanvasPresence.list_peers(socket.assigns.topic, socket.assigns.self_key)
    })

    {:noreply, socket}
  end

  # Echoes of our own ops are pushed too — the client matches on
  # actor_ref and drops them, but still adopts the version.
  def handle_info({:canvas_op, payload}, socket) do
    push(socket, "op", %{
      op: payload.op,
      version: payload.version,
      actor_ref: payload.actor_ref
    })

    {:noreply, socket}
  end

  def handle_info(_msg, socket), do: {:noreply, socket}

  @impl true
  def handle_out("presence_diff", _diff, socket) do
    push(socket, "peers", %{
      peers: CanvasPresence.list_peers(socket.assigns.topic, socket.assigns.self_key)
    })

    {:noreply, socket}
  end

  @impl true
  def handle_in("op", %{"op" => op} = params, socket) when is_map(op) do
    actor_ref = Map.get(params, "actor_ref", "")

    case Authority.apply_op(socket.assigns.canvas_id, socket.assigns.scope, op, actor_ref) do
      {:ok, %{version: version}} ->
        {:reply, {:ok, %{version: version}}, socket}

      {:error, reason} ->
        {:reply, {:error, %{code: to_string(reason)}}, socket}
    end
  end

  def handle_in("op", _params, socket) do
    {:reply, {:error, %{code: "invalid_op"}}, socket}
  end

  def handle_in("cursor", params, socket) do
    cursor =
      case params do
        %{"x" => x, "y" => y} when is_number(x) and is_number(y) -> %{x: x, y: y}
        _ -> nil
      end

    lock = if is_binary(params["lock"]), do: params["lock"]

    CanvasPresence.update(self(), socket.assigns.topic, socket.assigns.self_key, fn meta ->
      %{meta | cursor: cursor, lock: lock}
    end)

    {:noreply, socket}
  end

  def handle_in(_event, _params, socket), do: {:noreply, socket}

  ## Helpers

  defp parse_id(str) do
    case Integer.parse(str) do
      {id, ""} -> {:ok, id}
      _ -> {:error, :not_found}
    end
  end

  defp fetch_canvas(scope, id) do
    case Canvases.get(scope, id) do
      nil -> {:error, :not_found}
      canvas -> {:ok, canvas}
    end
  end

  defp canvas_meta(canvas) do
    %{
      id: canvas.id,
      slug: canvas.slug,
      title: canvas.title,
      theme: canvas.theme
    }
  end

  defp display_name(user) do
    user.display_name || user.email |> String.split("@") |> hd()
  end
end