Real-Time Messaging with Rails and ActionCable

Share this article

Real-Time Messaging with Rails and ActionCable

Key Takeaways

  • ActionCable enhances Rails applications by enabling real-time web socket communication, allowing for features like live messaging and notifications.
  • Client-side JavaScript and server-side Ruby code work together to manage subscriptions and broadcast messages securely and efficiently in real-time.
  • Integrating user authentication within ActionCable channels ensures that messages are securely sent and received by authorized users only.
  • Deployment on Heroku with Redis for production requires configuration adjustments, including setting up the ActionCable adapter to use Redis Cloud.
  • The implementation of features such as dynamic conversation updates and user online status indicators provides a rich, interactive user experience.

In the previous article I showed how to create a custom messaging application allowing one-to-one chats between users. We’ve integrated Devise, set up proper associations, added support for Emoji, and made everything work in a synchronous fashion. However, I would really like this app to be more modern. Therefore today we will enhance it further with ActionCable, making it real-time.

By the end of the article you will know how to:

  • Write code client-side to subscribe to a web socket/channel
  • Write code server-side to broadcast messages
  • Secure ActionCable
  • Display notifications about new messages
  • Dynamically update conversations
  • Implement an “is online” feature using Redis
  • Deploy your application to Heroku

If you haven’t read the previous article, you may use this code to get started. This branch contains the final version of the application with all the real-time features.

A working demo is available at sitepoint-custom-messaging.herokuapp.com.

Integrating ActionCable

To make our messaging system work in real time, we will require ActionCable, which I have already covered in one of the previous articles. The first step is mounting it to some path, usually just /cable:

config/routes.rb

#...
mount ActionCable.server => '/cable'

Next, we need to take care of both client and server side.

Client Side

Inside the javascripts directory there should be a file called cable.js with the following contents:

cable.js

//= require action_cable
//= require_self
//= require_tree ./channels

(function() {
  this.App || (this.App = {});

  App.cable = ActionCable.createConsumer();

}).call(this);

We need to require this file in our javascript manifest:

application.js

//= require cable

Then, create a new channel that will be called Conversations. Let’s also move all code from the conversations.coffee into it:

javascripts/channels/conversations.coffee

jQuery(document).on 'turbolinks:load', ->
  messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight"))
  messages = $('#conversation-body')

  if messages.length > 0
    messages_to_bottom()

This code simply scrolls the conversations box to the very bottom as the newest messages are displayed. The javascripts/conversations.coffee can be removed.

Now, we need to subscribe to the Conversations channel. However, we only want to do this when a user is signed in, so let’s just add an empty block to the page if that’s true:

shared/_menu.html.erb

<% if user_signed_in? %>
  <div id="current-user"></div>
        <!-- ... -->

Now the CoffeeScript:

conversations.coffee

jQuery(document).on 'turbolinks:load', ->
  messages_to_bottom = -> messages.scrollTop(messages.prop("scrollHeight"))
  messages = $('#conversation-body')

  if $('#current-user').size() > 0
    App.personal_chat = App.cable.subscriptions.create {
      channel: "NotificationsChannel"
    },
    connected: ->
      # Called when the subscription is ready for use on the server

    disconnected: ->
      # Called when the subscription has been terminated by the server

    received: (data) ->
      # Called when data is received
  if messages.length > 0
     messages_to_bottom()

I’ve received many questions about ActionCable and often readers experience problems with CoffeeScript by getting errors like “Unexpected indentation”. Therefore, note that CoffeeScript relies heavily on indentations and whitespace, so double check your code and compare it to the source published on GitHub.

The next step we should do is listen to the submit event on our form and prevent it from happening. Instead, we are going to send the message via our Notifications channel while providing the conversation id:

conversations.coffee

# ...
if messages.length > 0
  messages_to_bottom()
  $('#new_personal_message').submit (e) ->
    $this = $(this)
    textarea = $this.find('#personal_message_body')
    if $.trim(textarea.val()).length > 1
      App.personal_chat.send_message textarea.val(), $this.find('#conversation_id').val()
      textarea.val('')
    e.preventDefault()
    return false

