Ruby
Article

Real-Time Messaging with Rails and ActionCable

By Ilya Bodrov-Krukowski

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!

  • Melanie

    Thanks so much Ilya. This is a really useful guide. I’m having trouble with the final step. I get an error with: Error connecting to Redis on 127.0.0.1:6379 (Errno::ECONNREFUSED). The error points to the online method. Any ideas? I’m not using sidekiq, which seems to be the source of issues for many who have the same problem.

    • Ilya Bodrov

      You are welcome :) Well, are you sure that’s the correct port? Maybe you’ve set some password as well? Sidekiq is not needed. And actually for development Redis is not needed as well. Or are you getting this when publishing to production? Anyways, you can contact me by e-mail to discuss it further.

      • Melanie

        hmmm – i’m having an issue with it in development mode. The problem highlights the online? method in my user.rb. The log shows: Redis::CannotConnectError – Error connecting to Redis on 127.0.0.1:6379 (Errno::ECONNREFUSED):

        redis (3.3.1) lib/redis/client.rb:345:in `rescue in establish_connection’

        redis (3.3.1) lib/redis/client.rb:331:in `establish_connection’

        redis (3.3.1) lib/redis/client.rb:101:in `block in connect’

        redis (3.3.1) lib/redis/client.rb:293:in `with_reconnect’

        redis (3.3.1) lib/redis/client.rb:100:in `connect’

        redis (3.3.1) lib/redis/client.rb:364:in `ensure_connected’

        redis (3.3.1) lib/redis/client.rb:221:in `block in process’

        redis (3.3.1) lib/redis/client.rb:306:in `logging’

        redis (3.3.1) lib/redis/client.rb:220:in `process’

        redis (3.3.1) lib/redis/client.rb:120:in `call’

        redis (3.3.1) lib/redis.rb:862:in `block in get’

        redis (3.3.1) lib/redis.rb:58:in `block in synchronize’

        • Ilya Bodrov

          Well, seems like you don’t have a server on this port at all. Can you show me your config/cable.yml file? Redis is not really needed for development

          • Melanie

            It has:

            development:

            adapter: async

            test:

            adapter: async

            production:

            adapter: redis

            # url: redis://localhost:6379/1

            url:

          • Matthew Stevenson

            I am having the same issue.

            Completed 500 Internal Server Error in 60ms (ActiveRecord: 2.1ms)
            ActionView::Template::Error (Error connecting to Redis on 127.0.0.1:6379 (Errno::ECONNREFUSED)):
            1:
            2: Chatting with
            3:
            4:
            5:

            app/models/user.rb:15:in `online?’
            app/helpers/application_helper.rb:14:in `online_status’
            app/views/conversations/_conversation.html.erb:2:in `_app_views_conversations__conversation_html_erb__2937973460693122620_30671900′
            app/views/conversations/index.html.erb:4:in `_app_views_conversations_index_html_erb___3019354782766930144_31183740′

            Is the problem that Redis is not active but the online? method calls for it even in development?

          • CF

            Matthew- did you manage to figure this out? I’m still stuck.

          • Matthew Stevenson

            Sorry. No. Gave up on it for now.

          • CF

            Ilya- it seems others are having the same issue here. Can you see what’s going wrong? Thanks!

          • Ilya Bodrov

            To be honest, its very hard to say. If you are 100% sure that Redis is installed, is available on that port and requires no additional auth, then unfortunately I can’t be much of a help. I mean, I am only basing my suggestions on guesses here…

  • Ilya Bodrov

    Thank you!

  • Matthew Stevenson

    As opposed to your previous ActionCable tutorial I find this one very confusing. In part b/c I think you imply it should build on that prior tutorial directly, but you have used different names for things (message vs personal_message) and in part b/c your git repo does not match at all the files in the text of the tutorial. Can you please update the git repo with the correct files so that we can use them to check for errors?

    It would also be extremely helpful if you were explicit about when new files/folders were being created.

    EDIT:
    Ooops. Just realized I was basing this off your “Create a Chat App with Rails 5 and Devise” instead of the “Build a Messaging System” tutorial.

  • Matthew Stevenson

    Further info: when trying to commit it is running — “commit”=>”Create Personal message”} instead of Create PersonalMessage but I can’t figure out why.

  • Andres Abed

    Does anybody could make this work? I’ve been trying during hours and I can’t show the messages on the receiver window.

Recommended
Sponsors
Get the latest in Ruby, once a week, for free.