Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

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:

  1. We permitted only the :body parameter from being white-listed into the database. Rails will ignore any other fields since they are not permitted.
  2. 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 default new action. Going outside of normal conventions means we need to code outside normal conventions as well.
  3. Declaring a simple format.turbo_stream response type tells rails to reach inside the app/views/tweets directory for a create.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:

  1. Display them as they get created in real time on the tweets#index page
  2. Don't require any page refreshing to create or see newly created tweets from other users
  3. 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:

  1. Any user must be signed in to comment
  2. 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 a before_action callback function called set_tweet. From the tweet we can then create and assign a new comment.
  3. On the create action we are merging the current_user with the comment_params. These are considered white-listed values that can enter the database.
  4. By default we'll respond with turbo_stream responses if all goes well. I prefer to fallback to html 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.

Link this article
Est. reading time: 65 minutes
Stats: 8,028 views

Collection

Part of the Let's Build: With Ruby on Rails collection

Products and courses