Edit Queues with Phoenix Sockets

So, the idea for this experiment was to maintain a queue of editors for a given object (in this case, a project) so that if I open up a project first, I get full editing privileges for as long as my session remains active. Anyone else who opens it up during that time will have read-only access. Then, as soon as I leave the page or close the tab, the next person in line receives the edit rights, and so on and so forth.

Let’s jump straight into this and crack open a new Phoenix project:

mix phx.new editor_queue

We’ll want to have some way to “edit” our project, we’re going to keep this relatively simple in that we have a button on the page that does magic edits of some kind, and if you’re not the editor, you won’t be able to press it and you’ll be told to go away.

lib/editor_queue_web/templates/page/index.html.eex<div class="jumbotron">
<h2>Let's make an editor queue</h2>
<div id="edit-button" class="btn primary disabled">
Edit Things
</div>
</div>
assets/css/app.css.btn {
width: 150px;
height: 35px;
background-color: rgb(47, 79, 79);
color: white;
}
.btn.primary:hover {
color: white;
background-color: rgb(92, 113, 113);
}

We’re going to be using sockets pretty heavily here, we want to keep track of who’s currently looking at a given project and track that list as people pop in and out. If we open up our UserSocket, we can make a couple of quick changes to how we connect to that.

lib/editor_queue_web/channels/user_socket.exchannel "project:*", EditorQueueWeb.ProjectChanneldef connect(%{"user_id" => user_id}, socket) do
{:ok, assign(socket, :user_id, user_id)}
end

Our first change is that we want to expose a new channel that people can connect to, being our ProjectChannel. The second is that when connecting to the socket, we expect there to be a user ID present in the params, and we assign that to our socket.

With that in mind, let’s make a few changes to our JavaScript:

assets/js/socket.jslet socket = new Socket("/socket", {params: {user_id} })socket.connect()

Because we told our socket to expect a user ID, we need to provide it one when connecting. I’ll leave it up to you to decide where this is coming from.

Once we’ve connected to the socket, we need to actually do something with it. In this case, we want to now join our project channel. Let’s create that now.

lib/editor_queue_web/channels/project_channel.exdefmodule EditorQueueWeb.ProjectChannel do
use EditorQueueWeb, :channel
def join("project:" <> project_id, _params, socket) do
{:ok, assign(socket, :project_id, project_id)}
end
end

For now, we can tell everyone to join the channel for project “1”.

assets/js/socket.js...let channel = socket.channel("project:1", {})
channel.join()
.receive("ok", resp => { console.log("Joined successfully", resp) })
.receive("error", resp => { console.log("Unable to join", resp) })

Lastly, we just need to uncomment the following line on our default app.js file:

import socket from "./socket"

Now if we run our server and load localhost:4000, we should see our “joined successfully” message in the console.

This is a good start, we’re connecting to our channel properly, but, our button is still disabled. We need something here to tell us we’re allowed to press our button that’s going to make some changes.

Let’s start with the changes to our ProjectChannel.

lib/editor_queue_web/channels/project_channel.ex...def join("project:" <> project_id, _params, socket) do
send(self(), :after_join)
{:ok, assign(socket, :project_id, project_id)}
end
def handle_info(:after_join, socket) do
push socket, "editing_enabled", %{}
{:noreply, socket}
end

We’ve changed our channel to now do some work after we join, by just telling the connected client that they’re allowed to start making edits. Now let’s make our client aware of that event being sent across.

assets/js/socket.js...let editButton = document.getElementById('edit-button')
channel.on('editing_enabled', () => {
editButton.classList.toggle("disabled")
})
editButton.onclick = function() {
alert('Project successfully edited!')
}

If we reload our page, we can see our button is enabled and we can press it to make some magical edits happen. But, one problem, if we open the page in another tab, the button is still enabled and we can push it at the same time as the first editor, which is the exact situation we want to avoid.

Let’s take another look at what we added to our ProjectChannel:

lib/editor_queue_web/channels/project_channel.exdef handle_info(:after_join, socket) do
push socket, "editing_enabled", %{}
{:noreply, socket}
end

What we have currently is telling anyone who joins the project that they’re allowed to edit things, let’s fix that now. We’re going to use a couple of pieces of Elixir’s OTP structure, a GenServer, which is an Elixir process we can use to hold some state. We’ll call ours Queue.

lib/editor_queue/queue.exdefmodule EditorQueue.Queue do
use GenServer
defstruct [
viewers: []
]
def start_link(id) do
GenServer.start_link(__MODULE__, id, name: ref(id))
end
def init(id) do
{:ok, %__MODULE__{}}
end
def queue(id, viewer), do: GenServer.call(ref(id), {:queue, viewer})
def pop(id, viewer), do: GenServer.call(ref(id), {:pop, viewer})
def editor(id), do: GenServer.call(ref(id), {:editor})
def handle_call({:queue, viewer}, _from, queue) do
queue = %{queue | viewers: queue.viewers ++ [viewer]}
{:reply, {:ok, queue}, queue}
end

