Code Monkey home page Code Monkey logo

rails-conversations's Introduction

Creating an internal messaging system in Rails 5: a walkthrough

Note: This app was lifted almost line by line from an excellent article by Dana Muller. I've made a few changes to her solution, but I highly recommend reading the article.

Note: This guide assumes you've already implemented a user authentication system with BCrypt.

Overview

inbox

messges

The system requires 3 models: User, Message and Conversation. The conversation is simply a container for messages. It is essentially a relationship between two users. The system we are going to create will effectively mimic a phone text message system, where the user clicks on the name of a contact and all of the messages between those two people will be displayed there.

Setup

We will not be using scaffold at all throughout this walkthrough, since we only need a few, very specific views.

Conversation model

The conversation model and respective database table is quite unusual, so we're going to build it piecemeal, starting with a custom migration:

rails g migration CreateConversations

This will give you the following migration:

class CreateConversations < ActiveRecord::Migration[5.0]
  def change
    create_table :conversations do |t|
    end
  end
end

Update it as follows:

class CreateConversations < ActiveRecord::Migration[5.0]
  def change
    create_table :conversations do |t|
      t.integer :sender_id
      t.integer :receiver_id

      t.timestamps
    end
  end
end

Then run the migration with rake db:migrate

Since the sender_id and the receiver_id will both be user ids, we need to construct a very specific model. First we'll manually create the model file:

touch app/models/conversation.rb

Then flesh out the model:

class Conversation < ApplicationRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :receiver, class_name: "User", foreign_key: "receiver_id"
end

Here, we've indicated that the sender_id and receiver_id are both ids from the user table. So when we do conversation.sender or conversation.receiver, ActiveRecord will attempt to find a user with those ids.

But wait, there's more...

A conversation will have many messages, so we can add that relationship to the model now as well:

class Conversation < ApplicationRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :receiver, class_name: "User", foreign_key: "receiver_id"
  has_many :messages, dependent: :destroy
end

We're also going to add a validation as well. We want to make sure that only one conversation is created between two users, regardless of who is the receiver and who is the sender.

If Sarah starts a conversation with Bradley, Sarah is the sender and Bradley the receiver. If Bradley wants to send Sarah a message, he should use the same conversation that has already been set up between those two users, even though he is now the sender.

class Conversation < ApplicationRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :receiver, class_name: "User", foreign_key: "receiver_id"
  has_many :messages, dependent: :destroy

  validates_uniqueness_of :sender_id, scope: :receiver_id
end

The scope option limits the uniqueness check.

Note: more info on ActiveRecord validations can be found in the official docs

Finally, we need to add a class method between which will allow us to find the conversation between to users, regardless of who is the sender and who is the receiver. To create a class method with ActiveRecord, we use the scope method, like so:

class Conversation < ApplicationRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :receiver, class_name: "User", foreign_key: "receiver_id"
  has_many :messages, dependent: :destroy

  validates_uniqueness_of :sender_id, scope: :receiver_id

  scope :between, -> (sender_id,receiver_id) do
    where("(conversations.sender_id = ? AND conversations.receiver_id = ?) OR (conversations.receiver_id = ? AND conversations.sender_id = ?)", sender_id, receiver_id, sender_id, receiver_id)
  end
end

Here we have created some custom SQL. Conversation.between will take two arguments, both user ids, and try to find a conversation that has either one as sender OR receiver. We be using this later.

Message model

This will be a lot simpler! We can generate the model directly like so:

rails g model Message body:text conversation:references user:references read:boolean

Before running the migration that is created, we need to set read to be false by default. Amend the migration like so:

class CreateMessages < ActiveRecord::Migration[5.0]
  def change
    create_table :messages do |t|
      t.text :body
      t.references :conversation, foreign_key: true
      t.references :user, foreign_key: true
      t.boolean :read, default: false

      t.timestamps
    end
  end
end

Then rake db:migrate

We're just going to add a validation to the message model to ensure all of the fields are filled in:

