Building a real-time user page detector

August 29, 2023

Recently for RelationKit.io I wanted to build a system to help show other users on the same account if another person is on the same page as them to avoid embarrassing duplicate replies on support tickets. While thinking about how to build this, I wondered if I could use Turbo to build it without any custom JavaScript and the answer is a resounding yes.

You can see a demo of what we're building here.

Requirements:

  • Turbo

  • Redis

The Setup:

First, if you're on a fresh app with a user system installed you'll need to modify your ApplicationCable::Connection class to be able to identified_by :current_user. Something like this will work if you're using a custom session authentication, or if you use devise the standard code you can find on the internet will work too.

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    protected

    def find_verified_user
      if (authenticated_user = User.find_signed(cookies.signed[:session_token], purpose: :session_token))
        authenticated_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

The next thing we need is a custom ActionCable channel that Turbo's turbo_stream_from will use to connect. We need a custom channel to allow us to override the behavior when a user subscribes or unsubscribes from said channel.

mkdir app/channels

touch app/channels/user_detector_channel.rb

Then we can fill out the channel with the following code:

class UserDetectorChannel < Turbo::StreamsChannel
  def subscribed
    super
    ActionCable.server.pubsub.redis_connection_for_subscriptions.sadd(verified_stream_name_from_params, current_user.id)
    current_user.broadcast_status(verified_stream_name_from_params, :online)
  end

  def unsubscribed
    super
    ActionCable.server.pubsub.redis_connection_for_subscriptions.srem(verified_stream_name_from_params, current_user.id)
    current_user.broadcast_status(verified_stream_name_from_params, :offline)
  end
end

You can see we've added two methods subscribed and unsubscribed, we're calling super on both of them to do the behavior the Turbo::StreamsChannel does by default and then we get into the fun code.

We use the Redis attached to ActionCable and add a key named the stream name and add the current user id to Redis. Or when we unsubscribe we delete the key from Redis.

Next we call an instance method broadcast_status to the User model which is defined like so:

  def broadcast_status(key, state)
    if state == :online
      broadcast_append_to(key, target: "users_online", partial: "users/user", locals: { user: self })
    elsif state == :offline
      broadcast_remove_to(key, target: "user_#{id}")
    end
  end

This will either broadcast an append_to call of the users/user partial to be added to a div with an ID of users_online or will remove a div with the id of user_id. We could have made two separate methods, one for broadcasting when they're online and the other when they're offline but I don't mind keeping it all in one method. Just be sure to call the right method in the Channel's subscribed and unsubscribed methods.

The last thing we need for setting up the backend code is a new class. I named mine UserDetector.

class UserDetector
  def initialize(key)
    @key = key
  end

  def users
    ids = ActionCable.server.pubsub.redis_connection_for_subscriptions.smembers(@key)
    User.where(id: ids)
  end
end

This will allow us to call UserDetector.new(key).users to return the list of users.

The Views:

The code below assumes you have Tailwind installed but can be modified for other CSS frameworks as to your pleasing.

Inside of your application.html.erb, or another view where you'd like to display your list of active users on the page, you can set it up like so:

<% detector_key = 'user_detector:account-#{Current.account.id}:#{request.original_url.encode("UTF-8").parameterize}' %>
<%= turbo_stream_from detector_key, channel: UserDetectorChannel %>
<div class="flex flex-1 items-center justify-end h-[48px] bg-neutral-50 border-b border-neutral-200 dark:border-neutral-700">
  <div class="m-2 mr-4 flex items-center">
    <div class="text-xs text-neutral-500 mr-2">On this page:</div>
    <div class="flex -space-x-2" id="users_online">
      <% UserDetector.new(detector_key).users.each do |user| %>
        <%= image_tag user.avatar_url, id: "user_#{user.id}", class: "inline-block h-8 w-8 rounded-full ring-2 ring-white" %>
      <% end %>
    </div>
  </div>
</div>

The turbo_stream_from does all the magic with the string we pass it. In my case I have a Current.account model I wish to scope things down to, so only users on the same account and on the same page will know who's also on that page for. Then we grab the request url and call parameterize on it so it uses dashes between things.

"user_detector:account-#{Current.account.id}:#{request.original_url.encode("UTF-8").parameterize}"

Note: There may be a better way to grab the current page someone is on, I had it working with controller names / actions but ran into an issue where someone being on the same action (like show) would make them always show up for all Tickets#show actions (for example).

Inside your users/_user.html.erb partial (or whatever partial you're broadcasting from the User model)

<%= image_tag user.avatar_url, id: "user_#{user.id}", class: "inline-block h-8 w-8 rounded-full ring-2 ring-white" %>

Conclusion

To wrap things up, I'll show you how it looks in my application at the end of the day.

CleanShot 2023-08-29 at 11.20.33@2x.png

I think that it looks quite nice and I'd be interested to hearing how you might take and use this approach on your own applications. Thanks for reading~

~ Andrea