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:

../assets/images/posts/2022/2022-01-11-cubism-sequence.png

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!