Rails in Real Time: Blastoff

When? Now!

Interactions on the internet today appear to be so fluid and instant. It's becoming more unacceptable from today's user experience perspective to have to refresh the web page when data changes. We want the changes to be reflected instantly. Can you imagine having to restart your Facebook or WhatsApp mobile app after you sent a message just to visually reflect the data changes that the server received? Why should the coolest features of your Rails applications be tied and bound to the seemingly antiquated way of checking for updates to your pages, the refresh button?

Ready Up

Before we go and implement these real time features into a Rails application, let's make sure we're all on the same page, here. I'll be referencing a project in my GitHub repo to go over implementing these features. I plan on using that project as the base of this blog post series going forward, where each blog post is a branch on the repo so keep it handy.

You can go over any file of that project but there are some things that I want to specifically point out.

Config

Unless you want to look over all of the files to find out how I configured the project, here are the key configuration steps that I took:

  • Add dependent gems to the Gemfile
  • Default installation of Devise, RSpec, and Webpacker
rails generate devise:install
rails generate devise User
rails generate rspec:install
rails webpacker:install
  • Added jQuery for easy DOM manipulation
yarn add jquery
// config/webpack/environment.js

const { environment } = require('@rails/webpacker')
const webpack = require('webpack')

environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

module.exports = environment
// app/javascript/packs/application.js

require('@rails/ujs').start()
require('turbolinks').start()
require('@rails/activestorage').start()
require('channels')
require('jquery') // This is the new item
  • Added Bootstrap for easy styling (downloaded into the project assets)
<%# app/views/layouts/application.html.erb %>

<%= stylesheet_link_tag 'bootstrap.min' %>
  • Generated the models, controllers, and views
rails scaffold Chatrooms
rails scaffold Messages

You can find the implementation details of the migrations, model configuration and seed data on the project repo.

Channels

In this first part of the series, we're going to implementing a single channel that users can post messages to. First, we'll need to generate a new channel:

rails g channel messages

This will create some files that we'll need to customize: app/javascript/channels/messages_channel.js and app/channels/messages_channel.rb. In the messages_channel.rb file, we'll need to modify the subscribed method to be:

def subscribed
  stream_from 'messages_channel'
end

The name messages_channel is set dynamically by generated code from Rails in app/javascript/channels/index.js.

This sets up a listener on the messages_channel channel so that we can create subscriptions to that channel through the messages_channel.js file. In the JavaScript file, you see some generated code for you. What I'd like you to be aware of is that it automatically set up the subscription for you with:

consumer.subscriptions.create('MessagesChannel', {...});

This function is the gateway to how you'll interact with Channels in your application. The options object that you pass as the second argument has 3 key function callbacks defined:

{
  connected() {
    // Called when connected to the server
  },

  disconnected() {
    // Called when the subscription has been terminated by the server
  },

  received(data) {
    // Called when the channel receives data
  }
}

We'll return to work on these channels when we have more of our application configured.

Controllers

The controller setup is pretty straight forward. Let's start with the ChatroomsController (found in app/controllers/chatrooms_controller.rb). Since we're using Devise, we'll want our users to be signed in before they interact with our channel system:

# app/controllers/chatrooms_controller.rb

before_action :authenticate_user!

Next, we'll want to display all of the chatrooms that the user can join, once logged in:

# app/controllers/chatrooms_controller.rb

def index
  @chatrooms = Chatroom.all
end

def show
  @message = Message.new
end

