In this quick tutorial, I’m going to acquaint you with Cubism, a lightweight, CableReady based way to add presence indicators to your Rails models.
Before we get too far into the weeds, though, here’s a quick overview of how it works conceptually:
For this demo I’ve set up a Rails 7 app using esbuild for JavaScript bundling, and installed (and configured) Kredis, since that is a dependency of Cubism.
$ rails new cubism_demo -j esbuild
$ bundle add kredis
$ bin/rails kredis:install
Set Up a Minimal Rails App
To demonstrate how Cubism manages user presence, we need a minimum of two resources: A user, and something that user is present on. So first, let’s generate a User
model with a username
attribute. We’ll add two dummy users, one named Julian, and one named Andrew (the abundance of Andrews in my life warranted this choice 😬).
User Model
$ bin/rails g model User username:string
$ bin/rails db:migrate
julian = User.create(username: "Julian")
andrew = User.create(username: "Andrew")
Message Scaffold
Next we’ll add a scaffold for a Message
resource, with a reference to a user and a content
attribute. Let’s also add a dummy message here in the Rails console:
$ bin/rails g scaffold Message user:references content:string
$ bin/rails db:migrate
Message.create(content: "test", user: julian)
Prune MessagesController and Show View
We don’t need any other routes than the show
route for demonstration purposes, so we clean everything else out. Since I don’t want to set up actual authentication for this trivial example, let’s simulate setting the current user via a request parameter.
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :set_message, only: %i[ show ]
before_action :set_user, only: %i[ show ]
# GET /messages/1 or /messages/1.json
def show
end
private
# Use callbacks to share common setup or constraints between actions.
def set_message
@message = Message.find(params[:id])
end
def set_user
@user = User.find(params[:user_id])
end
# Only allow a list of trusted parameters through.
def message_params
params.require(:message).permit(:user_id, :content)
end
end
<!-- app/views/messages/show.html.erb -->
<p style="color: green"><%= notice %></p>
<%= render @message %>
Install Cubism
Now we’re ready to actually install Cubism. We do that by adding both the Rubygem as well as the npm package.
$ bundle add cubism
$ yarn add @minthesize/cubism
In addition to importing said JavaScript module in application.js
, we also need to initialize CableReady with an ActionCable consumer
, which we obtain from turbo-rails
:
// app/javascript/application.js
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
import { cable } from "@hotwired/turbo-rails";
import CableReady from "cable_ready";
import "@minthesize/cubism";
// top level await is not supported in esbuild
(async () => {
const consumer = await cable.getConsumer()
CableReady.initialize({ consumer });
})()
Server-Side Preparations
To wire up the server-side part of Cubism, we need to do two things:
Tell it which class to use for user lookup by including Cubism::User
. Typically, this will be your User
class, but it doesn’t have to be. You could equally set up Team
for tracking presence, etc.
# app/models/user.rb
class User < ApplicationRecord
# establishes the class Cubism uses for user lookup
include Cubism::User
end
Include the Cubism::Presence
module in any class we want to track presence for, in our case the Message
class.
# app/models/message.rb
class Message < ApplicationRecord
# tracks users present on an instance of this model
include Cubism::Presence
belongs_to :user
end
And that’s really it for server-side code. Elegant, isn’t it? 💅🏻
Display Present Users
Let’s get a first impression of how this can be used to display a list of current users by adding a Cubicle element to the message’s show view. We can use the included cubicle_for
helper for this, which takes the model and the current user as arguments, and yields the present users
array to a block. Note that I’ve set exclude_current_user
to false, because the default would be to exclude it, Google-Drive style.
<!-- app/views/messages/show.html.erb -->
<p style="color: green"><%= notice %></p>
<%= render @message %>
<%= cubicle_for @message, @user, exclude_current_user: false do |users| %>
<%= users.map(&:username).to_sentence %> <%= users.size > 1 ? "are" : "is" %> watching this message.
<% end %>
Display Typing Indicators
Finally, let’s transform this from tracking every user who views our message to tracking users who are typing into a text field. To simulate a comment for, let’s just add a plain text field (note that this conveniently adds a DOM ID of comment
).
To change the appear/disappear behavior, we add a couple of options to our helper. First, appear
and disappear
triggers, focus
and blur
. You can use every Javascript event name (custom ones, too!) for this. Finally, because we only care about events fired by a specific element (our comment field), we add a trigger_root
, which expects a CSS selector, to scope our presence tracking. And voilà, here you go!
<!-- app/views/messages/show.html.erb -->
<p style="color: green"><%= notice %></p>
<%= render @message %>
<%= cubicle_for @message, @user,
appear_trigger: :focus,
disappear_trigger: :blur,
trigger_root: "#comment" do |users| %>
<% if users.present? %>
<%= users.map(&:username).to_sentence %> <%= users.size > 1 ? "are" : "is" %> typing...
<% end %>
<% end %>
<%= text_field_tag :comment %>
Conclusion
Cubism makes it dead simple to add any kind of presence indicators to your Reactive Rails app. For the moment, it’s resource based (you can only add it to AR models), but routes-specific presence tracking is on the roadmap!