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
neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
5.8 KiB · text
History
bfa2c00