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.
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