June 17, 2022
•Last updated November 5, 2023
Let’s Build: With Ruby on Rails 7 - Twitter clone
If you have followed me for some time you might remember a series I did on Ruby on Rails titled “Let’s Build”. These guides were a “learn in public” exercise I tasked myself with that ultimately resonated with a number of folks.
This specific mini-series is going to focus on redoing an older “Let’s Build” where I took on building a Twitter clone.
Since the advent of Rails 7, the way you might approach new problems when building software has changed a great deal. I wanted to take the opportunity to create some fresh content and show you how to leverage some new features of Ruby on Rails along the way.
Video Version
Part 1
Part 2
Part 3
Part 4
Part 5
Part 6
Written Version
Let’s dig in:
$ rails new tweeter -m kickoff_tailwind/template.rb -j esbuild
I’ll be leveraging my kickoff template (simply to save some configuration time), ES Build and Tailwind CSS for this tutorial.
Check out the template for all the gems I leverage by default. We’ll make use of a few if not all in this guide. Devise (an authentication gem) will come pre-configured with the template. We’ll use this for user accounts.
Within my template you’ll want to remove the javascript include tag that comes stock with my template. If you go to boot up the server running bin/dev
you’ll see an error message display until it’s removed. The line you’re looking for is below:
<%= javascript_importmap_tags %> <-- Remove this
Create Tweets
Let's start with the obvious. Creating a Tweet comes with some basic data we need to capture.
I'll start by generating a new Tweet
model.
Instead of using a scaffold approach I'll do things in a more manual way.
I find this helps you learn more actively as you type out code rather than letting Rails add it for you. As you scale as a developer and understand the inner-workings of the framework, scaffolds become your friend.
rails g model Tweet body user:references
rails db:migrate
Before I forget I'll add the newly created association to the User
model. A user can have many tweets:
class User < ApplicationRecord
# ...
has_many :tweets
# ...
end
We can generate a controller all the same:
rails g controller Tweets index create destroy --skip-views
In true Twitter fashion we'll forego having an editing option. I figure it's closer to a real "clone".
Because we aren't allowing editing, we can skip both the edit and update actions typically supplied in the resources
method.
While we are in the routes file, I made the root path the tweets index page.
# config/routes.rb
require 'sidekiq/web'
Rails.application.routes.draw do
authenticate :user, lambda { |u| u.admin? } do
mount Sidekiq::Web => '/sidekiq'
end
devise_for :users
resources :tweets, except: [:edit, :update]
root to: 'tweets#index'
end
Adding the layout
Let's make this thing resemble Twitter a bit. Here's some basic Tailwind markup I added to my application.html.erb
inside views/layouts
. It features a three column layout where the list of tweets will fit within the model column.
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html class="h-full antialiased">
<%= render "shared/head" %>
<body class="font-sans font-normal leading-normal text-gray-800 bg-white flex flex-col min-h-screen ">
<header>
<%= render "shared/flash_notice" %>
</header>
<main class="flex-grow container mx-auto px-4">
<div class="grid lg:grid-cols-12 grid-cols-1 h-screen pr-6">
<div class="lg:col-span-2 pt-6 pr-6">
<%= link_to root_path, class:"link text-xl tracking-tight font-black" do %>
<span class="text-sky-500">Tweeter</span>
<% end %>
<ul class="mt-6">
<li>
<%= link_to root_path, class: "rounded-full px-4 -ml-4 inline-flex items-center py-3 hover:bg-neutral-50 w-full text-lg transition ease-in-out duration-500" do %>
<svg class="fill-current w-6 h-6 mr-4" viewBox="0 0 24 24" aria-hidden="true" class="r-18jsvk2 r-4qtqp9 r-yyyyoo r-lwhw9o r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"><g><path d="M22.58 7.35L12.475 1.897c-.297-.16-.654-.16-.95 0L1.425 7.35c-.486.264-.667.87-.405 1.356.18.335.525.525.88.525.16 0 .324-.038.475-.12l.734-.396 1.59 11.25c.216 1.214 1.31 2.062 2.66 2.062h9.282c1.35 0 2.444-.848 2.662-2.088l1.588-11.225.737.398c.485.263 1.092.082 1.354-.404.263-.486.08-1.093-.404-1.355zM12 15.435c-1.795 0-3.25-1.455-3.25-3.25s1.455-3.25 3.25-3.25 3.25 1.455 3.25 3.25-1.455 3.25-3.25 3.25z"></path></g></svg>
<span>Home</span>
<% end %>
</li>
</ul>
</div>
<div class="lg:col-span-6 border-x">
<%= content_for?(:content) ? yield(:content) : yield %>
</div>
<div class="lg:col-span-4 pl-6 pt-6">
<div class="bg-gray-50 w-full min-h-[150px] rounded-lg p-6">Sidebar stuff</div>
</div>
</div>
</main>
</body>
</html>
Tweets index
The tweets index (or root path) is the holy grail of the app. Here we'll be able to add a tweet, read other tweets and comment on tweets. The general idea is to not have to visit other pages to do those things. Thanks to Rails 7 and the new hotwire.dev framework we should be in great shape to build those types of features.
I added a new form to the index.html.erb
inside the app/views/tweets
folder. It's designed to look like what Twitter has today for their UI design. It could certainly be improved but to me it's familiar.
<!-- app/views/tweets/index.html.erb-->
<%= form_with model: @tweet do |form| %>
<% if form.object.errors.any? %>
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3 mx-3">
<h2><%= pluralize(form.object.errors.count, "error") %> prohibited this Tweet from being saved:</h2>
<ul>
<% form.object.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form.text_area :body, class: "border-b border-t-0 border-x-0 mb-6 px-6 pt-6 block w-full border-gray-200 focus:shadow-none focus:outline-none focus:ring-transparent focus:border-gray-300 resize-none min-h-[180px] text-lg", placeholder: "What's on your mind?" %>
<div class="flex justify-end px-6 -mt-20">
<%= form.submit "Tweet", class: "px-6 py-2 bg-sky-400 text-white font-semibold rounded-full text-center cursor-pointer inline-block hover:bg-sky-500 transition ease-in-out duration-300" %>
</div>
<% end %>
The form features some error handling should there be an issue. We'll require the :body
field to not be empty and have a certain number of characters coming up when we focus on validations.
With this UI in place that gives us a good foundation to work with. Let's move to the controller logic as well as some turbo logic that's new in Rails 7.
Creating a Tweet
Traditionally Rails apps would assume there was a form on a route like /tweets/new
but in this case I want a Tweet to be able to be authored on the index (or root_path). This allows for a better authoring experience for signed in users and mimics what Twitter does today.
We already have our form ready to go but at the moment the instance variable we used @tweet
doesn't have any kind of value.
We'll need to instantiate an instance of a Tweet with Ruby on the index
action in the controller so when we pass data through to the back end Rails will trigger a POST
request to CREATE
a record in the database. While doing this my goal is to render the new tweet just below the form in real-time using turbo frames and turbo streams.
Let's address the controller logic first
# app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
def index
@tweet = Tweet.new
@tweets = Tweet.all.order(created_at: :asc)
end
def create
end
def show
end
def destroy
end
end
Inside the index
action I added a singular @tweet
instance variable that initializes a new Tweet. I also added a collection of all tweets ordered by when they were created and in an ascending fashion.
The form markup we added earlier makes use of the new @tweet
instance variable.
When you click the submit button on the form (labeled "Tweet") the framework will make a POST request to the backend which matches up with the create
action inside the TweetsController
.
Inside the create action we can check if the Tweet saved to the database or not and then respond in whatever way we prefer. Below is the updated logic using a turbo_stream response if the tweet successfully saves.
# app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
def index
@tweet = Tweet.new
@tweets = Tweet.all.order(created_at: :desc)
end
def create
@tweet = Tweet.new(tweet_params)
respond_to do |format|
if @tweet.save
format.turbo_stream
else
format.html do
flash[:tweet_errors] = @tweet.errors.full_messages
redirect_to root_path
end
end
end
def show
end
def destroy
end
private
def tweet_params
params.require(:tweet).permit(:body)
end
end
A few things to note:
- We permitted only the
:body
parameter from being white-listed into the database. Rails will ignore any other fields since they are not permitted. - We added a custom flash[:tweet_errors] to highjack the default error rendering pattern when we talk about validations coming up. This is necessary because our form actually lives on the
index
action as opposed to the defaultnew
action. Going outside of normal conventions means we need to code outside normal conventions as well. - Declaring a simple
format.turbo_stream
response type tells rails to reach inside theapp/views/tweets
directory for acreate.turbo_stream.erb
file. You can optionally do all the logic in the controller inline but I find that messy and prefer to extract it to a file on its own. We'll cover what is inside that file in a bit.
Bug fixes
At this stage, I noticed the form doesn't clear contents once submitted. We could attack this problem in a couple ways but I'll reach for a tiny bit of JavaScript using the Stimulus.js framework that now ships with Rails.
Create a new controller inside app/javascript/controllers
// app/javascript/controllers/reset_form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
reset() {
this.element.reset()
}
}
Inside it I have a simple method called reset()
that gets triggered upon form submission on the Tweet form. this.element
refers to the element he data-controller
attribute lives on.
<!-- app/views/tweets/_form.html.erb -->
<%= form_with model: @tweet, data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset"} do |form| %>
<!-- fields go here -->
<% end %>
By passing the data attributes reset-form
and action: 'turbo:submit-end->reset-form#reset'
this will empty the form after a new tweet gets created.
Before it can work we need to initialize it inside app/javascript/controllers/index.js
// app/javascript/controllers/index.js
import { application } from "./application"
// Add the two lines below
import ResetFormController from "./reset_form_controller"
application.register("reset-form", ResetFormController)
Saving user data
Before we go much farther we've reached an important issue that must be addressed. In order to create a tweet, a user ID must be referenced to be saved. You may have noticed already but we are not logged in nor have we created an account so let's do some house keeping before we press on.
# app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
before_action :authenticate_user! # add this line
# more stuffs below ...
end
This locks down our current Tweet routing but introduces some gnarly UI bugs. To fix this I'll add an entirely new layout called devise
in app/views/layouts
.
touch app/views/layouts/devise.html.erb
Then within our application controller add some conditional logic using a built in helper method the Devise gem provides us to render the appropriate layout per request.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
layout :layout_by_resource
protect_from_forgery with: :exception
before_action :configure_permitted_parameters, if: :devise_controller?
private
def layout_by_resource
if devise_controller?
"devise"
else
"application"
end
end
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
devise_parameter_sanitizer.permit(:account_update, keys: [:name])
end
end
Above is my updated ApplicationController
file. There is default logic here from the kickoff_tailwind template. We'll actually make use of the name
field coming up.
I added a line layout :layout_by_resource
which I stole from the Devise documentation. Within the private class space a new method is born that renders the appropriate layout.
Our devise.html.erb
layout needs some of the content from our main layout so I'll copy that over.
<!-- app/views/layouts/devise.html.erb-->
<!DOCTYPE html>
<html class="h-full antialiased">
<%= render "shared/head" %>
<body class="font-sans font-normal leading-normal text-gray-800 bg-white flex flex-col min-h-screen ">
<header>
<%= render "shared/flash_notice" %>
</header>
<main>
<%= content_for?(:content) ? yield(:content) : yield %>
</main>
</body>
</html>
Now, with this in place we'll be able to have a custom layout dedicated to devise-specific views!
Add username field
Much like Twitter I'd like each user to have a unique handle associated with their account. The template I supplied is already extended to use a :name
field but I want to add a :username
field as well. To accomplish this we'll need to start with a migration. This will add a new string column called username
to the users table.
rails g migration add_username_to_users username:string:uniq
Note the username:string:uniq
declaration above. This means each username in the app needs to be unique down to the database level. So even if we don't supply front-end validations the database will enforce this additional check.
Here's the migration
# db/migrate/SOMETIMESTAMP_add_username_to_users.rb
class AddUsernameToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :username, :string
add_index :users, :username, unique: true
end
end
Migrate the changes
rails db:migrate
Next let's update the registration form within app/views/devise/registrations/new
to include the username
field.
<!-- app/views/devise/registrations/new.html.erb-->
<%= render layout: "auth_layout", locals: { title: "Sign up" } do %>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), data: { turbo: false }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<!-- add the following -->
<div class="mb-6">
<%= f.label :username, class: label_class %>
<%= f.text_field :username, class: input_class %>
</div>
<!-- more code -->
<% end %>
<% end %>
Then also update the registrations edit form to accomodate the same field
<!-- app/views/devise/registrations/edit.erb -->
<%= render layout: "auth_layout", locals: { title: "Edit account" } do %>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }, data: { turbo: false }) do |f| %>
<!-- more fields -->
<div class="mb-6">
<%= f.label :username, class: label_class %>
<%= f.text_field :username, class: input_class %>
</div>
<!-- more fields -->
<% end %>
The application_controller.rb
file now needs to know about the new username
field so we can permit that data to insert inside the database.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# more code ...
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :username]) # add :username
devise_parameter_sanitizer.permit(:account_update, keys: [:name, :username]) # add :username
end
end
Finally, we can add some additional validations on the User
model to make sure the username value is indeed unique. While the database will be extra security to enforce this, there's nothing wrong with adding an additional layer on the model level.
# app/models/user.rb
class User < ApplicationRecord
# more code here...
validates_uniqueness_of :username
end
Now you can go and make a new account if you haven't already.
Enhancing the TweetsController
Being logged in now gives us an instance of the current_user
thanks to the Devise gem. We'll need to extract the ID to save it along with the Tweet.
# app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
before_action :authenticate_user! # add this line
# more stuffs below ...
def create
@tweet = current_user.tweets.new(tweet_params) # swap for this line
respond_to do |format|
if @tweet.save
format.turbo_stream
else
format.html do
flash[:tweet_errors] = @tweet.errors.full_messages
redirect_to root_path
end
end
end
end
end
If you remember back when we generated the Tweet
model we added a line user:references
. Behind the scenes this sets up a new relationship between a Tweet and a User model. Technically speaking, it means we added a user_id
column to the Tweet
table.
Doing this allows us to reference the user who created the Tweet directly. This is great for displaying each user's profile image, name, etc...
Validations
To keep users from creating blank tweets let's add some basic validation for now. We can also limit the number of characters like Twitter does today. This all happens in the Tweet model found in app/models/tweet.rb
class Tweet < ApplicationRecord
belongs_to :user
validates :body, length: { maximum: 240 }, allow_blank: false
end
Add the validation above to both not accept blank values and set a maximum character count.
Now clicking the submit button should display some error messages within the form itself.
You might see that it gets duplicated. This is because of a global flash
response partial I added to my Kickoff Tailwind template. Let's edit that file:
<!-- app/views/shared/_flash_notice.html.erb-->
<% unless flash[:tweet_errors] %>
<% flash.each do |type, message| %>
<div class="<%= flash_classes(type) %>">
<%= message %>
</div>
<% end %>
<% end %>
I added a new unless
statement that will conditionally render all flash messages unless it's the newer :tweet_errors
one we introduced. Unfortunately the pattern we need isn't the typical convention of Rails. Luckily there's a decently simple work around!
Turbo-fied tweets
There are a few features I have in mind for displaying tweets as they get created:
- Display them as they get created in real time on the tweets#index page
- Don't require any page refreshing to create or see newly created tweets from other users
- Render tweets in a sequential order based on the rate they are authored.
With Turbo in our arsenal we can make use of both turbo_frames and turbo_streams to do all the of the above. We're already set up for success with the create.turbo_stream.erb
file. Let's start there.
<!-- app/views/tweets/create.turbo_stream.erb-->
<%= turbo_stream.prepend "tweets" do %>
<%= render "tweet", tweet: @tweet %>
<% end %>
Inside the file I created a turbo_stream block that makes use of a prepend
method. If you think about a series of items, prepend will mean it adds it to the start of that list of items. So in theory when we author a Tweet, the new tweet will be added to the list in front of all the rest.
For comparison there is also a append
method which does the inverse.
There are a number of methods you can make use of within the turbo framework. Many of these mimic JavaScript-style patterns but extract the JavaScript away so you needn't write any.
The turbo_stream.prepend
method targets a unique identifier called tweets
. This is assuming there is an HTML
element on that index view with and id
of tweets
that is ready and waiting for action.
On my index view I have the following code for now:
<!-- app/views/index.html.erb
<div class="mb-8">
<%= render "form" %>
</div>
<ul id="tweets" class="divide-y">
<%= render @tweets %>
</ul>
We render the form partial created earlier and then also have a div
with the id of tweets
below it. Within the div
is a short hand way to render the entire list of Tweets which get initialized from the TweetsController#index
action.
The render @tweets
line assumes there is a partial in the app/views/tweets
folder called _tweet.html.erb
. Inside that file will be the contents we render for each individual tweet. Right now I just have the body and the user's name but we can add more user details all the same. Coming up we'll add profile thumbnail support to mimic what Twitter does even more.
<!-- app/views/tweets/_tweet.html.erb -->
<li class="p-6">
<p class="font-bold"><%= tweet.user.name %></p>
<div class="prose prose-lg"><%= tweet.body %></div>
</li>
Try adding a few tweets and hopefully you see the real-time quality at play!
Adding more profile data
What's a tweet without a user profile thumbnail? We also don't have any controls for comments, likes or retweets. For now I'll focus on the profile image and mock up the UI for Tweet actions.
We can make use of ActiveStorage attachments for this purpose. We won't need to add a new column to the User
model which is a plus!
# app/models/user.rb
class User < ApplicationRecord
pay_customer
has_person_name
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_one_attached :profile_image # <- add this line
end
Now we need to tell Devise to permit this new field. Doing so can happen inside the ApplicationController
.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# ....
# ....
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:name, :username])
devise_parameter_sanitizer.permit(:account_update, keys: [:name, :username, :profile_image])
end
Depending on where you prefer users to add an image you can add it to both arrays we are passing inside the devise_parameter_sanitizer
methods. I chose to just use :account_update
which is where an already registered user can add or edit their account details.
Each new user will have a default image as a result that they can later modify. I'll address this coming up.
Adding a profile page
Let's add a dedicated profile page to match Twitter's design. This page will be very simple but you could extend it a great deal if you wanted. I want both the profile owner and other users to be able to see the page so we'll make it a public page by default and show controls only for the currently signed in user to edit it.
Start by adding a simple resources
method in the routes file.
# config/routes.rb
resources :profiles
Next we need a controller.
# app/controllers/profiles_controller.rb
class ProfilesController < ApplicationController
def show
@profile = User.find(params[:id])
end
end
The controller has one job and that is to display the user information bound to the request.
To make it easier to see the page in mention let's append a new Profile
link to the primary navigation. I only want this to display for signed in users.
<!-- app/views/layouts/application.html.erb-->
<% if user_signed_in? %>
<li>
<%= link_to profile_path(current_user), class: "rounded-full px-4 -ml-4 inline-flex items-center py-3 hover:bg-neutral-50 w-full text-lg transition ease-in-out duration-500" do %>
<svg class="fill-current w-6 h-6 mr-4" viewBox="0 0 24 24" aria-hidden="true"><g><path d="M12 11.816c1.355 0 2.872-.15 3.84-1.256.814-.93 1.078-2.368.806-4.392-.38-2.825-2.117-4.512-4.646-4.512S7.734 3.343 7.354 6.17c-.272 2.022-.008 3.46.806 4.39.968 1.107 2.485 1.256 3.84 1.256zM8.84 6.368c.162-1.2.787-3.212 3.16-3.212s2.998 2.013 3.16 3.212c.207 1.55.057 2.627-.45 3.205-.455.52-1.266.743-2.71.743s-2.255-.223-2.71-.743c-.507-.578-.657-1.656-.45-3.205zm11.44 12.868c-.877-3.526-4.282-5.99-8.28-5.99s-7.403 2.464-8.28 5.99c-.172.692-.028 1.4.395 1.94.408.52 1.04.82 1.733.82h12.304c.693 0 1.325-.3 1.733-.82.424-.54.567-1.247.394-1.94zm-1.576 1.016c-.126.16-.316.246-.552.246H5.848c-.235 0-.426-.085-.552-.246-.137-.174-.18-.412-.12-.654.71-2.855 3.517-4.85 6.824-4.85s6.114 1.994 6.824 4.85c.06.242.017.48-.12.654z"></path></g></svg>
<span>Profile</span>
<% end %>
</li>
<% end %>
I stole the SVG (and all the others in this guide) from Twitter directly so don't sue me :)
Now, if you refresh the page and click the link (assuming you are signed in) it will try to redirect you to a show
page.
Let's add the views so the app doesn't keep up with the errors.
<!-- app/views/profiles/show.html.erb-->
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex-1 flex items-center space-x-6">
<%= profile_image(@profile, size: "large") %>
<div>
<h1 class="font-bold text-3xl"><%= @profile.name %></h1>
<%= "@"[email protected] %>
</div>
</div>
<div>
<%= link_to "Edit profile", edit_user_registration_path, class: "inline-flex justify-center items-center px-5 py-2 rounded-full border ring-4 focus:ring-sky-50 ring-transparent hover:ring-sky-50 hover:border-gray-300" if current_user %>
</div>
</div>
<ul class="list-none divide-y -mx-6 mt-10">
<%= render collection: @profile.tweets, partial: "tweets/tweet" %>
</ul>
</div>
In this view we render our profile image if attached, the name of the profile user, an edit link that goes back to the edit registration form and finally all the tweets associated with the profile (a.k.a. user).
Note that the "Edit profile" link is only rendered for the currently signed in user and not all folks who might visit this page.
You may notice a new profile_image
helper in the view. I added this so we can extract some logic from the view and use it elsewhere in the app. This is responsible for displaying the profile image on a per user basis. I added it to the main application_helper.rb
file since it's more of a global method. It features some sizing options which default to a set size when none are passed.
# app/helpers/application_helper.rb
module ApplicationHelper
# ....
def profile_image(user, options={})
size = case options[:size]
when "large"
"w-20 h-20"
when "small"
"w-10 h-10"
else
"w-14 h-14"
end
classes = "#{size} flex-shrink-0 rounded-full border-2 border-white"
if user.profile_image.attached?
image_tag user.profile_image, class: classes
else
image_tag "https://doodleipsum.com/700/avatar-5?bg=3D27F6&i=f339578a64040310d3eb5bd82b550627", class: classes
end
end
end
Extending the registration form
We already permitted the new profile_image field to be saved but what we didn't do is extend the devise form within app/views/devise/registrations/edit
.
Before the name
field I'll add the profile image field. With it comes some custom CSS from Tailwind.
<!-- app/views/devise/registrations/edit.html.erb-->
<div class="mb-6">
<%= f.label :profile_image, class: label_class %>
<%= f.file_field :profile_image, class: "block w-full text-sm text-slate-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-sky-50 file:text-sky-700
hover:file:bg-sky-100" %>
</div>
With this in place we should be able to choose an image and attach it. You can verify this by heading back to your profile page (mine is a localhost:3000/profiles/1
).
A quick note:
In this file (and other devise view files) you may see some view helpers I created to make styling a little more modular. Feel free to edit these to your liking or scrap them entirely. I adjusted the primary color to be the sky
color from Tailwind we've been using. You can find the helper methods inside the app/helpers/application_helper.rb
file.
Adding additional Tweet controls
So far we can create tweets but we can't do much else. It would be great to be able to comment, retweet, like, and delete a tweet like Twitter does today. The easiest of those features to start with is deleting. Before we do that let's get the Tweets looking better on the design front.
<!-- app/views/tweets/_tweet.html.erb-->
<li class="px-6 py-3">
<div class="flex space-x-3 items-start pb-3">
<div class="flex-shrink-0">
<%= profile_image(tweet.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to tweet.user.name, profile_path(tweet.user), class: "hover:underline", data: { turbo: false } %></p>
<div class="prose prose-lg"><%= tweet.body %></div>
<ul class="-ml-3 flex space-x-14 items-start justify-start list-none">
<li>
<a href="#" class="text-gray-500 space-x-2 group flex items-center justify-center">
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-sky-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-sky-500"><g><path d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.044.286.12.403.142.225.384.347.632.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.367-3.43-7.787-7.8-7.788zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.335-.75-.75-.75h-.396c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z"></path></g></svg>
</div>
<span class="group-hover:text-sky-500 text-gray-500">3</span>
</a>
<li>
<a href="#" class="text-gray-500 space-x-2 group flex items-center justify-center">
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-green-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-green-500"><g><path d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z"></path></g></svg>
</div>
<span class="group-hover:text-green-500 text-gray-500">3</span>
</a>
</li>
<li>
<a href="#" class="text-gray-500 space-x-2 group flex items-center justify-center">
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-rose-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-rose-500"><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.034 11.596 8.55 11.658 1.518-.062 8.55-5.917 8.55-11.658 0-2.267-1.823-4.255-3.903-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.014-.03-1.425-2.965-3.954-2.965z"></path></g></svg>
</div>
<span class="group-hover:text-rose-500 text-gray-500">3</span>
</a>
</li>
<li>
<a href="#" class="text-gray-500 space-x-2 group flex items-center justify-center">
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-gray-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="fill-current text-gray-400 group-hover:text-rose-500 w-5 h-5"><g><path d="M20.746 5.236h-3.75V4.25c0-1.24-1.01-2.25-2.25-2.25h-5.5c-1.24 0-2.25 1.01-2.25 2.25v.986h-3.75c-.414 0-.75.336-.75.75s.336.75.75.75h.368l1.583 13.262c.216 1.193 1.31 2.027 2.658 2.027h8.282c1.35 0 2.442-.834 2.664-2.072l1.577-13.217h.368c.414 0 .75-.336.75-.75s-.335-.75-.75-.75zM8.496 4.25c0-.413.337-.75.75-.75h5.5c.413 0 .75.337.75.75v.986h-7V4.25zm8.822 15.48c-.1.55-.664.795-1.18.795H7.854c-.517 0-1.083-.246-1.175-.75L5.126 6.735h13.74L17.32 19.732z"></path><path d="M10 17.75c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75zm4 0c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75z"></path></g></svg>
</div>
</a>
</li>
</ul>
</div>
</div>
</li>
This markup is purely static but it has some controls we can harness for the actions I mentioned above. Twitter uses slightly more sophisticated UI than this but feel free to extend it further should you desire.
Deleting Tweets
Let's tackle deleting a tweet. This process can follow typical Rails conventions. With Rails 7, we can go one step further and make the response a turbo_stream response. This will allow the tweet to be deleted and the list of tweets to update in real time.
Doing so comes with some new conventions to follow. I'll start with the controller on the destroy
action
class TweetsController < ApplicationController
# ...
def destroy
@tweet = current_user.tweets.find(params[:id])
@tweet.destroy
end
# ...
end
Assuming you only want to support a turbo response I added a one-liner inside a new file called destroy.turbo_stream.erb
inside the app/views/tweets
folder. This instructs the response to be a turbo_stream response in search of a specific @tweet
instance.
<%= turbo_stream.remove(@tweet) %>
In the app/views/tweets/index.html.erb
file we need to change how the list of tweets gets rendered. I've updated the view to compensate.
<!-- app/views/tweets/index.html.erb -->
<div class="mb-8">
<%= render "form" %>
</div>
<%= turbo_frame_tag "tweets", class: "divide-y list-none" do %>
<%= render @tweets %>
<% end %>
Instead of our older ul
list of tweets I opted for divs to have more consistent styling as things change in the list.
The turbo_frame_tag
is just a helper method for <turbo-frame>
that gets rendered as HTML
in the end. We can treat it much like a div
and add styles. I passed the tweets
name to it so there's a unique way to identify it.
Inside the _tweet.html.erb
partial we need to change a few things as well.
I omitted the other actions for now to make things easier to read.
I swapped the li
tags used previously for div
tags. The surrounding div
now has a dom_id(tweet)
view helper method being rendered in the id
attribute. This is so we can find each tweet in a unique way and only effect that particular tweet.
<div class="px-6 py-3" id="<%= dom_id(tweet) %>">
<div class="flex space-x-3 items-start pb-3">
<div class="flex-shrink-0">
<%= profile_image(tweet.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to tweet.user.name, profile_path(tweet.user), class: "hover:underline", data: { turbo: false } %></p>
<div class="prose prose-lg"><%= tweet.body %></div>
<ul class="-ml-3 flex space-x-14 items-start justify-start list-none">
<!-- more actions here -->
<% if user_signed_in? && current_user == tweet.user %>
<li>
<%= button_to tweet_path(tweet), method: :delete, form: { data: { turbo_confirm: "Are you sure?" } }, class: "text-gray-500 space-x-2 group flex items-center justify-center" do %>
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-gray-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="fill-current text-gray-500 group-hover:text-rose-500 w-5 h-5"><g><path d="M20.746 5.236h-3.75V4.25c0-1.24-1.01-2.25-2.25-2.25h-5.5c-1.24 0-2.25 1.01-2.25 2.25v.986h-3.75c-.414 0-.75.336-.75.75s.336.75.75.75h.368l1.583 13.262c.216 1.193 1.31 2.027 2.658 2.027h8.282c1.35 0 2.442-.834 2.664-2.072l1.577-13.217h.368c.414 0 .75-.336.75-.75s-.335-.75-.75-.75zM8.496 4.25c0-.413.337-.75.75-.75h5.5c.413 0 .75.337.75.75v.986h-7V4.25zm8.822 15.48c-.1.55-.664.795-1.18.795H7.854c-.517 0-1.083-.246-1.175-.75L5.126 6.735h13.74L17.32 19.732z"></path><path d="M10 17.75c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75zm4 0c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75z"></path></g></svg>
</div>
<% end %>
</li>
<% end %>
</ul>
</div>
</div>
</div>
Inside the li
tag within the ul
of tweet actions I added a new button_to
form helper method that will be responsible for deleting a Tweet
. Note the tweet_path(tweet)
url helper I passed for telling the app which Tweet we are targeting. Also note that we're only rendering this action to the logged in user who authored this Tweet initially.
On a button_to
view helper method we pass the method: :delete
option to signify that the request Rails should except is a DELETE
request with Rails. That maps to the destroy
action in the controller where our logic from before now lives.
In order to not delete a Tweet mistakenly I added a form: { data: { turbo_confirm: "Are you sure?" } }
statement. This creates an alert window each time the delete button gets clicked to confirm the action is indeed the one you want to take.
After all that is said and done you should hopefully be able to delete a Tweet in real time and have the list on the index page update 🎉.
Handling empty states with Turbo
I haven't found an amazing way to handle empty states but so far this hack is where I lean. We need a new partial to start with for when there are no tweets to display on the index page.
<!-- app/views/tweets/_empty.html.erb -->
<div class="p-6" id="empty">Nothing to see here</div>
Probably the simplest partial ever right?
To get this to work the secret sauce lies within the turbo_stream.erb
files and the tweets/index.html.erb
file.
<!-- app/views/tweets/index.html.erb-->
<div class="mb-8">
<%= render "form" %>
</div>
<%= turbo_frame_tag "tweets", class: "divide-y list-none" do %>
<%= render @tweets %>
<% end %>
<%= turbo_frame_tag Tweet.new do %>
<% if @tweets.none? %>
<%= render "empty" %>
<% end %>
<% end %>
Here's the updated file. I added 4 new lines of code with a new turbo_frame_tag Tweet.new
block. This sets up another turbo frame that can be targeted when we create/destroy tweets. My goal is to render an empty state when there are no tweets but then remove that empty state in when there are tweets. Seems simple right? Not entirely...
<%# app/views/tweets/create.turbo_stream.erb %>
<%= turbo_stream.prepend "tweets" do %>
<%= render "tweet", tweet: @tweet %>
<% end %>
<%= turbo_stream.remove Tweet.new do %>
<%= render "tweets/empty" %>
<% end %>
On the create.turbo_stream.erb
file I added a new block to remove the empty state if it is present. So when a new Tweet gets created we have this handled.
When it comes to deleting all the tweets we need a way to render the empty state when the last Tweet is destroyed. To do this I'll update the destroy.turbo_stream.erb
file.
<%# app/views/tweets/destroy.turbo_stream.erb %>
<%= turbo_stream.remove(@tweet) %>
<% unless Tweet.all.any? %>
<%= turbo_stream.update Tweet.new do %>
<%= render "tweets/empty" %>
<% end %>
<% end %>
Here I check if there are no tweets and render the empty partial if so. I know it will be rare that there will be ever zero tweets to display but it's good to have a fallback in case.
All-in-all it's kind of hacky but it works!
Retweets
Retweets are just tweets of tweets. We can reuse the Tweet
model and add a bit of logic to signify why "type" of tweet it is. We don't have a tweet type defined in the model yet so we'll need to address this as well.
Add an additional column to the tweets
table with a new migration:
rails g migration add_tweet_id_to_tweets tweet_id:integer
invoke active_record
create db/migrate/20220615203110_add_tweet_id_to_tweets.rb
Migrate your changes:
rails db:migrate
Retweet routing
We can extend our existing tweets routes to include a retweet route. Adding a member
block lets you hijack the typical resources
paths and add another to the mix. We'll make use of a POST
request here since we will be effectively creating a new tweet.
# config/routes.rb
resources :tweets, except: [:edit, :update] do
resources :comments, only: [:create, :destroy]
member do
post :retweet
end
end
Tweet model updates
Our model needs to associate a tweet with another tweet. This is a little confusing but totally possible now that we have a tweet_id
column. How we signify which is which is made possible by the data provided with the creation of each new tweet.
class Tweet < ApplicationRecord
belongs_to :user
belongs_to :tweet, optional: true
def tweet_type
if tweet_id? && body?
"quote-tweet"
elsif tweet_id?
"retweet"
else
"tweet"
end
end
end
Here we add the belongs_to
association to a tweet with the optional: true
option. This means a tweet can be created without another tweet association. In Rails, most models aren't optional by default.
The tweet_type
method allows us to pass a tweet type we'll use in the views coming up and render the appropriate partial. The shorthand tweet_id?
and body?
methods are ways to check if these values exist. You can do this for any column on a given model. This is built into Rails.
Retweet controller updates
Inside the tweets_controller.rb
we can add a new method called retweet
since we added the new route.
# app/controllers/tweets_controller.rb
class TweetsController < ApplicationController
before_action :authenticate_user!
def index
@tweet = Tweet.new
@tweets = Tweet.all.order(created_at: :desc)
end
def create
@tweet = current_user.tweets.new(tweet_params)
respond_to do |format|
if @tweet.save
format.turbo_stream
else
format.html do
flash[:tweet_errors] = @tweet.errors.full_messages
redirect_to root_path
end
end
end
end
def show
@tweet = Tweet.find(params[:id])
end
def destroy
@tweet = current_user.tweets.find(params[:id])
@tweet.destroy
end
def retweet
@tweet = Tweet.find(params[:id])
retweet = current_user.tweets.new(tweet_id: @tweet.id)
respond_to do |format|
if retweet.save
format.turbo_stream
else
format.html { redirect_back fallback_location: @tweet, alert: "Could not retweet" }
end
end
end
private
def tweet_params
params.require(:tweet).permit(:body, :tweet_id)
end
end
The new retweet
action looks a lot like our create
action though we are first finding a tweet in mention by it's id then assigning it to a new tweet created by the current_user. Much like the turbo_stream response on the create
action I'll follow the same protocol for the retweet
action. We do need to add a retweet.turbo_stream.erb
file to the app/views/tweets
folder. It will have the following code inside.
<%# app/views/tweets/retweet.turbo_stream.erb %>
<%= turbo_stream.prepend "tweets" do %>
<%= render "tweet", tweet: @tweet %>
<% end %>
Retweet link and parial
In our views we'll need to both add a link to retweet and add a partial that contains the origin tweet.
The link to retweet will actually need to be a button_to
view helper again since we need to fire off a POST
request. This helper embeds a small form to help with that workload. I also only want people who aren't the tweet author to be able to retweet a given tweet. Doing this means we conditionally check the original tweet user_id agains the current user id. If they match the retweet button won't display.
<!-- app/views/tweets/_controls.html.erb
<% if user_signed_in? && tweet.user_id != current_user.id %>
<li>
<%= button_to retweet_tweet_path(tweet), method: :post, class: "text-gray-500 space-x-2 group flex items-center justify-center" do %>
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-green-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-green-500"><g><path d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z"></path></g></svg>
</div>
<span class="group-hover:text-green-500 text-gray-500">3</span>
<% end %>
</li>
<% end %>
If you created a tweet and are signed in you should see that icon dissapear. To verify it shows otherwise I made another account. In doing so I realized there are now sign in and sign out buttons in the app yet. For the time being I added those in the application.html.erb
layout file in the right sidebar.
<!-- app/views/layouts/application.html.erb-->
<!-- a ton more code -->
<div class="lg:col-span-6 border-x">
<%= content_for?(:content) ? yield(:content) : yield %>
</div>
<div class="lg:col-span-4 pl-6 pt-6">
<div class="bg-gray-50 w-full min-h-[150px] rounded-lg p-6 mb-6">Sidebar stuff</div>
<!-- Add the code below -->
<% if user_signed_in? %>
<p class="mb-4">Signed in as <%= current_user.name %></p>
<%= button_to "Sign out", destroy_user_session_path, method: :delete, class: button_class(theme: "primary"), data: { turbo: false } %>
<% else %>
<%= link_to "Sign in", new_user_session_path, class: button_class(theme: "primary") %>
<% end %>
</div>
<!-- a ton more code -->
Displaying retweets
We'll need to tweets dynamically based on the type they are. We have normal tweets, retweets, and quote tweets. For now I'll just do retweets. Feel free to extend to include quote tweets on your own!
Let's add a retweet partial:
<!-- app/views/tweets/_retweet.html.erb -->
<div class="px-6 py-3" id="<%= dom_id(tweet.tweet) %>">
<div class="flex items-center mb-3 space-x-2">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-4 h-4 fill-current"><g><path d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z"></path></g></svg>
<p class="text-sm font-medium">Retweeted by <%= "@" + tweet.user.username %></p>
</div>
<div class="flex space-x-3 items-start">
<div class="flex-shrink-0">
<%= profile_image(tweet.tweet.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to tweet.tweet.user.name, profile_path(tweet.tweet.user), class: "hover:underline", data: {turbo: false} %></p>
<%= link_to tweet, data: { turbo: false } do %>
<div class="prose prose-lg"><%= tweet.tweet.body %></div>
<% end %>
<%= render "tweets/controls", tweet: tweet.tweet %>
</div>
</div>
</div>
This may come across confusing but we're essentially finding the retweeted tweets details and displaying those with a tweet.tweet
notation. I added an icon as well as some copy to show the retweet text as well.
Unfortunately, we need to update the index.html.erb
file to make things dynamic.
<!-- app/views/tweets/index.html.erb -->
<div class="mb-8">
<%= render "form" %>
</div>
<%= turbo_frame_tag "tweets", class: "divide-y list-none" do %>
<% @tweets.each do |tweet| %>
<%= render partial: "tweets/#{tweet.tweet_type}", locals: { tweet: tweet } %>
<% end %>
<% end %>
<%= turbo_frame_tag Tweet.new do %>
<% if @tweets.none? %>
<%= render "empty" %>
<% end %>
<% end %>
Now we are rendering a dynamic partial per tweet in the collection rendered in the .each
method.
Trying to click a retweet action now presents a bug. We previously required the body
column to have a minimum amount of characters in order to be created. Let's adjust this validation a touch to compensate.
class Tweet < ApplicationRecord
validates :body, length: { maximum: 240 }, allow_blank: false, unless: :tweet_id
end
With the addition of unless: :tweet_id
we can bypass the validation on retweets specifically.
Comments
Comments are what make Twitter a community. A comment on Twitter is much like a Tweet and honestly might be more sophisticated than what we are doing here but I'll keep this a bit simple for the sake of exercise.
Let's start with adding a new model and work our way toward adding turbo comments to the app.
$ rails g model Comment user:references tweet:references body:text
This generates a new Comment
model with the following:
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :user
belongs_to :tweet
end
We want a Tweet to be able to have more than one comment so let's address that in the Tweet
model.
# app/models/tweet.rb
class Tweet < ApplicationRecord
belongs_to :user
has_many :comments # add this line
validates :body, length: { maximum: 240 }, allow_blank: false
end
Next we need a comments controller. We'll keep it simple with just a create
and destroy
action.
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
before_action :authenticate_user!
def create
end
def destroy
end
end
And now add the routing. To make it easier to find a given tweet we can make a nested route so comments are within tweets representative of the URL. i.e. (tweets/:id_of_tweet/comments/:id_of_comment)
# config/routes.rb
resources :tweets, except: [:edit, :update] do
resources :comments, only: [:create, :destroy]
end
Finally, we need some views. For this to work I'll assume a user will click on the comment icon within the Tweets index. In my mind that would then link directly to a dedicated tweet show page where we can then render any comments.
Create a new comments
folder inside app/views
. Inside we can add a _form.html.erb
partial that we'll render on the tweet's dedicated show page.
Inside the app/views/tweets/_tweet.html.erb
file we can update the tweet to include a link to the show page. I chose the tweet text itself although this could be improved. I also made the comment icon link to the tweet as well as render the comment count if there is one.
<!-- app/views/tweets/_tweet.html.erb -->
<div class="px-6 py-3" id="<%= dom_id(tweet) %>">
<div class="flex space-x-3 items-start pb-3">
<div class="flex-shrink-0">
<%= profile_image(tweet.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to tweet.user.name, profile_path(tweet.user), class: "hover:underline", data: { turbo: false } %></p>
<%= link_to tweet, data: { turbo: false } do %>
<div class="prose prose-lg"><%= tweet.body %></div>
<% end %>
<%= render "controls", tweet: tweet %>
</div>
</div>
</div>
I don't want a turbo response on this link so I passed the data: { turbo: false }
option to tell turbo-rails that's the case.
Clicking that link should now take you to the app/views/tweets/show.html.erb
page. Inside I added the following code:
<!-- app/views/tweets/show.html.erb -->
<div class="p-6">
<div class="flex items-center space-x-3">
<%= link_to root_path, class: "rounded-full w-8 h-8 flex items-center justify-center hover:bg-gray-100 duration-300 transition-all" do %>
<svg viewBox="0 0 24 24" aria-hidden="true" class="fill-current text-gray-800 w-5 h-5"><g><path d="M20 11H7.414l4.293-4.293c.39-.39.39-1.023 0-1.414s-1.023-.39-1.414 0l-6 6c-.39.39-.39 1.023 0 1.414l6 6c.195.195.45.293.707.293s.512-.098.707-.293c.39-.39.39-1.023 0-1.414L7.414 13H20c.553 0 1-.447 1-1s-.447-1-1-1z"></path></g></svg>
<% end %>
<h1 class="font-bold text-2xl">Tweet</h1>
</div>
</div>
<div class="p-6">
<div class="flex items-start justify-between space-x-3">
<%= profile_image(@tweet.user) %>
<div class="flex-1">
<h1 class="font-bold">
<%= link_to @tweet.user.name, profile_path(@tweet.user) %>
</h1>
<p class="text-gray-700"><%= "@" + @tweet.user.username %></p>
</div>
</div>
<div class="prose prose-2xl text-gray-900 my-3">
<% if @tweet.tweet_id? %>
<%= @tweet.tweet.body %>
<% else%>
<%= @tweet.body %>
<% end %>
</div>
<ul class="py-3 border-t text-gray-700 flex space-x-4 text-sm">
<li>
<span class="font-semibold text-gray-800">3</span> Retweets
</li>
<li>
<span class="font-semibold text-gray-800">4</span> Quote Tweets
</li>
<li>
<span class="font-semibold text-gray-800">4</span> Likes
</li>
</ul>
<div class="py-2 border-y px-4 justify-center flex">
<%= render "tweets/controls", tweet: @tweet %>
</div>
</div>
The UI here is mimicing Twitter pretty heavily. I added some static stats and extracted the controls we used inside the _tweet.html.erb
partial to another partial called _controls.html.erb
.
Be sure to update the _tweet.html.erb
file accordingly
<!-- app/views/tweets/_tweet.html.erb -->
<div class="px-6 py-3" id="<%= dom_id(tweet) %>">
<div class="flex space-x-3 items-start pb-3">
<div class="flex-shrink-0">
<%= profile_image(tweet.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to tweet.user.name, profile_path(tweet.user), class: "hover:underline", data: {turbo: false} %></p>
<%= link_to tweet, data: { turbo: false } do %>
<div class="prose prose-lg"><%= tweet.body %></div>
<% end %>
<%= render "tweets/controls", tweet: tweet %>
</div>
</div>
</div>
We can render the comments on the tweets/show.html.erb
page just below everything else.
<%# app/views/tweets/show.html.erb %>
<div class="p-6">
<div class="flex items-center space-x-3">
<%= link_to root_path, class: "rounded-full w-8 h-8 flex items-center justify-center hover:bg-gray-100 duration-300 transition-all" do %>
<svg viewBox="0 0 24 24" aria-hidden="true" class="fill-current text-gray-800 w-5 h-5"><g><path d="M20 11H7.414l4.293-4.293c.39-.39.39-1.023 0-1.414s-1.023-.39-1.414 0l-6 6c-.39.39-.39 1.023 0 1.414l6 6c.195.195.45.293.707.293s.512-.098.707-.293c.39-.39.39-1.023 0-1.414L7.414 13H20c.553 0 1-.447 1-1s-.447-1-1-1z"></path></g></svg>
<% end %>
<h1 class="font-bold text-2xl">Tweet</h1>
</div>
</div>
<div class="p-6">
<div class="flex items-start justify-between space-x-3">
<%= profile_image(@tweet.user) %>
<div class="flex-1">
<h1 class="font-bold">
<%= link_to @tweet.user.name, profile_path(@tweet.user) %>
</h1>
<p class="text-gray-700"><%= "@" + @tweet.user.username %></p>
</div>
</div>
<div class="prose prose-2xl text-gray-900 my-3">
<% if @tweet.tweet_id? %>
<%= @tweet.tweet.body %>
<% else%>
<%= @tweet.body %>
<% end %>
</div>
<ul class="py-3 border-t text-gray-700 flex space-x-4 text-sm">
<li>
<span class="font-semibold text-gray-800">3</span> Retweets
</li>
<li>
<span class="font-semibold text-gray-800">4</span> Quote Tweets
</li>
<li>
<span class="font-semibold text-gray-800">4</span> Likes
</li>
</ul>
<div class="py-2 border-y px-4 justify-center flex">
<%= render "tweets/controls", tweet: @tweet %>
</div>
<% if user_signed_in? %>
<%= turbo_frame_tag "#{dom_id(@tweet)}_comment_form" do %>
<%= render "comments/form", tweet: @tweet %>
<% end %>
<% else %>
<p class="mt-6 text-center text-lg"><%= link_to "Sign in", new_user_session_path, class: "text-sky-500 hover:text-sky-600 font-medium" %> to leave a reply</p>
<% end %>
<%= turbo_frame_tag "#{dom_id(@tweet)}_comments" do %>
<% @comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
<% end %>
</div>
Just after the controls we embed a comment form. Here a user can comment on a tweet assuming they have privelages to do so. Twitter's privelages are more advanced than what I'll account for. The minimum requirement is to be signed in.
At the bottom of the file we wrap everything in a turbo_frame_tag helper that will feature a unique identifier using the dom_id
view helper. We need this to be unique so Rails knows which tweet and list of comments to target for turbo stream responses.
<%# app/views/comments/_form.html.erb -->
<%= form_with model: [tweet, @comment], data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset"} do |form| %>
<p class="pt-3">Replying to <%= link_to tweet.user.name, profile_path(tweet.user), class: "text-sky-500 hover:text-sky-600" %></p>
<div class="flex items-start justify-between space-x-3 py-6 border-b">
<%= profile_image(current_user) %>
<div class="flex-1">
<%= form.label :body, class: "sr-only" %>
<%= form.text_area :body, class: "border-none w-full resize-none rounded-md focus:shadow-none focus:border-none ring-0 focus:ring-0 focus:outline-none text-lg py-3 px-0 h-full min-h-[100px]", placeholder: "Tweet your reply" %>
</div>
<%= form.submit "Reply", class: "px-6 py-2 bg-sky-400 text-white font-semibold rounded-full text-center cursor-pointer inline-block hover:bg-sky-500 transition ease-in-out duration-300" %>
</div>
<% end %>
Twitter has a fancier toggle to comment experience but for now I made it resemble the look and feel. I also copied over the stimulus JS logic to clear the form after submit. Making code reusable like this is so great!
Because we are using nested routing the array [tweet, @comment]
must be passed in the form model
option. This will build the correct URL.
If you're wondering where the @comment
and @comments
instance variables came from, I updated the show
action on the TweetsController
to include those.
class TweetsController < ApplicationController
def show
@tweet = Tweet.find(params[:id])
@comment = Comment.new
@comments = @tweet.comments.order(created_at: :desc)
end
end
Here we build a new instance of a comment relative to the current tweet. Then we create a @tweets
instance variable which will read back any comments that might exist.
We can now update the controls
partial to include live comment counts. If there's a comment count we'll display it and if not we dislay a 0
.
<%# app/views/tweets/_controls.erb -->
<!-- more code -->
<li>
<%= link_to tweet, class: "text-gray-500 space-x-2 group flex items-center justify-center", data: { turbo: false } do %>
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-sky-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-sky-500"><g><path d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.044.286.12.403.142.225.384.347.632.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.367-3.43-7.787-7.8-7.788zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.335-.75-.75-.75h-.396c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z"></path></g></svg>
</div>
<span class="group-hover:text-sky-500 text-gray-500"><%= tweet.comments.count ||= 0 %></span>
<% end %>
</li>
<!-- more code -->
Comments controller
Let's add the functionality to create a comment in the controller.
class CommentsController < ApplicationController
before_action :authenticate_user!
before_action :set_tweet
def create
@comment = @tweet.comments.new(comment_params.merge(user: current_user))
respond_to do |format|
if @comment.save
format.turbo_stream
else
format.html do
redirect_to tweet_path(@tweet), alert: "Comment could not be created"
end
end
end
end
def destroy
@comment = @tweet.comments.find(params[:id])
@comment.destroy
respond_to do |format|
format.turbo_stream
format.html do
redirect_to tweet_path(@tweet), alert: "Comment could not be created"
end
end
end
private
def comment_params
params.require(:comment).permit(:body)
end
def set_tweet
@tweet = Tweet.find(params[:tweet_id])
end
end
There are a few line items worth mentioning here:
- Any user must be signed in to comment
- Because of the routing we have we'll always need access to a tweet. Getting that comes back in the parameters (request) as a
tweet_id
instance. We can query for the tweet in this way using abefore_action
callback function calledset_tweet
. From the tweet we can then create and assign a new comment. - On the
create
action we are merging the current_user with thecomment_params
. These are considered white-listed values that can enter the database. - By default we'll respond with
turbo_stream
responses if all goes well. I prefer to fallback tohtml
if things go south.
Turbo-comments
With the general logic in place we need to follow similar patterns as we did with the Tweets index list.
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.prepend "#{dom_id(@tweet)}_comments" do %>
<%= render "comment", comment: @comment %>
<% end %>
Here we use a more unique identifier since there will likely be a large number of tweets out in the wild.
A given comment might look similar to a tweet
<%# app/views/comments/_comment.html.erb %>
<div class="py-4 border-b" id="<%= dom_id(comment) %>">
<div class="flex space-x-3 items-start">
<div class="flex-shrink-0">
<%= profile_image(comment.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to comment.user.name, profile_path(comment.user), class: "hover:underline", data: {turbo: false} %></p>
<div class="prose prose-lg"><%= comment.body %></div>
<%# controls can go here %>
</div>
</div>
</div>
Add comment controls
With comments being slightly different than Tweets, we need to add some similar controls to the mix. For now I'll add those directly to the _comment.html.erb
partial.
<%# app/views/comments/_comment.html.erb -->
<div class="py-4 border-b" id="<%= dom_id(comment) %>">
<div class="flex space-x-3 items-start">
<div class="flex-shrink-0">
<%= profile_image(comment.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to comment.user.name, profile_path(comment.user), class: "hover:underline", data: {turbo: false} %></p>
<div class="prose prose-lg"><%= comment.body %></div>
<ul class="-ml-3 flex space-x-14 items-start justify-start list-none">
<li>
<a href="#" class="text-gray-500 space-x-2 group flex items-center justify-center">
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-rose-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-rose-500"><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.034 11.596 8.55 11.658 1.518-.062 8.55-5.917 8.55-11.658 0-2.267-1.823-4.255-3.903-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.014-.03-1.425-2.965-3.954-2.965z"></path></g></svg>
</div>
<span class="group-hover:text-rose-500 text-gray-500">3</span>
</a>
</li>
<% if user_signed_in? && current_user == comment.user %>
<li>
<%= button_to tweet_comment_path(comment.tweet,comment), method: :delete, form: { data: { turbo_confirm: "Are you sure?" } }, class: "text-gray-500 space-x-2 group flex items-center justify-center" do %>
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-gray-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="fill-current text-gray-500 group-hover:text-rose-500 w-5 h-5"><g><path d="M20.746 5.236h-3.75V4.25c0-1.24-1.01-2.25-2.25-2.25h-5.5c-1.24 0-2.25 1.01-2.25 2.25v.986h-3.75c-.414 0-.75.336-.75.75s.336.75.75.75h.368l1.583 13.262c.216 1.193 1.31 2.027 2.658 2.027h8.282c1.35 0 2.442-.834 2.664-2.072l1.577-13.217h.368c.414 0 .75-.336.75-.75s-.335-.75-.75-.75zM8.496 4.25c0-.413.337-.75.75-.75h5.5c.413 0 .75.337.75.75v.986h-7V4.25zm8.822 15.48c-.1.55-.664.795-1.18.795H7.854c-.517 0-1.083-.246-1.175-.75L5.126 6.735h13.74L17.32 19.732z"></path><path d="M10 17.75c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75zm4 0c.414 0 .75-.336.75-.75v-7c0-.414-.336-.75-.75-.75s-.75.336-.75.75v7c0 .414.336.75.75.75z"></path></g></svg>
</div>
<% end %>
</li>
<% end %>
</ul>
</div>
</div>
</div>
To make this guide not drag on and on I'll only have two actions for comments. You could extend this to have the retweet functionality in place but that's more involved and something I invite you to take on.
For now we'll just feature likes and the delete icon. The delete icon is straight forward. The URL will need to be adjusted to include both the tweet and the comment. The button_to
method will kick off a DELETE
request which maps to the comments_controller#destroy
action. We already have logic in place to handle the request as a turbo_stream
response. To make this all work we need a new destroy.turbo_stream.erb
file.
<%# app/views/comments/destroy.turbo_stream.erb %>
<%= turbo_stream.remove(@comment) %>
With this in place we should be able to move comments that we authored and in real-time 👏.
Liking (hearting) tweets and comments
Liking a Tweet or Comment gives us an opportunity to add more of a polymorphic feature to the app. By this I mean a reusable model that can be added once but used for both the Tweet
and Comment
models and any other models you want to add likes to.
Let's start with the data layer:
rails g model Like likeable:references{polymorphic} user:references
invoke active_record
create db/migrate/20220617155419_create_likes.rb
create app/models/like.rb
invoke test_unit
create test/models/like_test.rb
create test/fixtures/likes.yml
Since likes are very basic we don't need to store any other data. The "able" naming convention is one you might commenly see in Rails for functional/repeatable logic.
The migration file created looks like the following:
# db/migrate/TIMESTAMP_create_likes.rb
class CreateLikes < ActiveRecord::Migration[7.0]
def change
create_table :likes do |t|
t.references :likeable, polymorphic: true, null: false
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
I'll ammend this slightly by adding an index
# db/migrate/TIMESTAMP_create_likes.rb
class CreateLikes < ActiveRecord::Migration[7.0]
def change
create_table :likes do |t|
t.references :likeable, polymorphic: true, null: false, index: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
Next, I'll run:
rails db:migrate
Now, if you check out the db/schema.rb
file you should see the newly created table:
# db/schema.rb
create_table "likes", force: :cascade do |t|
t.string "likeable_type", null: false
t.integer "likeable_id", null: false
t.integer "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["likeable_type", "likeable_id"], name: "index_likes_on_likeable"
t.index ["user_id"], name: "index_likes_on_user_id"
end
Update our models
With the new Like model in place we can extend our Tweet
and Comment
models to have the new polymorphic associations
# app/models/like.rb
class Like < ApplicationRecord
belongs_to :likeable, polymorphic: true
belongs_to :user
end
# app/models/tweet.rb
class Tweet < ApplicationRecord
has_many :likes, as: :likeable
end
# app/models/comment.rb
class Comment < ApplicationRecord
has_many :likes, as: :likeable
end
Now on to the controllers.
Create a new file in app/controllers
called likes_controller.rb
. Inside I've added the following
# app/controllers/likes_controller.rb
class LikesController < ApplicationController
before_action :set_likeable
def create
if @likeable.likes.count >= 1 && @likeable.liked_by?(current_user)
@like = Like.find_by(likeable_id: @likeable.id, user_id: current_user)
@like.destroy
else
@like = @likeable.likes.new
@like.user = current_user
@like.save!
end
end
private
def set_likeable
@likeable = params[:likeable_type].constantize.find(params[:likeable_id])
end
end
While the concept of liking and unliking seems simple, we have a variety of edge cases to account for during the action. In the code above I'm checking first if a given user already liked the @likeable
instance. In this case it's a Tweet. If they did we'll destroy their like. If they did not we'll create the new Like for the given likeable instance. This might be a touch confusing. It honestly took me a few attempts to get right but so far it's working as I'd hoped even though there are some bugs.
You may notice I don't have any response related methods here. Rails is smart enough to know that if you create those files in the view folders, it will respond in that manner.
I created a create.turbo_stream.erb
file in app/views/likes
with the following inside:
<%# app/views/likes/create.turbo_stream.erb %>
<%= turbo_stream.update_all(".#{dom_id(@likeable)}_likes") do %>
<%= render "likes/likes", likeable: @likeable %>
<% end %>
<%= turbo_stream.update_all(".#{dom_id(@likeable)}_likes-size", @likeable.likes.size) %>
Next I updated our _controls.html.erb
partial to include the following where the static like button used to be. You often see guides using turbo_frame_tag
helpers but they aren't always necessary. In our case, I needed a class identifier since we'll be targeting many tweets in a list if they by chance are retweeted.
<%#= app/views/tweets/_controls.html.erb %>
<li>
<%= content_tag :div, class: "#{dom_id(tweet)}_likes" do %>
<%= render "likes/likes", likeable: tweet %>
<% end %>
</li>
We'll pass a likeable
instance through this partial as I plan to reuse it for comments.
Inside the app/views/likes/_likes.html.erb
file I have the following:
<%# app/views/likes/_likes.html.erb -->
<%= button_to likes_path(likeable_id: likeable.id, likeable_type: likeable.model_name.name), method: :post, class: "text-gray-500 space-x-2 group flex items-center justify-center" do %>
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-rose-50">
<% if likeable.liked_by?(current_user) %>
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current text-rose-500"><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12z"></path></g></svg>
<% else %>
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-rose-500"><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.034 11.596 8.55 11.658 1.518-.062 8.55-5.917 8.55-11.658 0-2.267-1.823-4.255-3.903-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.014-.03-1.425-2.965-3.954-2.965z"></path></g></svg>
<% end %>
</div>
<span class="group-hover:text-rose-500 text-gray-500">
<div class="<%= dom_id(likeable) %>_likes-size"><%= likeable.likes.size %></div>
</span>
<% end %>
This is the same markup we used to have but made dynamic. We'll be passing the likeable
instance through which can be re-used. Be sure to add the new div
surrounding the <%= likeable.likes.size %>
code. This will be targeted so that when a tweet that may be a retweet get's liked, all other instances of that tweet update their count as well.
You may notice a new method for seeing if a like was indeed liked. I'll make a concern which we can then include in both the Tweet
and Comment
models. This will extract some code we are repeating elsewhere.
# app/models/concerns/likeable.rb
module Likeable
extend ActiveSupport::Concern
included do
has_many :likes, as: :likeable
end
def liked_by?(user)
likes.where(user_id: user).any?
end
end
Now we can include this on the tweet and comment models and remove the old lines has_many :likes, as: :likeable
# app/models/tweet.rb
class Tweet < ApplicationRecord
include Likeable # add this like
belongs_to :user
belongs_to :tweet, optional: true
has_many :comments
# remove has_many :likes, as: :likeable
validates :body, length: { maximum: 240 }, allow_blank: false, unless: :tweet_id
def tweet_type
if tweet_id? && body?
"quote-tweet"
elsif tweet_id?
"retweet"
else
"tweet"
end
end
end
We'll follow suit in the Comment
model.
# app/models/comment.rb
class Comment < ApplicationRecord
include Likeable
belongs_to :user
belongs_to :tweet
# remove has_many :likes, as: :likeable
end
Liking comments
With most of the liking setup already completed we can extend comments to follow a similar pattern as tweets.
<%# app/views/comments/_comment.html.erb %>
<div class="py-4 border-b" id="<%= dom_id(comment) %>">
<div class="flex space-x-3 items-start">
<div class="flex-shrink-0">
<%= profile_image(comment.user) %>
</div>
<div class="flex-1">
<p class="font-bold"><%= link_to comment.user.name, profile_path(comment.user), class: "hover:underline", data: {turbo: false} %></p>
<div class="prose prose-lg"><%= comment.body %></div>
<ul class="-ml-3 flex space-x-14 items-start justify-start list-none">
<li>
<%= content_tag :div, class: "#{dom_id(comment)}_likes" do %>
<%= render "likes/likes", likeable: comment %>
<% end %>
</li>
<!-- more code below-->
Updating stats dynamically
On the show page we can now update the other stats as well. To do this we need to adjust a few areas to include unique identifiers
I'll start again with the _controls.html.erb
partial. There's a comment count I would like to update as new comments get added.
<ul class="-ml-3 flex space-x-14 items-start justify-start list-none">
<li>
<%= link_to tweet, class: "text-gray-500 space-x-2 group flex items-center justify-center", data: { turbo: false } do %>
<div class="flex items-center justify-center w-10 h-10 rounded-full hover:bg-sky-50">
<svg viewBox="0 0 24 24" aria-hidden="true" class="w-5 h-5 fill-current group-hover:text-sky-500"><g><path d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.044.286.12.403.142.225.384.347.632.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.367-3.43-7.787-7.8-7.788zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.335-.75-.75-.75h-.396c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z"></path></g></svg>
</div>
<%= turbo_frame_tag "#{dom_id(tweet)}_comments_count" do %>
<%= render "tweets/comments_count", tweet: tweet %>
<% end %>
<!-- more code below -->
I wrapped the span
tag containing the comment count in another turbo_frame_tag
with a unique id.
When a comment is created we can update that count.
I then added a new partial to make things nicer to look at. It's called _comments_count.html.erb
<%# app/views/tweets/_comments_count.html.erb %>
<span class="group-hover:text-sky-500 text-gray-500"><%= tweet.comments.count ||= 0 %></span>
Then we can update both our create
and destroy
turbo_stream response files.
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.prepend "#{dom_id(@tweet)}_comments" do %>
<%= render "comment", comment: @comment %>
<% end %>
<%= turbo_stream.replace "#{dom_id(@tweet)}_comments_count" do %>
<%= render "tweets/comments_count", tweet: @tweet %>
<% end %>
<%# app/views/comments/destroy.turbo_stream.erb %>
<%= turbo_stream.remove(@comment) %>
<%= turbo_stream.replace "#{dom_id(@tweet)}_comments_count" do %>
<%= render "tweets/comments_count", tweet: @tweet %>
<% end %>
Now our comment count on the show page will update dynamically!
Updating the retweet stats and the like we can make more static. I removed the quote tweet stat for now.
<!-- app/views/tweets/show.html.erb-->
<div class="p-6">
<div class="flex items-center space-x-3">
<%= link_to root_path, class: "rounded-full w-8 h-8 flex items-center justify-center hover:bg-gray-100 duration-300 transition-all" do %>
<svg viewBox="0 0 24 24" aria-hidden="true" class="fill-current text-gray-800 w-5 h-5"><g><path d="M20 11H7.414l4.293-4.293c.39-.39.39-1.023 0-1.414s-1.023-.39-1.414 0l-6 6c-.39.39-.39 1.023 0 1.414l6 6c.195.195.45.293.707.293s.512-.098.707-.293c.39-.39.39-1.023 0-1.414L7.414 13H20c.553 0 1-.447 1-1s-.447-1-1-1z"></path></g></svg>
<% end %>
<h1 class="font-bold text-2xl">Tweet</h1>
</div>
</div>
<div class="p-6">
<div class="flex items-start justify-between space-x-3">
<%= profile_image(@tweet.user) %>
<div class="flex-1">
<h1 class="font-bold">
<%= link_to @tweet.user.name, profile_path(@tweet.user) %>
</h1>
<p class="text-gray-700"><%= "@" + @tweet.user.username %></p>
</div>
</div>
<div class="prose prose-2xl text-gray-900 my-3">
<% if @tweet.tweet_id? %>
<%= @tweet.tweet.body %>
<% else%>
<%= @tweet.body %>
<% end %>
</div>
<ul class="py-3 border-t text-gray-700 flex space-x-4 text-sm">
<li>
<span class="font-semibold text-gray-800"><%= Tweet.where(tweet_id: @tweet).size %></span> Retweets
</li>
<li>
<span class="font-semibold text-gray-800"><%= @tweet.likes.size %></span> Likes
</li>
</ul>
<div class="py-2 border-y px-4 justify-center flex">
<%= render "tweets/controls", tweet: @tweet %>
</div>
<% if user_signed_in? %>
<%= turbo_frame_tag "#{dom_id(@tweet)}_comment_form" do %>
<%= render "comments/form", tweet: @tweet %>
<% end %>
<% else %>
<p class="mt-6 text-center text-lg"><%= link_to "Sign in", new_user_session_path, class: "text-sky-500 hover:text-sky-600 font-medium" %> to leave a reply</p>
<% end %>
<%= turbo_frame_tag "#{dom_id(@tweet)}_comments" do %>
<% if @comments.any? %>
<% @comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
<% end%>
<% end %>
</div>
Profile house keeping
With our app now having retweets and tweets the original collection we render on the profile page is broken. I'll update that to match the tweets#index
page and we should be good to go.
<!-- app/views/profiles/show.html.erb-->
<div class="p-6">
<div class="flex items-center justify-between space-x-4">
<div class="flex items-center">
<%= profile_image(@profile, size: "large") %>
</div>
<div class="flex-1">
<div>
<h1 class="font-bold text-3xl"><%= @profile.name %></h1>
<%= "@" + @profile.username %>
</div>
</div>
<div>
<%= link_to "Edit profile", edit_user_registration_path, class: "inline-flex justify-center items-center px-5 py-2 rounded-full border ring-4 focus:ring-sky-50 ring-transparent hover:ring-sky-50 hover:border-gray-300" if current_user %>
</div>
</div>
<div class="mt-10 -mx-6">
<% @profile.tweets.each do |tweet| %>
<%= render partial: "tweets/#{tweet.tweet_type}", locals: { tweet: tweet } %>
<% end %>
</div>
</div>
Closing thoughts
Twitter although appears to be simple, is actually somewhat complex. It's a great learning experience to take another application and try to recreate it. I invite you to try to go behind what I have here to see what's possible!
For more content like this be sure to subscribe to my newsletter and YouTube channel. There are complete collections of other Ruby on Rails content I invite you to check out as well.
Categories
Collection
Part of the Let's Build: With Ruby on Rails collection
Products and courses
-
Hello Hotwire
A course on Hotwire + Ruby on Rails.
-
Hello Rails
A course for newcomers to Ruby on Rails.
-
Rails UI
UI templates and components for Rails.