Things are pretty simple here. Check that the message contains at least 2 characters and, if true, invoke the send_message method that will be coded in a moment. Next, clear the textarea indicating to the user that the message was consumed and prevent the default action from happening. Note, that even if our JavaScript fails to run or a user has disabled JavaScript for a website, messages will still be sent in a synchronous fashion.

Here is the send_message method:

conversations.coffee

# ...
App.personal_chat = App.cable.subscriptions.create {
  channel: "NotificationsChannel"
},
send_message: (message, conversation_id) ->
  @perform 'send_message', message: message, conversation_id: conversation_id
# ...

@perform 'send_message' means that we are invoking a method with this name on the server side while passing an object containing the message and conversation id. Let’s write this method now.

Server Side

Rails 5 applications have a new directory called app/channels. Create a new file inside that directory with the following code:

channels/notifications_channel.rb

class NotificationsChannel < ApplicationCable::Channel
  def subscribed
    stream_from("notifications_#{current_user.id}_channel")
  end

  def unsubscribed
  end

  def send_message(data)
    conversation = Conversation.find_by(id: data['conversation_id'])
    if conversation && conversation.participates?(current_user)
      personal_message = current_user.personal_messages.build({body: data['message']})
      personal_message.conversation = conversation
      personal_message.save!
    end
  end
end

subscribed and unsubscribed are hooks that run when someone starts or stops listening to a socket.

send_message is the method we are calling from the client side. That method checks whether a conversation exists and a user has rights to access it. This is a very important step, because otherwise, anyone may write to any conversation. If it is true, just save the new message. Note that when you make any changes to the files inside the channels directory, you must reboot web server (even in development).

Another thing that needs to be tackled is authentication to prevent any unauthorized user from subscribing to a socket. Unfortunately, we don’t have access to Devise’s current_user method inside channels, so add it now:

channels/application_cable/connection.rb

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

    def connect
      self.current_user = find_verified_user
      logger.add_tags 'ActionCable', current_user.email
    end

    protected

    def find_verified_user # this checks whether a user is authenticated with devise
      verified_user = env['warden'].user
      if verified_user
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

identified_by :current_user allows the current_user method to be used by the channels. connect is being triggered automatically when someone tries to connect to a socket. Here, we set the current user only if they are authenticated. As long as Devise uses Warden for authentication purposes, we can use env['warden'].user to return an ActiveRecord object. If the user is not authenticated simply call reject_unauthorized_connection.

logger.add_tags 'ActionCable', current_user.email prints out information about a user who has subscribed to a channel into the terminal.

So far so good, but after the message is saved, it should be sent back to the participants of a conversation and be rendered on the page. One of the ways to achieve this is by using a callback and a background job:

models/personal_message.rb

# ...
after_create_commit do
  NotificationBroadcastJob.perform_later(self)
end

after_create_commit is called only after a record was saved and committed.

Here is the background job to broadcast a new message:

jobs/notifications_broadcast_job.rb

class NotificationBroadcastJob < ApplicationJob
    queue_as :default

    def perform(personal_message)
      message = render_message(personal_message)
      ActionCable.server.broadcast "notifications_#{personal_message.user.id}_channel",
                                   message: message,
                                   conversation_id: personal_message.conversation.id

      ActionCable.server.broadcast "notifications_#{personal_message.receiver.id}_channel",
                             notification: render_notification(personal_message),
                             message: message,
                             conversation_id: personal_message.conversation.id
    end

    private

    def render_notification(message)
      NotificationsController.render partial: 'notifications/notification', locals: {message: message}
    end

    def render_message(message)
      PersonalMessagesController.render partial: 'personal_messages/personal_message',
                                        locals: {personal_message: message}
    end
  end

ActionCable.server.broadcast sends data via a channel. Since, inside notifications_channel.rb, we said stream_from("notifications_#{current_user.id}_channel"), the same name will be used in the background job.