def handle_call({:pop, viewer}, _from, queue) do
queue = %{queue | viewers: queue.viewers |> Enum.filter(fn(x) -> x != viewer end)}
{:reply, {:ok, queue}, queue}
end
def handle_call({:editor}, _from, queue) do
{:reply, fetch_editor(queue.viewers), queue}
end
defp ref(id), do: {:global, {:edit_queue, id}}
defp fetch_editor([]), do: nil
defp fetch_editor([editor | _]), do: editor
end

I’ll skip over the initialisation side of things, as the docs linked above will cover that. But this is doing a couple of things for us, we’re storing a list of user IDs in a property called viewers, we can add it to it by calling queue, and we can find out the editor (the user at the head of the list) by calling editor.

We’ll also need a Supervisor to watch over our Queue process.

lib/editor_queue/queue_supervisor.exdefmodule EditorQueue.QueueSupervisor do
use Supervisor
def start_link do
Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
children = [
worker(EditorQueue.Queue, [], restart: :temporary)
]
supervise(children, strategy: :simple_one_for_one)
end
def create_queue(id) do
Supervisor.start_child(__MODULE__, [id])
end
end
lib/editor_queue/application.exchildren = [
...
supervisor(EditorQueue.QueueSupervisor, []),
]

Now, let’s introduce this into our ProjectChannel.

lib/editor_queue_web/channels/project_channel.exalias EditorQueue.{QueueSupervisor, Queue}...def handle_info(:after_join, socket) do
queue_viewer(socket.assigns.project_id, socket.assigns.user_id)
editor = Queue.editor(socket.assigns.project_id)
if editor == socket.assigns.user_id do
push socket, "editing_enabled", %{}
end
{:noreply, socket}
end
defp queue_viewer(project_id, user_id) do
QueueSupervisor.create_queue(project_id)
Queue.queue(project_id, user_id)
end

Now on joining the channel, we add the user to our queue, then check if the editor is the person who just joined, and if they are, tell them they can edit things. If we open some tabs again, we can see that the the first tab we open has an enabled button, and subsequent tabs have a disabled button.

We have one problem remaining, what happens when people leave the queue? If we’re the next person in the queue, we want to know when it’s our time to shine and we can start making edits of our own. To do this, we’re going to use the terminate/2 callback on Phoenix.Channel to let us broadcast about the new editor.

lib/editor_queue_web/channels/project_channel.exalias EditorQueueWeb.{Endpoint}...def handle_info(:after_join, socket) do
subscribe_editor(socket)

...
end
def terminate(_payload, socket) do
old_editor = Queue.editor(socket.assigns.project_id)
Queue.pop(socket.assigns.project_id, socket.assigns.user_id)
new_editor = Queue.editor(socket.assigns.project_id)
if old_editor != new_editor do
notify_editor(socket.assigns.project_id, new_editor)
end
socket
end
defp subscribe_editor(socket) do
Phoenix.PubSub.subscribe(
socket.pubsub_server,
editor_topic(socket.assigns.project_id, socket.assigns.user_id),
fastlane: {socket.transport_pid, socket.serializer, []}
)
end
defp editor_topic(project_id, user_id), do: "project:#{project_id}:editor:#{user_id}"defp notify_editor(project_id, user_id) do
Endpoint.broadcast!(
editor_topic(project_id, user_id), "editing_enabled", %{}
)
end

We’ve added a step to our initial join handler, which now subscribes the socket to a new topic solely for them to be notified when they become the editor. The fastlane config sends those events straight down the socket to the client rather than being handled within this channel.

A new callback, terminate/2, has been implemented. This is going to fire off if a client leaves or if their connection closes for some reason, so will give us an opportunity to pop them out of the queue and (potentially) move a new editor into play. If we do notice that the editor has changed, we’re going to broadcast on our new topic, and that client will pick up the event and activate the button.

Because of this change in how we’re notifying clients, we’ll need to tweak how we handle the event, this event doesn’t actually come down on the channel itself, it just comes from the socket.

assets/js/socket.js...let editButton = document.getElementById('edit-button')
socket.onMessage(({ topic, event, payload }) => {
if (event == "editing_enabled"
&& topic == `project:1:editor:${user_id}`) {
editButton.classList.toggle("disabled");
}
})

Rather than the callback now living on the channel itself, we move it to the socket, so we need to check the event coming down as well as the topic itself (depending if there’s some event clashes across topics, not the case here).

Now if we open up multiple tabs, starting with one active editor, we can close our tabs and see the next tab in line become active. 🎉

That about covers everything we set out to do, but, you could take this even further by communicating actual changes via the socket as well. Rather than a form doing an API request, pushing them through the channel could let you validate that they were the active editor as well.

If you want to take a look at the source for our project, you can find it here.

Lead Developer @ Papercloud, Lego builder, hip-hop listener.