Using Presence to make sure a user only has 1 tab open

I’m working on Application which requires limiting users to 1 tab open at any time. In order to do this, I’m using Presence to track and untrack to know if they are connected to the Channel. Here is how I’m untracking on termination:

def terminate(_reason, %{assigns: %{current_user: user}} = socket) do
    :ok = Presence.untrack(socket, user.id)
  end

And when the user joins the channel, I check if they’re already tracked. If they are, I reply with an error and then redirect them client side:

def join("room:" <> id, _payload, socket) do
    cond do
      already_in_room?(socket) ->
        {:error, %{reason: "already_in_room"}}

      true ->
       ...
    end
end
...
defp already_in_room?(%{assigns: %{current_user: user}} = socket) do
    socket
    |> Presence.list()
    |> Map.has_key?(user.id)
end

The client error handler:

const handleRoomChannelError = (resp) => {
  console.log("Unable to join room channel", resp);
  if (resp.reason === "already_in_room") {
    window.location.href = `/rooms/already_in?return_to=${window.location.pathname}`;
  }
};

This is all mostly working fine, except that sometimes I’m seeing users in 1 tab suddenly become redirected. I’m guessing that their browser is reconnecting without terminating properly beforehand.

This requirement comes from dealing with a third party API which errors if we connect from multiple tabs, so not doing this isn’t an option.

My questions then are:

  1. Am I untracking the Presence or handling the termination correctly? If not, what should I be doing?
  2. Am I misusing Presence for this? Is relying on the termination of the connection to the Channel for a feature a bad idea?
  3. If it is, any suggestions of how to handle this another way?

Thanks

Doing things in terminate, which are required for consistancy is usually not a good idea at all. But in this case you can just skip this. Presence will cleanup for stopped processes on its own.

I’d say so. Channels cannot guarantee that they’re closed immediatelly when a user closes a tab or even worse their tab crashes or whatever else happens for it to not cleanly shut down. The channel on the server will eventually shut down (latest when the next heartbeat fails), but not immediatelly.

This is a distributed system (client and server), so you’ll need to choose between AP or CP. Seems like you’re forced into CP by your 3rd party API, so really you cannot guarantee continuous access. The user will need to wait till everything is properly cleaned up on the server before they can reconnect again.

I’d probably suggest instead of immediatelly failing when the slot is taken retrying to take it for at least 1 channel heartbeat interval, that should help with unclean disconnects.

2 Likes

Can you unpack this requirement a little more? Why / how does the third-party API know about the user’s tabs? Is it being connected to from client-side code?

On the client, it’s a Twilio video call. I connect using an identifier - the user’s id. When a user attempts to connect twice with the same identity Twilio throws a warning, but also causes some weird behaviours on all the other users who are connected. Duplicate videos, flickering video, exponentially growing list of participants when it’s still the same number of users, etc.

My solution was stopping a user from opening the page in another tab. I know it’s not the best solution, but this project is a prototype for user research, so this requirements comes more from some basic consistency while user testing. It’s a workaround rather than a proper fix.

How about instead of using Presence, you just use normal Pub Sub.

For instance, every user that joins, subscribes to a channel specific to that user topic user:user_id.
The first one joining the Twilio video call, stores a token or something in browser to signify that the first tab is the chosen one. (i.e. set session(“chosen”, true) or something).

Then any other tab before joining the Twilio video call, first asks in the user topic user:user_id, which tab is the chosen one.

This is like a client side distributed lock or something. :sweat_smile:

  def subscribe(user) do
    Phoenix.PubSub.subscribe(DerpyCoder.PubSub, topic(user))
  end

  defp broadcast({:ok, user}, event) do
    Phoenix.PubSub.broadcast(
      DerpyCoder.PubSub,
      topic(user),
      {event, user}
    )

    {:ok, user}
  end

  defp broadcast({:error, _changeset} = error, _event), do: error

  defp topic(%User{id: id}) do
    "user:#{id}"
  end


def handle_info({:who_has_the_lock, _}, socket) do
   # Let me check in Session to see if this tab does!!
end

def handle_info({:i_do_so_send_the_user_my_way, _}, socket) do
   # Let the user know that they have already joined on another tab and redirect to some page.
end


So you can have |> broadcast(:who_has_the_lock), or |> broadcast(:i_do_so_send_the_user_my_way) kind of events to communicate.


P.S. This is not a complete example, just an inkling of idea. I haven’t tested it in local so I don’t know the challenges you are going to face.

I just liked the thought of 2 processes communicating and letting each other know who’s the boss! :sweat_smile:

1 Like

Another thought. I’m not sure whether there are limitations to this, but it seems like you can use the Twilio API to check whether someone’s connected. If they’re already connected to the room, display a message saying “you can only be connected in one browser window” or whatever and a button to try again (in the event they’ve closed that other window).

That’s not to say you can’t also handle things in your app, but it seems like Twilio is the real source of truth here.

How about have the process try to register itself using the :global registry?