Here we actually send two broadcasts, one containing the actual message to render and another with the notification. The idea is simple: if a user is currently browsing the conversation to which the message belongs, simply append a new message to the bottom. If, however, they are on some other page, display a small notification message at the bottom right corner saying “You’ve got a new message from…”.

Create a new controller to render the notification partial:

notifications_controller.rb

class NotificationsController < ApplicationController
end

And the partial itself:

views/notifications/_notification.html.erb

<div id="notification">
  <div class="close">close me</div>
  <p>
    <%= message.body %><br>
    from <%= message.user.name %>
  </p>
  <p><%= link_to 'Take me there now!', conversation_path(message.conversation) %></p>
</div>

Great! The next thing we need to do is actually render the markup, so return to the client side.

Back to the Client Side

Tweak the received callback to render the message or a notification:

conversations.coffee

received: (data) ->
  if messages.size() > 0 && messages.data('conversation-id') is data['conversation_id']
    messages.append data['message']
    messages_to_bottom()
  else
    $('body').append(data['notification']) if data['notification']

The conditional inside this callback relies on the data-conversation-id attribute being set for the #conversation-body, so add it now:

views/conversations/show.html.erb

<div id="conversation-body" data-conversation-id="<%= @conversation.id %>">

We are nearly done here. Let’s also make the “close me” link inside the notification to work. As long as a notification is a dynamically added content, we can’t bind a click event handler directly to it. Instead, rely on event bubbling:

conversations.coffee

$(document).on 'click', '#notification .close', ->
  $(this).parents('#notification').fadeOut(1000)

Also, style the notification to make it always display at the bottom right corner:

application.scss

#notification {
  position: fixed;
  right: 10px;
  bottom: 10px;
  width: 300px;
  padding: 10px;
  border: 1px solid black;
  background-color: #fff;
  .close {cursor: pointer}
}

Now you can boot the server, open two separate browser windows (or maybe use two different browsers) and test the result! Talking to yourself might not be very fun, though…

Note that in order to receive updates in real time you need to visit the same address in all browsers, either localhost:3000 or 127.0.0.1:3000.

Updating Conversations

Another useful and pretty easy to implement feature is the dynamic updating of conversations. Currently, we sort conversations by updated_at, but we don’t really set this column with a new value when a message is saved. Also, it would be nice to re-render conversations sorted in a new order dynamically if a user is browsing the main page.

To make it work, firstly modify the callback:

models/personal_message.rb

after_create_commit do
  conversation.touch
  NotificationBroadcastJob.perform_later(self)
end

touch will simply set the updated_at column with the current date and time.

Tweak the received callback:

conversations.coffee

received: (data) ->
  if messages.size() > 0 && messages.data('conversation-id') is data['conversation_id']
    messages.append data['message']
    messages_to_bottom()
  else
    $.getScript('/conversations') if $('#conversations').size() > 0
    $('body').append(data['notification']) if data['notification']

So if a user is not browsing a particular conversation but, rather, is located on the conversations page, request the JavaScript to re-render conversations in which they participate. Instead, you may request a JSON array with all the conversations and render them from the callback.

Make the index action respond to requests in HTML and JS formats:

conversations_controller.rb

def index
  @conversations = Conversation.participating(current_user).order('updated_at DESC')
  respond_to do |format|
    format.html
    format.js
  end
end

Lastly, the .js.erb view:

views/conversations/index.js.erb

$('#conversations').replaceWith('<%= j render @conversations %>');

That’s it!

“Is Online?” Feature

“Who is online” is a very common feature for messaging systems. We will implement it in a pretty simple format by styling the user’s name in either red or green marking whether they are online or not.

Setting the “online” flag for specific users can be done from the controller, but we are going to employ another channel for that (that’ll be also used to notify when someone goes online or offline). We are also going to utilize Redis so install it to see the result in action.

First of all, add the redis-rb gem

Gemfile

gem 'redis'

and install it

$ bundle install

Next, create the new Appearances channel:

javascripts/channels/appearances.coffee