class Message < ApplicationRecord
  belongs_to :conversation
  belongs_to :user

  validates_presence_of :body, :conversation_id, :user_id
end

While we're here, we can add some formatting to the timestamp, so we can display the time of the message in a more human readable way:

class Message < ApplicationRecord
  belongs_to :conversation
  belongs_to :user

  validates_presence_of :body, :conversation_id, :user_id

  private
    def message_time
      created_at.strftime("%d/%m/%y at %l:%M %p")
    end
end

Done and done.

Routes

The routing for this system is actually very straightforward. We want a conversations index page, which will display all the conversations a user has. We also want a conversations_messages index page that will display all the messages for that conversation.

We can do that very simply like so:

# config/routes.rb
resources :conversations, only: [:index, :create] do
  resources :messages, only: [:index, :create]
end

You can see how that has affected your app's routes by typing rails routes in the terminal.

Conversations controller

The conversations controller will basically handle showing all conversations, and creating new conversations when needed. It will only need index and create methods.

Lets make this now:

rails g controller conversations index

This will create the controller and an index.html.erb file in app/views/conversations/

Let's flesh out the controller:

class ConversationsController < ApplicationController
  before_action :authenticate

  def index
    @conversations = Conversation.where("sender_id = ? OR receiver_id = ?", current_user.id, current_user.id)
    @users = User.where.not(id: current_user.id)
  end
end

For the index, we will display a list of all the conversations that the current user has on the go, and a list of the users that are signed up to the app.

Notice that I have added the authenticate method as a before_action. The user must be logged in to view their messages!

Let's now add the create method. First we will check if a conversation already exists between the two users. If it does we will redirect the user to that conversation's messages index page. If not, we will create the conversation and then redirect the user.

class ConversationsController < ApplicationController
  before_action :authenticate

  def index
    @users = User.where.not(id: current_user.id)
    @conversations = Conversation.where("sender_id = ? OR receiver_id = ?", current_user.id, current_user.id)
  end

  def create
    if Conversation.between(params[:sender_id], params[:receiver_id]).present?
      @conversation = Conversation.between(params[:sender_id], params[:receiver_id]).first
    else
      @conversation = Conversation.create!(conversation_params)
    end

    redirect_to conversation_messages_path(@conversation)
  end

  private
    def conversation_params
      params.permit(:sender_id, :receiver_id)
    end
end

This is where the between method we created in the model comes in to play.

The view

There's only one view we need to create here. Here's a skeleton view which you can amend and style to your liking:

<h1>Inbox</h1>
<ul>
  <% @conversations.each do |conversation| %>
    <% recipient = conversation.sender_id == current_user.id ? conversation.receiver : conversation.sender %>
    <li><%= link_to recipient.username, conversation_messages_path(conversation) %></li>
  <% end %>
</ul>


<h2>Users</h2>
<ul>
  <% @users.each do |user| %>
    <li><%= link_to user.username, conversations_path(sender_id: current_user.id, receiver_id: user.id), method: :post %></li>
  <% end %>
</ul>

Messages controller

As before we can generate the controller like so:

rails g controller messages index

Here's the completed controller. Take a look, then I'll talk you through it:

class MessagesController < ApplicationController
  before_action :authenticate

  before_action do
    @conversation = Conversation.find(params[:conversation_id])
  end

  def index
    @messages = @conversation.messages

    @messages.where("user_id != ? AND read = ?", current_user.id, false).update_all(read: true)

    @message = @conversation.messages.new
  end

  def create
    @message = @conversation.messages.new(message_params)
    @message.user = current_user

    if @message.save
      redirect_to conversation_messages_path(@conversation)
    end
  end

  private
    def message_params
      params.require(:message).permit(:body, :user_id)
    end
end

We start by ensuring the user is logged in. Since we are always going to need the current conversation regardless of what we are doing, we can use the before_action hook to pull that from the database.