Moving to the MessagesController (`app/controllers/messages_controller.rb), we only want logged in users to see messages as well:

# app/controllers/messages_controller.rb

before_action :authenticate_user!

Everything in this controller is pretty stock except for the create method. We'll need to hook into ActionCable server and broadcast a message to a channel so that the subscribers can receive them:

# app/controllers/messages_controller.rb

def create
  @message = Message.new(message_params)
  @message.user = current_user

  respond_to do |format|
    if @message.save
      format.html do
        ActionCable.server.broadcast('messages_channel', content: @message.content, user: current_user.email)
      end
    else
      chatroom = Chatroom.find(message_params[:chatroom_id])
      format.html { redirect_to chatroom_path(chatroom), alert: @message.errors.full_messages }
    end
  end
end

Views

We'll need a new message to send within the chatroom in the show method (coming up soon). To display those chatrooms:

<%# app/views/chatrooms/index.html.erb %>

<h2>Chatrooms</h2>

<%= render partial: 'chatrooms/chatroom_list', locals: { chatrooms: @chatrooms } %>
<%# app/views/chatrooms/_chatroom_list.html.erb %>

<ul class="list-group">
  <% chatrooms.each do |room| %>
    <%= link_to chatroom_path(room) do %>
      <li class="list-group-item"><%= room.topic %></li>
    <% end %>
  <% end %>
</ul>

Although not required, I made the list a partial in my application because I also display that on the home page HomeController#index.

The show.html.erb page has a bit more going on:

<%# app/views/chatrooms/show.html.erb %>

<h2><%= @chatroom.topic %></h2>

<nav class="nav">
  <%= link_to 'All Rooms', chatrooms_path, class: 'nav-link' %>
</ul>

<div class="d-flex flex-column justify-content-start message-container">
  <div id="messages" class="message-list list-group px-3">
    <% @chatroom.messages.each do |message| %>
      <div class="message d-flex flex-column list-group-item">
        <span class="mb-3">
          <%= message.user.email %>
        </span>
        <span><%= message.content %></span>
      </div>
    <% end %>
  </div>

  <%= render partial: 'messages/form', locals: { message: @message, chatroom: @chatroom } %>
</div>

We needed to set up the @message variable in the ChatroomsController#show method so that we can use the app/views/messages/_form.html.erb partial:

<%# app/views/messages/_form.html.erb %>

<%= form_with(model: message, remote: true, html: { class: 'messages-form' }) do |form| %>
  <% if message.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(message.errors.count, 'error') %> prohibited this message from being saved:</h2>

      <ul>
        <% message.errors.full_messages.each do |message| %>
          <li class="alert alert-danger"><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group d-flex justify-content-center m-0">
    <%= form.hidden_field :chatroom_id, value: chatroom.id %>
    <%= form.text_area :content, class: "message-text-area form-control mx-auto", placeholder: 'Type something!' %>
    <%= form.submit 'Send', style: 'display: none' %>
  </div>
<% end %>

It's time to update the app/javascript/channels/messages_channel.js file and finally get to using those channels!

Interactions

First, we'll have to wait for TurboLinks to be loaded onto the page before we can send any messages to our MessagesChannel subscribers:

// app/javascript/channels/messages_channel.js

$(document).on('turbolinks:load', function () {
  submitMessages();
});

I've added a helper function called submitMessages() once TurboLinks is fully loaded:

// app/javascript/channels/messages_channel.js

function submitMessages() {
  let timer;

  $('#message_content').on('keydown', function (e) {
    // Give custom behavior once the 'Enter' button is pressed after sending a message
    if (e.key === 'Enter') {
      $('input').click();
      e.target.value = '';
      e.preventDefault();

      // Scroll to the newly added HTML after sending a new message
      clearTimeout(timer);
      timer = setTimeout(() => {
        const container = $('.message-container');
        container.scrollTop(container[0].scrollHeight - container[0].clientHeight);
      }, 50);
    }
  });
}

Once the user hits the Enter key to send a message, we need to do something with the received data:

// app/javascript/channels/messages_channel.js

received(data) {
  $(`
    <div class="message d-flex flex-column list-group-item">
      <span class="mb-3">
        ${data.user}
      </span>
      <span>
        ${htmlEntities(data.content)}
      </span>
    </div>
  `).appendTo($('#messages'));
}

I created another helper function as a basic protection against possible hackers:

// app/javascript/channels/messages_channel.js

function htmlEntities(str) {
  return String(str).replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

An example of a hacker would be someone to inject some (potentially bad) code in via JavaScript onto the page. If you just send data.content through to the page, who would see this alert? <script>alert('I win!');</script>

Sending Messages

This next part assumes that you have a seed file set up similar to this one. Once you run the project, create a user via the sign up form at http://localhost:3000/users/sign_up. After you've gotten a new user, open up either new browser or a new window in incognito mode (Chrome) / private mode (FireFox) and repeat the process to create a new user.

To get to our only chatroom, head over to http://localhost:3000/chatrooms/1 and test it out!

Conclusion

Throughout this post, we learned how to configure and set up channels using ActionCable in Rails 6. Make sure that you stay tuned for more post to come in this series!

Posts in this series

  • Rails in Real Time: Blastoff