jQuery(document).on 'turbolinks:load', ->
  App.personal_chat = App.cable.subscriptions.create {
    channel: "AppearancesChannel"
  },
  connected: ->

  disconnected: ->

  received: (data) ->

Basically, when a user subscribes to this channel, they are online.

Here is the server-side implementation:

channels/appearances_channel.rb

class AppearancesChannel < ApplicationCable::Channel
  def subscribed
    redis.set("user_#{current_user.id}_online", "1")
    stream_from("appearances_channel")
    ActionCable.server.broadcast "appearances_channel",
                                 user_id: current_user.id,
                                 online: true
  end

  def unsubscribed
    redis.del("user_#{current_user.id}_online")
    ActionCable.server.broadcast "appearances_channel",
                                 user_id: current_user.id,
                                 online: false
  end

  private

  def redis
    Redis.new
  end
end

Once someone is subscribed, set “user_SOME_ID_online” to “1” in Redis. Then start streaming and broadcast a message saying that a user is now online.

When a user unsubscribes, we delete the “user_SOME_ID_online” key and once again broadcast a message. Pretty simple, really.

While we are coding the server-side, introduce a new online? instance method for the User class:

models/user.rb

def online?
  !Redis.new.get("user_#{self.id}_online").nil?
end

The next step is to wrap all user names with a span tag and give it a proper class. To make this process easier, add the following helper method:

application_helper.rb

def online_status(user)
  content_tag :span, user.name,
              class: "user-#{user.id} online_status #{'online' if user.online?}"
end

Now this helper can be used in any view. I’ll utilize it inside the _conversation partial:

views/conversations/_conversation.html.erb

Chatting with <%= online_status conversation.with(current_user) %>

Now code the received callback. It should simply add or remove the online class:

appearances.coffee

received: (data) ->
  user = $(".user-#{data['user_id']}")
  user.toggleClass 'online', data['online']

Either add or remove the class based on the value of data['online'].

Of course, some styling is needed:

.online_status {
  color: red;
  transition: color 1s ease;
  &.online {
    color: green;
  }
}

I’ve added a transition to make things a bit more smooth. Of course, instead of styling names with some color, you may, for example, add an icon to them using :after or :before pseudo-selectors.

Publishing to Heroku

Of course, at some point in time you might want to share your application with the world. I will explain how to do it using Heroku as an example.

First of all, you need Redis installed. Heroku provides a bunch of addons (or elements as they are now called) adding Redis support. I am going to use Redis Cloud. Install its free version by typing:

$ heroku addons:create rediscloud

Now tell ActionCable to use Redis Cloud:

config/cable.yml

production:
  adapter: redis
  url: <%= ENV["REDISCLOUD_URL"] %>

Another thing to note is that when you say Redis.new, redis-rb will try to connect to localhost:6379 (6379 is the default port). That’s okay for development, but not particularly great for production. Therefore I’ve created this simple initializer:

config/initializes/set_redis_url.rb

ENV['REDIS_URL'] = ENV["REDISCLOUD_URL"] if ENV["REDISCLOUD_URL"]

By default, redis-rb first tries to connect to the URL set in the ENV['REDIS_URL'] and uses localhost:6379 as as fallback.

Another thing that needs to be done is the creation of the Procfile telling Heroku to use Puma as a web server:

Procfile

web: bundle exec puma -C config/puma.rb

The configuration docs for Puma can be found here.

The last step is setting the ActionCable URL and adding allowed origins for the production environment. For my demo application, the configuration is the following:

config/environments/production.rb

config.action_cable.allowed_request_origins = ['https://sitepoint-custom-messaging.herokuapp.com',
                                               'http://sitepoint-custom-messaging.herokuapp.com']

config.action_cable.url = "wss://sitepoint-custom-messaging.herokuapp.com/cable"

Your URL will be different.

That’s all! Once these steps are done, you can deploy your application to Heroku and test it out.

Conclusion

That was a long road. In these two articles, we’ve created a messaging application, allowing one-to-one conversations. We then made it work in real-time with the help of ActionCable and Redis. Of course, this code can be further developed and many new features can be introduced. If you create something interesting based on my code, do share it!