In the index method, we can pull out all of the messages from the conversation. Since all the messages will be on the page, we can assume the user has read all the unread messages that were sent to her, so we update the read attribute of any messages sent by the other user to be true. After that, we create a new message, ready for the user to add the content.

The create method is standard, we save the message and redirect to the same page, so the user can see their new message has been added to the conversation.

The view

Again, a skeleton view for your consideration:

<ul>
  <% @messages.each do |message| %>
    <% if message.body %>
      <li>
        <h4><%= message.user.username %></h4>
        <p><%= message.body %></p>
      </li>
    <% end %>
  <% end %>
</ul>

<%= form_for [@conversation, @message] do |f| %>
  <div class="field">
    <%= f.text_area :body, placeholder: "Your message" %>
  </div>

  <%= f.submit "Send" %>
<% end %>

The only thing here that's unusual is the form_for tag. We're passing two models. This will mean that the form points to the correct url, eg: /conversations/1/messages/

Cleaning up our views

The ideal goal with the MVC design pattern is to have the model take care of all the data, and as much logic as possible, leaving our views and controllers as clean as possible. We can remove the receiver variable from the conversations index page and move the logic into the model:

# app/models/conversation.rb
class Conversation < ApplicationRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :receiver, class_name: "User", foreign_key: "receiver_id"
  has_many :messages, dependent: :destroy

  validates_uniqueness_of :sender_id, scope: :receiver_id

  scope :between, -> (sender_id, receiver_id) do
    where("(conversations.sender_id = ? AND conversations.receiver_id = ?) OR (conversations.receiver_id = ? AND conversations.sender_id = ?)", sender_id, receiver_id, sender_id, receiver_id)
  end

  def recipient(current_user)
    self.sender_id == current_user.id ? self.receiver : self.sender
  end

end

We've created a new method recipient, which will return the other user (ie. not the current_user) from the conversation. Unfortunately we have to pass current_user into the method since current_user is not available in the model by design.

Note: Some more info about that from http://stackoverflow.com/questions/2513383/access-current-user-in-model

Now we can use it in our view

<h1>Inbox</h1>

<ul>
  <% @conversations.each do |conversation| %>
    <li>
      <%= link_to conversation.recipient(current_user).username, conversation_messages_path(conversation) %>
    </li>
  <% end %>
</ul>

...

Ah, that's much nicer

Unread message count

Finally, lets display the number of unread messages in a conversation. Again, not all the unread messages, just the unread messages that were posted by the other user. Let's make a new method in the model to handle that:

class Conversation < ApplicationRecord
  belongs_to :sender, class_name: "User", foreign_key: "sender_id"
  belongs_to :receiver, class_name: "User", foreign_key: "receiver_id"
  has_many :messages, dependent: :destroy

  validates_uniqueness_of :sender_id, scope: :receiver_id

  scope :between, -> (sender_id, receiver_id) do
    where("(conversations.sender_id = ? AND conversations.receiver_id = ?) OR (conversations.receiver_id = ? AND conversations.sender_id = ?)", sender_id, receiver_id, sender_id, receiver_id)
  end

  def recipient(current_user)
    self.sender_id == current_user.id ? self.receiver : self.sender
  end

  def unread_message_count(current_user)
    self.messages.where("user_id != ? AND read = ?", current_user.id, false).count
  end

end

Great, let's update the view

<h1>Inbox</h1>

<ul>
  <% @conversations.each do |conversation| %>
    <li>
      <%= link_to conversation.recipient(current_user).username, conversation_messages_path(conversation) %>
      <% if !conversation.unread_message_count(current_user).zero? %>
        (<%= conversation.unread_message_count(current_user) %>)
      <% end %>
    </li>
  <% end %>
</ul>

If there are unread messages (ie. the unread_message_count is not 0), then we can display them on the screen.

Note: if you're using bootstrap [http://getbootstrap.com/components/#list-group-badges](a list group with badges) might be useful here.

rails-conversations's People

Contributors

mickyginger avatar

Watchers

 avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.