July 4, 2018
•Last updated November 5, 2023
Let's Build: With Ruby on Rails - Trade App With In-App Messaging
Welcome to my latest Let's Build: With Ruby on Rails series. This build will dive a little deeper into relationships within a Ruby on Rails application and teach you to create a simple trade app with in-app messaging within your main rails app.
Think of the messaging structure as an ongoing conversation. Users can initially create a conversation and always look back to their history. This app is a start to what could be a fully-featured messaging area within your own app but I want to introduce new methods include scopes, params, and more that come bundled with Rails.
Kicking things off, literally
I created my own Rails application template called Kick Off of which you are free to use. Follow the steps in the videos to kick off your own projects. You'll need to clone the repo to your own machine and of course, be set up to run Ruby on Rails.
At the time of this recording, I am using Rails 5.2
and Ruby 2.5.1
.
Part 2
Part 3
Part 4
Scaffold the trade
$ rails g scaffold Trade title:string description:text user:references
Adjust User model to include many trades
A user will want to be able to trade more than once so here we add the has_many :trades
association to the user model. When we ran the kickoff template scaffold our User
model had already been generated.
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :trackable, :validatable
has_many :trades
end
Add ActiveStorage support
ActiveStorage support comes out of the box with any new 5.2
rails application but we need a migration to add the appropriate columns to the database. Once this is migrated in we are free to add a single image and/or multiple images to any model within an application.
$ rails active_storage:install
$ rails db:migrate
Update model to include many images
Having migrated our ActiveStorage migration we can now associate it to our Trade
model. I want a trade to have support for multiple images so the proper way to denote this is to include has_many_attached
plus whatever naming convention you'd like to call your image instances. In our case, I simply chose :images
. I also appended dependent: :destroy
so when a trade is possibly deleted the images will be too.
class Trade < ApplicationRecord
belongs_to :user
has_many_attached :images, dependent: :destroy
end
Update trades controller
In order to have the images save to the database, we need to permit the new array within the trades_controller.rb
file.
def trade_params
params.require(:trade).permit(:title, :description, :user_id, images: [])
end
Add in user-related criteria on create
and new
actions
Here we add the referenced current_user
to the mix when a trade is created. This allows us to add an user_id
to the trade
itself so each user is related to their own trades. We also set a before action to make sure anyone who creates a new trade is logged in so we can actually associate the user with the trade.
class TradesController < ApplicationController
before_action :set_trade, only: [:show, :edit, :update, :destroy]
before_action :authenticate_user!, except: [:index, :show]
# GET /trades
# GET /trades.json
def index
@trades = Trade.all
end
# GET /trades/1
# GET /trades/1.json
def show
end
# GET /trades/new
def new
@trade = current_user.trades.build
end
# GET /trades/1/edit
def edit
end
# POST /trades
# POST /trades.json
def create
@trade = current_user.trades.build(trade_params)
respond_to do |format|
if @trade.save
format.html { redirect_to @trade, notice: 'Trade was successfully created.' }
format.json { render :show, status: :created, location: @trade }
else
format.html { render :new }
format.json { render json: @trade.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /trades/1
# PATCH/PUT /trades/1.json
def update
respond_to do |format|
if @trade.update(trade_params)
format.html { redirect_to @trade, notice: 'Trade was successfully updated.' }
format.json { render :show, status: :ok, location: @trade }
else
format.html { render :edit }
format.json { render json: @trade.errors, status: :unprocessable_entity }
end
end
end
# DELETE /trades/1
# DELETE /trades/1.json
def destroy
@trade.destroy
respond_to do |format|
format.html { redirect_to trades_url, notice: 'Trade was successfully destroyed.' }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_trade
@trade = Trade.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the whitelist through.
def trade_params
params.require(:trade).permit(:title, :description, :user_id, images: [])
end
end
Generate migration for conversations
Our messaging concept requires a bit of finesse to work properly. We need a way to contain messages and reference them later. To do this I'll create a conversations model of which will feature sender_id
and recipient_id
columns. Going forward we will be able to associate a given user and conversation using these ids.
$ rails g migration createConversations
Create the table as follows:
class CreateConversations < ActiveRecord::Migration
def change
create_table :conversations do |t|
t.integer :sender_id
t.integer :recipient_id
t.timestamps
end
end
end
Generate migration for messages
While we are generating migrations we might as well generate the message migration as well. This migration has a basic body
text column as well as references to the conversation
and user
model. With the references we add an index which creates a foreign key association between conversations, messages, and users.
$ rails g migration createMessages
Create the table as follows:
class CreateMessages < ActiveRecord::Migration
def change
create_table :messages do |t|
t.text :body
t.references :conversation, index: true
t.references :user, index: true
t.timestamps
end
end
end
Generate Conversation Model
Since we already created the Conversation
table we just need a conversation.rb
file within our models
directory. You can create one from scratch or just as easily run the following. We pass --skip-migration
since we already created on before this.
ruby$ rails g model Conversation --skip-migration
Update the Conversation model
The conversation model will belong to both a sender and recipient. We created these names but ultimiately they are referenced as :sender_id
and recipient_id
of which will associate to the class User
.
On top of this we create a has_many :messages
association because a conversation will obiviously have many messages.
Next we validate the uniqueness of the sender and recipient to make sure there is only one conversation between both parties.
Andy finally, we create a scope (something we haven't discussed up until this point) which acts as a variable of sorts for a specific query to the database. We called the scope :between
as it finds the conversations between any two given users if there is one.
# app/models/conversation.rb
class Conversation < ApplicationRecord
belongs_to :sender, foreign_key: :sender_id, class_name: "User"
belongs_to :recipient, foreign_key: :recipient_id, class_name: "User"
has_many :messages
validates_uniqueness_of :sender_id, scope: :recipient_id
# This scope validation takes the sender_id and recipient_id for the conversation and checks whether a conversation exists between the two ids because we only want two users to have one conversation.
scope :between, -> (sender_id, recipient_id) do
where("(conversations.sender_id = ? AND conversations.recipient_id = ?) OR (conversations.sender_id = ? AND conversations.recipient_id = ?)", sender_id, recipient_id, recipient_id, sender_id)
end
end
Message model
The message model will belong to a conversation and user. Here we also run a quick generation to create the message.rb
file within the models
directory to streamline our workflow.
You'll notice a message_time
method of which could also be a helper if you prefer to contain those outside of your model files. This method displays each messages created at time in a nicer format.
$ rails g model Message --skip-migration
# app/models/message.rb
class Message < ApplicationRecord
belongs_to :conversation
belongs_to :user
validates_presence_of :body, :conversation_id, :user_id
def message_time
created_at.strftime("%m/%d/%y at %l:%M %p")
end
end
Conversations Controller
The conversations controller will only require a few actions. We want to definitely make sure the user is signed in by adding a before_action :authenticate_user!
just after the class decoration.
The index
action will be responsible for displaying all of the users and conversations.
The create
action uses our between
scope as mentioned before to query the database using the appropriate parameters passed in through the url.
# app/controllers/conversations_controller.rb
class ConversationsController < ApplicationController
before_action :authenticate_user!
def index
@users = User.all
@conversations = Conversation.all
end
def create
if Conversation.between(params[:sender_id], params[:recipient_id]).present?
@conversation = Conversation.between(params[:sender_id], params[:recipient_id]).first
else
@conversation = Conversation.create!(conversation_params)
end
redirect_to conversation_messages_path(@conversation)
end
private
def conversation_params
params.permit(:sender_id, :recipient_id)
end
end
Message Controller
The message controller has more logic but it's fairly straight-forward. We need an index
, create
and new
action. The index
will be responsible for displaying all the messages. We also tap into how the messages are displayed by making the experience a little more forgiving for users with a lot of messages. If the message count grows to more than ten we display a link to show more rather than just continue to grow the archive of messages.
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
before_action :find_conversation
def index
@messages = @conversation.messages
if @messages.length > 10
@over_ten = true
@messages = @messages[-10..-1]
end
if params[:m]
@over_ten = false
@messages = @conversation.messages
end
@message = @conversation.messages.new
end
def create
@message = @conversation.messages.new(message_params)
if @message.save
redirect_to conversation_messages_path(@conversation)
end
end
def new
@message = @conversation.messages.new
end
private
def message_params
params.require(:message).permit(:body, :user_id)
end
def find_conversation
@conversation = Conversation.find(params[:conversation_id])
end
end
Update the routes
Our routes will end up as follows. Notice how we nest messages within the conversations block. This makes your url structure look the way you'd expect.
require 'sidekiq/web'
Rails.application.routes.draw do
resources :trades
resources :conversations do
resources :messages
end
devise_for :users
root to: 'trades#index'
end
Sprinkle in some useful helpers
Throughout the views, we'll make use of some helpers to extract logic outside of our markup. This promotes higher readability of our code and is a little more scalable in the end if we need the logic elsewhere in our app. Here we make use of a custom gravatar helper, a markdown parser, and determine if a current user is an author of a given trade.
# app/helpers/application_helper.rb
module ApplicationHelper
def gravatar_for(user, options = { size: 200})
gravatar_id = Digest::MD5::hexdigest(user.email.downcase)
size = options[:size]
gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}"
image_tag(gravatar_url, alt: user.name, class: "border-radius-50")
end
def markdown_to_html(text)
Kramdown::Document.new(text, input: "GFM").to_html
end
def trade_author(trade)
user_signed_in? && current_user.id == trade.user_id
end
end
Trade Index View
The trade index view will look like the following. Notice how we are extracting the iterated trade into its own partial. Rails is smart enough to know that trade
will mean there is a template called _trade.html.erb
within the trades
view directory.
<!-- app/views/trades/index.html.erb -->
<h1 class="title is-1">Trades</h1>
<p class="subtitle is-5">Looking to get rid of gear but not lose money. Trade some of your gear!</p>
<div class="columns">
<% @trades.each do |trade| %>
<div class="column is-3">
<%= render trade, trade: trade %>
</div>
<% end %>
</div>
Trade index view trade partial
Here is the markup for the trade partial. We can use the trade
instance variable at will within this file since we are rendering it as a variable to use in trades/index.html.erb
.
<!-- app/views/trades/_trade.html.erb -->
<div class="card">
<div class="card-image">
<figure class="image is-square">
<%= link_to image_tag(trade.images.first.variant(resize: "640x480>")), trade %>
</figure>
</div>
<div class="card-content">
<h3 class="pt1 title is-5"><%= trade.title %></h3>
<p class="pv1"><%= truncate(trade.description, length: 120) %></p>
<div class="media pt3">
<div class="media-left pt3">
<figure class="image is-48x48">
<%= gravatar_for(trade.user, size: 96) %>
</figure>
</div>
<div class="media-content">
<p class="has-text-weight-bold mb0"><%= trade.user.name %></p>
<p class="is-italic mb0"><time>posted <%= time_ago_in_words(trade.created_at) %> ago</time></p>
</div>
</div>
</div>
</div>
Trade Show View
The show view is pretty straight forward. We tap into ActiveStorage to display our images as well as use the helpers discussed prior. We also check for a trade_author
as we want to only display certain controls to those who author their own trades.
<!-- app/views/trades/show.html.erb -->
<div class="columns">
<div class="column is-8">
<h1 class="title is-1"><%= @trade.title %></h1>
<div class="content">
<p class="pb3 border-bottom">Post <%= time_ago_in_words(@trade.created_at) %> ago</p>
<div class="pt1"><%= sanitize markdown_to_html(@trade.description) %></div>
</div>
<% if @trade.images.attached? %>
<div class="columns is-multiline">
<% @trade.images.each do |image| %>
<div class="column is-one-third">
<%= image_tag image.variant(resize: "800x600>") %>
</div>
<% end %>
</div>
<% end %>
</div>
<div class="column is-3 is-offset-1">
<% if trade_author(@trade) %>
<div class="bg-light pa3 mb4 border-radius-3">
<p class="f6 pb1">Author actions:</p>
<div class="button-group">
<%= link_to "Edit trade: #{@trade.title}", edit_trade_path(@trade), class: 'button is-small' %>
<%= link_to "Back", trades_path, class: "button is-small" %>
</div>
</div>
<% end %>
<div class="pr5 mb4">
<p class="text-align-left f6">Trade author:</p>
<div class="inline-block nudge-down-10"><%= gravatar_for @trade.user, size: 32 %></div>
<div class="inline-block"><%= @trade.user.name %></div>
</div>
<% if user_signed_in? && current_user.id != @trade.user_id %>
<%= link_to "Message #{@trade.user.name}", conversations_path(sender_id: current_user.id, recipient_id: @trade.user.id), method: 'post', class:"button is-link" %>
<% elsif user_signed_in? && current_user.id == @trade.user_id %>
<%= link_to "Conversations", conversations_path %>
<% else %>
<%= link_to "Sign up to message #{@trade.user.name}", new_user_registration_path %>
<% end %>
</div>
</div>
Messages index view
Our messages index view will display all the messages between two users. This will be an ongoing thread that users and join and leave at any time. The app itself is geared around not creating new messages each time you want to communicate but rather picking up where you left off if you've already contacted a user before.
<!-- app/views/messages/index.html.erb -->
<h1 class="title is-4">Message <%= @conversation.recipient.name %></h1>
<% if @over_ten %>
<%= link_to "Show previous", '?m=all', class:'button is-link' %>
<% end %>
<section id="messages" class="mb4">
<% @messages.each do |message| %>
<% if message.body %>
<% user = User.find(message.user_id) %>
<article class="message is-dark">
<div class="message-body">
<div class="inline-block nudge-down-10 pr2"><%= gravatar_for user, size: 32 %></div>
<div class="inline-block"><strong><%= user.name %></strong> <%= message.message_time %></div>
<div class="block pt4">
<div class="f4"><%= sanitize markdown_to_html(message.body) %></div>
</div>
</div>
</article>
<% end %>
<% end %>
</section>
<%= form_for [@conversation, @message] do |f| %>
<%= f.text_area :body, class: "textarea", placeholder: "Inquire about a trade..." %>
<%= f.text_field :user_id, value: current_user.id, type: "hidden" %>
<div class="text-align-right">
<%= f.submit "Send message", class: "button is-link is-large mt3" %>
</div>
<% end %>
Conversations index view
Here we simply display the conversations.
<div class="columns">
<div class="column is-3">
<h3 class="title is-3">All Users</h3>
<% @users.each do |user| %>
<% if user.id != current_user.id %>
<%= link_to "Message #{user.name}", conversations_path(sender_id: current_user.id, recipient_id: user.id), method: "post" %>
<% end %>
<% end %>
</div>
<div class="column is-7">
<h3 class="title is-3">Conversations</h3>
<% @conversations.each do |conversation| %>
<% if conversation.sender_id == current_user.id || conversation.recipient_id == current_user.id %>
<% if conversation.sender_id == current_user.id %>
<% recipient = User.find(conversation.recipient_id) %>
<% else %>
<% recipient = User.find(conversation.sender_id) %>
<% end %>
<% unless current_user.id == recipient %>
<div class="columns">
<div class="column">
<div class="inline-block nudge-down-10"><%= gravatar_for recipient, size: 32 %></div>
<div class="inline-block"><%= link_to recipient.name, conversation_messages_path(conversation) %></div>
</div>
</div>
<% end %>
<% end %>
<% end %>
</div>
</div>
Final Thoughts
I hope you enjoyed this build. We are knowing on our 12th installment and it's been a rewarding experience making this content for you guys. If you have any feedback, want to understand something more, or have an idea for a build please let me know. I wish I had more time to take these further but my goal is to at least help you make sense of the logic going on so you can build your own apps. I started with virtually zero knowledge of the framework and by doing these builds (as well as building my latest app Affinicasts I was able to both land a new job as well as bring my ideas to life. Stay tuned for the next one!
If you're just finding my blog, welcome! You can see the entire archive of "Let's Build: With Ruby on Rails" builds below:
- Let's Build: With Ruby on Rails - Introduction
- Let's Build: With Ruby on Rails - Installation
- Let's Build: With Ruby on Rails - Blog with Comments
- Let's Build: With Ruby on Rails - A Twitter Clone
- Let's Build: With Ruby on Rails - A Dribbble Clone
- Let's Build: With Ruby on Rails - Project Management App
- Let's Build: With Ruby on Rails - Discussion Forum
- Let's Build: With Ruby on Rails - Deploying an App to Heroku
- Let’s Build: With Ruby on Rails – eCommerce Music Shop
- Let’s Build: With Ruby on Rails – Book Library App with Stripe Subscription Payments
- Let's Build: With Ruby on Rails - Trade App With In-App Messaging
Shameless plug time
I have a new course called Hello Rails. Hello Rails is a modern course designed to help you start using and understanding Ruby on Rails fast. If you're a novice when it comes to Ruby or Ruby on Rails I invite you to check out the site. The course will be much like these builds but a super more in-depth version with more realistic goals and deliverables. View the course!
Follow @hello_rails and myself @justalever on Twitter.
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.