visit
This tutorial is a distillation of the type of information you'll find in one of the two books I am writing, Realtime Rails.
First, we can create a new Rails app from the main
branch to get all of the Rails 8 goodies (though, soon, this will be available through alpha/beta gem releases).
rails new --main blabber
rails g model user email:string password_digest:string
rails g model post user:references message:text
These generators will create two models and migrations for the Users
and Posts
. Let's add a few lines to the User model for validation and authentication:
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
has_secure_password
end
The significant addition here is the has_secure_password
line which includes code to set and authenticate passwords via bcrypt
. Now, on the Post
model, the user reference should exist, and we can add a quick validation:
class Post < ApplicationRecord
belongs_to :user
validates :message, length: { minimum: 1, maximum: 280 }
end
Finally, here is a quick bin/rails db:migrate
to get those tables created.
Rails.application.routes.draw do
get "/login", to: "sessions#new", as: "login"
post "/sessions", to: "sessions#create"
get "/logout", to: "sessions#destroy", as: "logout"
resources :posts, only: %i[index create]
root "posts#index"
end
These will be all the routes we need for the whole app: the first three for sessions, a resource helper for posts
, and setting the root route.
Now we can add the SessionsController
at app/controllers/sessions_controller.rb
:
class SessionsController < ApplicationController
def create
@user = User.find_by(email: params.dig(:user, :email))
if @user && @user.authenticate(params.dig(:user, :password))
sign_in(@user)
redirect_to root_path, notice: "You have successfully logged in!"
else
flash.now[:alert] = "There was a problem logging in."
render :new, status: 422
end
end
def destroy
sign_out
redirect_to login_path, notice: "You have successfully logged out!"
end
private
def sign_in(user)
session[:user_id] = user.id
end
def sign_out
session.delete(:user_id)
end
end
Pretty simple here. If we find a user and that user authenticates with the included authenticate
method from the User
model, we will call sign_in
, which sets a session for the user. Real quick, we can add a helper method in ApplicationController
to use current_user
throughout controllers and views through the app:
def current_user
@current_user = User.find_by(id: session[:user_id]) if session[:user_id]
end
helper_method :current_user
Next, we can quickly add a PostsController
that resembles much of what we used in the last tutorial, aside from now having a user_id:
class PostsController < ApplicationController
before_action :require_login
def index
@posts = Post.all.order(created_at: :desc)
@post = Post.new
end
def create
@post = Post.new(post_params.merge(user_id: current_user.id))
respond_to do |format|
if @post.save
redirect_to posts_path
else
render :index
end
end
end
private
def post_params
params.require(:post).permit(:message)
end
end
Both the index
and create
actions are reasonably straightforward Rails-like methods for listing and creating Posts
. The last part of the authentication puzzle here would be to add the before_action:require_login
method to the ApplicationController and use the Rails Console to create two users (thus, we skipped building a signup interface).
# app/controllers/application_controller.rb
## redirect to login, if there is no current_user, meaning there is no authenticated user session
def require_login
redirect_to login_path, alert: 'You must be logged in to access this page.' if current_user.nil?
end
Lastly, we can go ahead and knock out a few views to get all of the authentication, Bootstrap, and basic Post
markup out of the way
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Blabber" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<% if current_user %>
<meta name="current-user-id" content="<%= current_user.id %>">
<% end %>
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<link href="//cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" type="text/css" />
<script src="//cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<%# Includes all stylesheet files in app/views/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
</head>
<body class="">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">Blabber</a>
<span class="navbar-text">
<% if current_user %>
<%= current_user.email %>
<%= link_to "Logout", logout_path, method: :delete, class: "btn btn-outline-danger" %>
<% else %>
<%= link_to "Login", login_path, class: "btn btn-outline-primary" %>
<% end %>
</span>
</div>
</nav>
<% if flash[:notice] %>
<div class="alert alert-success" role="alert">
<%= flash[:notice] %>
</div>
<% end %>
<% if flash[:alert] %>
<div class="alert alert-danger" role="alert">
<%= flash[:alert] %>
</div>
<% end %>
<div class="container mt-5">
<%= yield %>
</div>
</body>
</html>
<% if current_user %>
<meta name="current-user-id" content="<%= current_user.id %>">
<% end %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
Ok, three Post
templates, and we're done with setup.
To display the list of posts, you can create an index.html.erb
file in the app/views/posts
directory. Here's an example of how the index view could look like:
<div class="container">
<h1>Blabber</h1>
<h4>A Rails, Hotwire demo</h4>
<%= render partial: 'form' %>
<%= render @posts %>
</div>
This code uses the _post.html.erb
partial to render each post in the @posts
collection.
<div class="card mb-2" id="<%= dom_id(post) %>">
<div class="card-body">
<h5 class="card-title text-muted">
<small class="float-right">
Posted <%= time_ago_in_words(post.created_at) %> ago
</small>
<%= post.user.email %>
</h5>
<div class="card-text lead mb-2"><%= post.message %></div>
</div>
</div>
...and then the _form.html.erb
<%= form_with model: @post, id: dom_id(@post), html: {class: 'my-4' } do |f| %>
<% if @post.errors.any? %>
<div id="error_explanation">
<h2>
<%= pluralize(@post.errors.count, "error") %> prohibited this post from
being saved:
</h2>
<ul>
<% @post.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group" data-controller="typing">
<%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control',rows: 3 %>
</div>
<div class="actions my-2">
<%= f.submit class: "btn btn-primary" %>
</div>
<% end %>
This code uses the form_with
helper to create a form for the @post
object. The text_area
helper creates a textarea input for the post's message
attribute.
If you followed along with the previous tutorial, there are only a couple of lines to add to get morphed updates out of the box! Earlier, we pre-empted the code to add with the line to the partial. To finish this off, we will add a line to the Post
model and two through the Post
views.
class Post < ApplicationRecord
belongs_to :user
broadcasts_refreshes ## New line
validates :message, length: { minimum: 1, maximum: 280 }
end
<div class="container">
<h1>Blabber</h1>
<h4>A Rails, Hotwire demo</h4>
<%= render partial: 'form' %>
<%= turbo_stream_from 'posts' %> <!-- This is that streams from the post collection -->
<%= render @posts %>
</div>
<%= turbo_stream_from post %> <!-- This is that streams from each post -->
<div class="card mb-2" id="<%= dom_id(post) %>">
<div class="card-body">
<h5 class="card-title text-muted">
<small class="float-right">
Posted <%= time_ago_in_words(post.created_at) %> ago
</small>
<%= post.user.email %>
</h5>
<div class="card-text lead mb-2"><%= post.message %></div>
</div>
</div>
In our case, we will use an indicator next to the users' email addresses in the Post
card. This indicator will be a simple green dot if the user is online and a red dot if the user is offline.
The first step here will be to include icons to use a circle and then color it based on the user's presence. We can easily include Bootstrap icons by adding the following line to the application.css
file:
@import url("//cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css");
Now, we can generate a quick migration to add an online
boolean to the User
model:
rails g migration add_online_to_users online:boolean
rails db:migrate
Next, we can add two simple methods to the User
to adjust a user's presence status using the online
boolean. We will also add a method to broadcast changes to the presence channel after a user's status changes and check for the online_previously_changed?
(a built-in ActiveRecord field status method) in that method used by the callback:
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
has_secure_password
after_commit :broadcast_changes
def broadcast_changes
if online_previously_changed?
Turbo::StreamsChannel.broadcast_refresh_to(:presence)
end
end
def came_online
update!(online: true) unless online?
end
def went_offline
update!(online: false) if online?
end
end
Finally, we can add a line to the application.html.erb
to listen for changes to the presence channel and then update the _post
partial to include the presence indicator:
...
<%= turbo_stream_from :presence, channel: PresenceChannel if current_user %>
...
The turbo_stream_from:presence
will use the turbo_stream
helpers from the turbo-rails
gem to handle the automatic subscription to the presence channel. The PresenceChannel
argument in this method sets which Channel will be specifically used in the streaming process.
<%= turbo_stream_from post %>
<div class="card mb-2" id="<%= dom_id(post) %>">
<div class="card-body">
<h5 class="card-title text-muted">
<small class="float-right">
Posted <%= time_ago_in_words(post.created_at) %> ago
</small>
<i class="bi bi-circle-fill align-middle text <%= post.user.online? ? 'text-success' : 'text-danger'%>" style="font-size: .5rem"></i>
<%= post.user.email %>
</h5>
<div class="card-text lead mb-2"><%= post.message %></div>
</div>
</div>
This uses the bi-circle-fill
icon from Bootstrap and changes its color based on the user's presence status.
Lastly, we can create the PresenceChannel
in the app/channels
directory:
class PresenceChannel < ActionCable::Channel::Base
extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
include Turbo::Streams::StreamName::ClassMethods
def subscribed
stream_from "presence"
current_user.came_online
end
def unsubscribed
current_user.went_offline
end
end
This channel extends and includes some Turbo::Streams
modules, so that it can broadcast to the presence channel and handle the stream name. The subscribed
method will stream from the presence
channel and then call the came_online
method on the current user. The unsubscribed
method will call the went_offline
method on the current user. unsubscribe
is a method that is called when the client disconnects from the channel by closing the window or logging out.
Now, if you tried to use the browser, you would see errors from your channel in the logs, as we have to set up one last piece, the current_user
in the Connection
class. This class is much like an ApplicationController
but for the ActionCable connections. We can add a method to the Connection
class to set the current_user
:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if current_user = User.find_by(id: @request.session[:user_id])
current_user
else
reject_unauthorized_connection
end
end
end
end
This module will set the current_user
to the user found by the session[:user_id]
, which was set during the login, or reject the connection if the user is not found.
Note: This presence could become noisy with a lot of users, specifically because it will be writing a lot to the
users
table. This example was built for simplicity and not scale. With enough traffic on a real application, you may want to consider a second database or technology to store the data (e.g., Redis).
bin/rails generate channel typing
bin/importmap pin lodash.debounce
Inside the TypingChannel
, we can add a few methods to handle the typing and typing stopped events from the "Cable" side:
class TypingChannel < ApplicationCable::Channel
def subscribed
stream_from "typing"
end
def typing
ActionCable.server.broadcast("typing", { action: 'typing', uid: current_user.id.to_s, user_email: current_user.email } )
end
def typing_stopped
ActionCable.server.broadcast("typing", { action: 'typing_stopped', uid: current_user.id.to_s, user_email: current_user.email } )
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end
This channel will stream from the typing
channel and then broadcast the typing
and typing_stopped
events to the channel. The typing
event will include the current user's uid
and user_email
. This information is needed to do two things. One, the user_id
will be used to filter out the currently typing user from seeing the typing indicator. Second, the email address is used to display the message so as not to send the data to the front end differently. The unsubscribed
method was added to the channel by default during the generation of the channel but can be removed as it is not needed.
With that out of the way, the next place to add some code is the markup by adjusting and adding some StimulusJS to the Posts
form:
<%= form_with model: @post, id: dom_id(@post), html: {class: 'my-4' } do |f| %>
<% if @post.errors.any? %>
<div id="error_explanation">
<h2>
<%= pluralize(@post.errors.count, "error") %> prohibited this post from
being saved:
</h2>
<ul>
<% @post.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<div class="form-group" data-controller="typing">
<%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control', rows: 3 %>
<%= f.text_area :message, placeholder: 'Enter your blab', class: 'form-control', data: { turbo_permanent: true, action: 'keydown->typing#typing keyup->typing#typingStopped' }, rows: 3 %>
<div id="typingHint" class="form-text" data-typing-target="display"></div>
</div>
<div class="actions my-2">
<%= f.submit class: "btn btn-primary" %>
</div>
<% end %>
We replace two lines, adding StimulusJS markup of <div class="form-group"
and action: 'keydown->typing#typing keyup->typing#typingStopped'
for the StimulusJS controller to listen for keydown and keyup events. We add turbo_permanent: true
so that the turbo morph updates will not clear out the form's current state (i.e., text that has been typed or bound javascript that StimulusJS is handling). Finally, the data-typing-target="display"
will be used to update the message in the view when someone is typing.
Finally, we can add the StimulusJS controller to the app/javascript/controllers
directory:
import { Controller } from "@hotwired/stimulus";
import consumer from "channels/consumer";
import debounce from "lodash.debounce";
export default class extends Controller {
static targets = ["display"];
initialize() {
this.typingStopped = debounce(this.typingStopped, 1000);
}
connect() {
this.subscription = consumer.subscriptions.create("TypingChannel", {
received: (data) => {
if (data.uid != this.userId) {
this.displayTarget.innerHTML =
data.action == "typing" ? `${data.user_email} is typing...` : "";
}
},
});
}
typing(_event) {
this.subscription.perform("typing");
}
typingStopped(_event) {
this.subscription.perform("typing_stopped");
}
get userId() {
return document.head.querySelector("meta[name=current-user-id]")?.content;
}
}
First, we create a getter for the userId
, which will get the current-user-id
from the meta tag in the head of the document, which was added way earlier when we first modified the application.html.erb
. This will be used later in the event filtering process.
Then, in the connect
method, we create a subscription to the TypingChannel
and then listen for the received
event. The received
happens when the broadcast is sent from the TypingChannel
. We'll come back to the if
statement in a moment.
In the view, we used keydown->typing#typing
to call the typing
function when that event happens in the browser. The typing
function then calls the perform
method on the subscription to send the typing
event to the TypingChannel
. This event triggers the typing
method in the TypingChannel
to broadcast the typing
event to the typing
channel, which is handled by the received
handler.
Additionally, we used keyup->typing#typingStopped
in the view to call the typingStopped
function. Here, we debounce the typingStopped
function to only call it once every 1000 milliseconds or one second. This function will prevent the typingStopped
method from being called too frequently and basically allow the controller to wait for the typing to be stopped. Using a debounce function is a common pattern in JavaScript to prevent a function from being called too frequently. We are using debounce
from the lodash
library to do this and was added in the import statement at the top of the file. The trick to getting the StimulusJS-based keydown event to use the lodash debounce is to set the controller's typingStopped
function in the initializer to itself inside the debounce function call.
The typingStopped
function then calls the perform
method on the subscription to broadcast the typing_stopped
event to the TypingChannel
much like the same round trip for typing
.
To get the controller ready to display text, we added data-typing-target="display"
in the view to display the typing indicator and then added a static targets = ["display"];
to allow this controller to interact with that element.
Now, finally getting back to the code inside the received
handler, we check if the uid
in the data object does not equal the userId
from the meta tag. If the uid
is not equal to the userId
, then we update the displayTarget
with the user's email and a message that they are typing. Inside the innerHTML
setter, we use a ternary operator to check if the action
in the data object equals typing
. If it is, we set the innerHTML
to the user's email and a message they are typing. If the action
is not equal to typing
, then we set the innerHTML
to an empty string which clears the message in the browser.