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 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.