Building a chat room using hotwire and ruby on rails
Back in the 90s/early 2000s, they used to say "every application grows until it includes email".
Today, that has become "every app grows until it includes messaging".
This isn't a surprise - nearly all software is about communication and making sure the right people know about the important stuff.
![](https://theartandscienceofruby.com/content/images/2024/02/https-3a-2f-2fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984-s3-amazonaws-com-2fpublic-2fimages-2fbf307f6f-e321-4d16-b6b6-eb2322603450_1024x1024-png.jpg)
The last few weeks, I've been working on what is essentially a Slack-clone in Collabor8online. As a collaboration tool (with document management and, very soon, full blown workflows and tasks), communicating with the other people involved on your project is at the heart of how things work. And thanks to Hotwire it was pretty easy to add in.
![](https://theartandscienceofruby.com/content/images/2024/02/https-3a-2f-2fbucketeer-e05bbc84-baa3-437e-9518-adb32be77984-s3-amazonaws-com-2fpublic-2fimages-2f1f7b690b-5f03-4ad1-b889-dd10c0988bd1_2258x2010.png)
There are lots of examples of writing a chat room using Hotwire, so I won't dive too deep into the details - just run through the overall design and some of the lessons I've learnt after using Turbo and Stimulus (the two main components of Hotwire) every day for the past 18 months.
Firstly, I'm big on keeping my controllers simple. Nothing makes my heart sink like reading a controller with lots of actions and pages and pages of code. Remember Rails and its "restful" routing style - keep each controller small and dedicated to one aspect of the task in question. In this case, we're only dealing with messages within a single folder (the core organisational concept in Collabor8Online).
So we'll be primarily dealing with two models - a Folder and a Message.
The route for the controller looks like this:
resources :folders do
# lots of other stuff related to folders
resources :messages, controller: "folder_messages"
# even more stuff related to folders
end
At present, the route declarations nested underneath folders take up 50 lines - it's the core unit of organisation so everything hangs beneath it - which is why so many routes are nested within folders. Within Collabor8, you can attach messages in a number of different places, so I've named the controller the FolderMessagesController
- to distinguish it from the DocumentMessagesController
or TaskMessagesController
. However, if you're nesting routes, I think naming your controllers in this way is good practice anyway as it keeps the structure of your application clear.
The main access point for messages within folders is the route GET /folders/FOLDERID/messages
- which maps to FolderMessagesController#index
. Which looks like this:
def index
authorize! :read, current_folder
render action: "index", locals: { folder: current_folder, messages: messages_for(date), date: date }
end
# Extract from app/controllers/folder_messages_controller.rb
I'm using CanCanCan for controlling permissions - so the first line is checking that the current user is allowed to view this folder. These permissions are defined in an "ability" file, so you're not peppering your code with authorisation logic.
The next line tells Rails to render the "index" template. I pass all the data that the template needs as explicit local variables. Most people define @variables
and then let Rails do its magic and the template just finds them. I don't like this - because when someone (probably you) return to that code in a year's time and want to change it, you'll accidentally forget one of the important @variables
(that maybe isn't used in index
but is used in one of its component partials) and you'll break the system. I prefer to be explicit - "in order to render folder_messages/index.html.erb
you need a folder, some messages and a date". The controller then has protected methods - current_folder
, messages_for(date)
and date
- which load up the relevant data.
The index template is pretty simple:
<%= render "folders/folder_layout", folder: folder do %>
<%= card_panel data: { controller: "filter" } do %>
<%= scroll_container do %>
<%= render "folder_messages/header", folder: folder, date: date %>
<%= grid_view id: dom_id(folder, :messages) do %>
<% messages.each do |message| %>
<%= render "folder_messages/message", message: message, folder: folder %>
<% end %>
<% end %>
<% end %>
<% if can? :comment_on, folder %>
<%= turbo_frame_tag dom_id(folder, :new_message), src: new_folder_message_path(folder) do %>
<%= spinner %>
<% end %>
<% end %>
<% end %>
<% end %>
# Extract from app/views/folder_messages/index.html.erb
I use a partial folders/folder_layout
to render the general folder "chrome" - the stuff, like tabs and general folder information, that every folder-related page has to show. Plus it does some important TurboStream stuff, that I'll talk about below. Then I have a load of helper methods defined to draw out standard components. card_panel
draws a Bootstrap card
, scroll_container
builds a div
with the overflow
properties set, grid_view
draws a Bootstrap grid
.
Within the grid, we then render our individual messages - each one drawn as a partial from app/views/folder_messages/_message.html.erb
. Again, I make explicit the exact variables that are needed to draw that partial - so I can confidently reuse the partial later (which we'll see later).
Finally, at the bottom of the page, we want a form so the user can add a new message. Firstly, we check their permissions using CanCanCan (the permission is called :comment_on
as the previous iteration of this functionality was called "commenting"). Then we use a turbo_frame
- and tell it to load itself from new_folder_message_path(folder)
using the src
parameter. That means that when the page is loaded, Turbo will make a request to GET /folder_messages/FOLDERID/messages/new
which will then call FolderMessagesController#new
and inject the results into our page at run-time. By doing this, we keep each distinct action self-contained - all we have to do is make sure our app/views/folder_messages/new.html.erb
template includes the line turbo_frame_tag dom_id(folder, :new_message)
- Turbo matches the element IDs of the two turbo-frames and replaces one with the other.
So that's the basics of the index page - we keep everything neat and tidy, passing in variables to make it explicit what data is needed for the templates to work and trying to make each individual action completely self-contained.
Once the form has rendered, our user types in their message and hits the "Send" button. This then does a POST /folder_messages/FOLDERID/messages
, calling FolderMessagesController#create
. This is where things get interesting.
create
looks something like this:
def create
authorize! :comment_on, current_folder
current_folder.messages.create!(message_params).tap do |message|
# Other C8O-related message-linking stuff goes here
respond_to do |format|
format.html { redirect_to folder_messages_path(current_folder) }
format.turbo_stream { render action: "created", locals: { folder: current_folder, message: new_message } }
end
end
rescue ActiveRecord::RecordInvalid => ri
render action: "new", locals: { folder: current_folder, message: ri.record }
end
# Idealised code for app/controllers/folder_messages_controller.rb
(Actually, in Collabor8Online it's a bit more complex - I call current_user.adds :message, to: current_folder
which makes sure that Collabor8Online tracks everything the user does - if you want to know why, check out "Dealing with support requests without getting overwhelmed")
Firstly, we check permissions, just in case someone has hacked the form to point to the wrong place. Then we create the message object - using a message_params
method that uses Rails' strong-parameters to prevent tampering.
def message_params
params.require(:message).permit(:contents, :file, document_ids: [], task_ids: [], recipient_ids: [], recipient_group_ids: [])
end
# Extract from app/controllers/folder_messages_controller.rb
Again, C8O is quite complex, so messages can be linked to a number of other models - hence the _ids: []
parameters which specify which documents, tasks, recipients and recipient-groups the message is relating to.
You'll see that in the create
method, I use the tap
method - this is a simple Ruby method that just launches a block passing in the given object. This is a stylistic preference of mine - I like the fact that everything regarding the newly created message is then contained within a scoped block of code, but you could just as easily add in a local variable and write:
def create
authorize! :comment_on, current_folder
message = current_folder.messages.create!(message_params)
# Other C8O-related message-linking stuff goes here
respond_to do |format|
format.html { redirect_to folder_messages_path(current_folder) }
format.turbo_stream { render action: "created", locals: { folder: current_folder, message: new_message } }
end
rescue ActiveRecord::RecordInvalid => ri
render action: "new", locals: { folder: current_folder, message: ri.record }
end
# Not-so idealised code for app/controllers/folder_messages_controller.rb
For errors, I've added an exception handler - if the #create!
call raises a ActiveRecord::RecordInvalid
then we redraw the new
template - passing in the partially created message. The exception includes the model (as ri.record
), so we can redraw the form and show any errors). Turbo will inject this redrawn form into the correct place in our page, using the magic of turbo-frames.
The real turbo-specific stuff then happens afterwards.
Firstly, we check the format
of the incoming request. If it's a normal HTML request (from someone who has Javascript switched off perhaps), then we redirect to the index page, and redraw everything. But if it's a Turbo-Stream request then we do something different. Turbo-Stream requests happen when you draw a form inside a turbo-frame (if you remember, we used a turbo-frame to dynamically inject our form into the index page). Turbo intercepts the form submission, marks it as a turbo-stream, and then our controller, instead of redirecting, renders app/views/folder_messages/created.turbo_stream.erb
. This turbo-stream-specific template looks like this.
<%= turbo_stream.update dom_id(current_folder, :new_message), partial: "folder_messages/form", locals: { folder: folder, message: message } %>
# app/views/folder_messages/created.turbo_stream.erb
We tell Turbo to find the current folder's new_message
element - which if you look back at index.html.erb
is the turbo-frame that holds our form - and tell it to redraw the form. This clears out all the data that the user had entered into the form and loads up a brand new one.
But we also need our new message object to appear in the list of messages. This is where turbo-stream gets clever and the interactive stuff happens.
In app/models/message.rb
we have an after_commit
handler defined.
It looks like this:
after_commit :broadcast_creation, on: :create
def broadcast_creation
broadcast_append_later_to folder, target: dom_id(folder, :messages), partial: "folder_messages/message", locals: { message: self, folder: folder }
# Other C8O-specific broadcasts
end
# Extract from app/models/message.rb
After the message object is created by our controller and the database transaction has committed (so we can be sure everything completed OK and the database record is unlocked), we tell turbo-stream to broadcast
a message to anyone that is listening to the given folder. The Rails implementation of TurboStream uses web-sockets and Redis to create an ongoing connection between anyone the browser that is viewing our folder page and the server. In my case, this is defined in app/views/folders/folder_layout
which is used to draw all my folder-related pages - but in most apps, you would just add this line to your index.html.erb
:
<%= turbo_stream_from folder %>
This establishes the web-socket connection and says "I am interested in updates to this folder". The message model's broadcast_creation
method then says "anyone who is interested in updates to this folder - I've got something for you" by calling broadcast_append_later_to folder
. The broadcast says find the folder's dom_id(folder, :messages)
element (which in our index.html.erb
was the grid_view
component) and append
this partial to it.
Quick aside: Rails includes a broadcast
message that does all of this automatically, so you don't need to add in your own after_commit
handler. But I've found that it's not flexible enough for me. In Collabor8Online, the message, because it's linked to lots of different objects (and therefore to lots of different views) needs to broadcast several updates - one to the folder, one to any related documents, one to any related tasks and so on - and each of these needs to draw a different partial. Which is why I've ignored the in-built Rails routine and built my own.
The outcome of this broadcast_append_later_to
is, not only will the person who posted the message have their page updated with the new message (appended on to the list of messages in their browser), but anyone else who happens to have the messages page for that folder open, will also see the new message. It just gets appended to their own page, at the end of the grid-view component.
So there you have it. We've built a system that dynamically injects contents into a static web-page and then uses web-sockets to respond to changes to that page and automatically show them. All without writing a single line of Javascript.
Pretty amazing eh?
EDIT: I noticed I had the element-id as “new_message”, not “dom_id(folder, :messages)” when the message is broadcast. I’ve corrected it.