This concludes the article about messaging and ActionCable. As always I thank you for staying with me until the end and see you soon!

Frequently Asked Questions (FAQs) about Rails and ActionCable

What is the main purpose of ActionCable in Rails?

ActionCable is a feature in Rails that integrates WebSockets into a Rails application. It allows for real-time features to be written in Ruby in the same style and form as the rest of your Rails application, while still being able to have real-time, full-duplex communication between the browser and the server. This means that you can update specific parts of a page in response to changes on the server, which is a key component in creating interactive, user-friendly applications.

How does ActionCable differ from AJAX?

While both ActionCable and AJAX are used to create interactive applications, they work in fundamentally different ways. AJAX works by making asynchronous HTTP requests to the server from the client side. On the other hand, ActionCable uses WebSockets, which provide a persistent connection between the client and the server, allowing for real-time, bidirectional communication. This makes ActionCable more suitable for applications that require instant updates, such as chat applications or live feeds.

How can I set up ActionCable in my Rails application?

Setting up ActionCable in a Rails application involves a few steps. First, you need to ensure that your Rails application is running on a server that supports WebSockets. Then, you need to mount the ActionCable server in your application’s routing file. After that, you can generate channels where the actual communication will take place. Each channel corresponds to a specific functionality in your application, such as a chat room or a notification system.

What are the benefits of using ActionCable over other real-time communication methods?

One of the main benefits of using ActionCable is its seamless integration with Rails. This means that you can write your real-time features in Ruby, in the same style as the rest of your Rails application. Additionally, ActionCable uses WebSockets, which provide a persistent, bidirectional connection between the client and the server. This allows for real-time updates without the need for polling, which can significantly improve the performance and user experience of your application.

Can I use ActionCable with other JavaScript frameworks?

Yes, you can use ActionCable with other JavaScript frameworks. ActionCable provides a JavaScript library that you can include in your application. This library provides an API for interacting with the server-side ActionCable channels. You can use this API to send and receive messages on the client side, regardless of the JavaScript framework you are using.

How secure is ActionCable?

ActionCable provides a number of security features to protect your application. For example, it uses the same origin policy to prevent cross-site scripting attacks. Additionally, it provides a mechanism for authorizing connections and channels, allowing you to control who can connect to your WebSocket server and what they can do.

How can I test my ActionCable implementation?

Rails provides a number of tools for testing your ActionCable implementation. For example, you can use the ActionCable::TestCase class to write unit tests for your channels. Additionally, you can use the ActionCable::Connection::TestCase class to test your connection logic.

Can I use ActionCable for large-scale applications?

Yes, you can use ActionCable for large-scale applications. However, as the number of concurrent connections increases, you may need to consider using a more scalable server, such as Puma or Passenger. Additionally, you may need to configure your server to allow for more concurrent connections.

How can I debug issues with ActionCable?

Rails provides a number of tools for debugging issues with ActionCable. For example, you can use the Rails logger to log messages from your channels and connections. Additionally, you can use the ActionCable JavaScript library’s debugging features to log messages on the client side.

Can I use ActionCable with Rails API?

Yes, you can use ActionCable with Rails API. However, since Rails API is a stripped-down version of Rails, you will need to add the ActionCable middleware to your application manually. Additionally, you will need to include the ActionCable JavaScript library in your client-side code.

Ilya Bodrov-KrukowskiIlya Bodrov-Krukowski
View Author

Ilya Bodrov is personal IT teacher, a senior engineer working at Campaigner LLC, author and teaching assistant at Sitepoint and lecturer at Moscow Aviations Institute. His primary programming languages are Ruby (with Rails) and JavaScript. He enjoys coding, teaching people and learning new things. Ilya also has some Cisco and Microsoft certificates and was working as a tutor in an educational center for a couple of years. In his free time he tweets, writes posts for his website, participates in OpenSource projects, goes in for sports and plays music.

actioncableGlennGRuby on Railsweb sockets
Share this article
Read Next
Get the freshest news and resources for developers, designers and digital creators in your inbox each week