paint-brush
Ruby on Rails Facebook Implementation: SpyBookby@Aaron Rory
11,144 reads
11,144 reads

Ruby on Rails Facebook Implementation: SpyBook

by Aaron NewboldMarch 6th, 2020
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

The Odin Project is attempting to build a Facebook replica using Ruby on Ruby on Rails. The Odin project will build a replica of Facebook's core features such as users, profiles, “friending”, posts, comments, news feed, and “liking’ We will also implement a sign-in feature by using the real Facebook through the use of gems such as ‘OmniAuth’ and ‘Devise’ The project will need a Users Table, Friendship Requests Table, Posts Table, Comments Table, Likes Table and a Notifications Table.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Ruby on Rails Facebook Implementation: SpyBook
Aaron Newbold HackerNoon profile picture

According to the Odin Project: the goal of this exercise is to build some of the baseline features found in one of the more popular social media website applications, Facebook.

While following this article we will be putting together some of the core features of the platform — users, profiles, “friending”, posts, comments, news feed, and “liking”. We will also implement a sign-in feature by using the real Facebook through the use of gems such as ‘OmniAuthand ‘Devise.

Entity Relationship Diagram (ERD) And Association Planning

In our Facebook replica Ruby on Rails Project we will need specific tables within our database to hold certain table relative information. So, with this in mind we must plan ahead and think about all the information we will need to store and access.

Well, for just about every piece of software out there that has been created with the goal of interaction between multiple users in mind. There must be some type of database table containing information regarding these users. I.e. their names, emails, passwords, ids and just about any other information needed from the user to accomplish the software’s goals.

Since we are attempting to build an application containing the core features of Facebook, we will need a Users Table, Friendship Requests Table, Posts Table, Comments Table, Likes Table and a Notifications Table.

Users Table

Information we will want stored within this table will be column id (integer type), email, first name, last name, password digest and an image (perhaps to hold the name of a profile picture).

All the columns for the user table will be strings except for the primary key which is the column’s id.

Friendship Requests Table

Information we will want stored in this table will be column id (primary key, integer type), sent_to_id (foreign key, integer), sent_by_id (foreign key, integer) and status (Boolean).

The two foreign keys are pretty much self-explanatory, the sent_by_id contains the ID of the User sending the friend request and the sent_to_id contains the ID of the User the friend request was sent to. Status determines whether or not the friend request was accepted.

Posts Table

Information we will want stored in this table will be column id (primary key, integer), content (text) and user_id (foreign key, integer).

The content will be the column that holds the information of the post and the user_id will hold the ID of the User that created the post.

Comments Table

Information we will want stored in this table will be the column id (primary key, integer), content (text), post_id (foreign key, integer) and user_id (foreign key, integer).

The Comments table is similar to the Posts table with the addition of a post_id foreign key which points to the ID of the Post this comment is associated with.

Likes Table

Information we will want stored in this table will be the column id (primary key, integer), post_id (foreign key, integer) and comment_id (foreign id, integer).

Similar to both the Posts and Comments table the Likes table will hold references to either the ID of the Post that was liked or the Comment that was liked.

Notifications Table

Information we will want stored in this table will be the column id (primary key, integer), notice_id (integer) and type (string).

The notice_id will hold the ID of either a Post, a Comment or a Friend Request. The type will be a string which holds the string representation of which Table the notice_id belongs to. For example, a Comment’s ID can be stored in notice_id and the string “comment” will be stored in the type column.

This notifications model will also have a reference to the user who is going to be notified when someone likes one of their posts, comments on one of their posts or sends them a friend request. This will be a rather basic notification model which will not give reference to the user who caused the notification to be sent.

After planning out the tables and their associations we came to conclude that:

Chart built using website: LucidChart.com

Project Setup

Creating a new Rails app using PostgreSQL

Source: Getting Started with Rails: Heroku

To automatically have rails set up the postgresql (pg) gem for us we will use the following line in the terminal to build our new app:

$ rails new fakebook --database=postgresql 

Gems to Install

All the below gems should be added or already included in your Gemfile

# Use postgresql as the database for Active Record
gem 'pg', '>= 0.18', '< 2.0'
# Rubocop gem to correct linter related issues keeping your code close to standard coding practices
gem 'rubocop'
# Devise security gem
gem 'devise'
# To handle images
gem 'carrierwave', '~> 2.0'
gem 'mini_magick'
# Use omniauth-facebook gem allows Facebook login integration
gem 'omniauth-facebook'

Optional Gems

# Allows access to twitter's Bootstrap framework
gem 'bootstrap'
# Hirb gem organizes the display for active record information into tables when using the rails console… eg. After opening rails console type Hirb.enable to activate it
gem 'hirb'
# All gems below are related to the RSpec Gem except the dotenv-rails gem
group :development, :test do
  # RSpec Testing
  gem 'database_cleaner'
  gem 'rspec-rails'
  # A Ruby gem to load environment variables from `.env` files.
  gem 'dotenv-rails'
end
group :test do
  gem 'capybara'
  gem 'selenium-webdriver'
end

After adding all the necessary gems into the Gemfile (Generally found at the root directory). Run the bundle install command into the terminal:

$ bundle install 

or the shorthand ‘bundle’:

$ bundle 

Setting up PostgreSQL Database

Source: Ruby on Rails with PostgresSQL

First ensure that PostgreSQL is installed by running this apt command within the terminal:

$ apt-get -y install postgresql postgresql-contrib libpq-dev 

In some cases, you may need to run this command with administrative authority using sudo:

$ sudo apt-get -y install postgresql postgresql-contrib libpq-dev

When the installation is done, login to the postgres user and access the postgresql shell.

$ su — postgres 
$ psql

In some cases, you may need to run this command with administrative authority using sudo:

$ sudo su — postgres 

Next, give the postgres user a new password with command below:

$ \password postgres
 Enter new password:

Next, create a new role named ‘rails-dev’ with the password ‘aqwe123’ using the command below:

$ create role rails_dev with createdb login password ‘aqwe123’;

You don’t have to create this role using ‘rails-dev’ or password ‘aqwe123’ but just remember what you used.

Now check to see if the new role was successfully created:

$ /du

List of all roles and their permissions in Postgres.

Getting Rails App to connect to PostgreSQL Database

Now you will need to edit your database.yml (

/config /database.yml/
) file to include the role’s username and password you just created.

In the development section, uncomment line 32 and type the name of the role we’ve created in earlier ‘rails_dev’.

username: rails_dev

Set the password field on line 35 to the password you used for the ‘rails_dev’ role.

password: aqwe123

Uncomment line 40 and 44 for the database host configuration.

host: localhost
port: 5432

Now go to the test section and add the new configuration below. The database will be the name of your rails app followed by an ‘_test’:

database: fakebook_test
 host: localhost
 port: 5432
 username: rails_dev
 password: aqwe123

After editing your database.yml file it should look similar to this, but the database may be different depending on what you named your rails app.

# PostgreSQL. Versions 9.3 and up are supported.
#
# Install the pg driver:
# gem install pg
# On macOS with Homebrew:
# gem install pg - - with-pg-config=/usr/local/bin/pg_config
# On macOS with MacPorts:
# gem install pg - - with-pg-config=/opt/local/lib/postgresql84/bin/pg_config
# On Windows:
# gem install pg
# Choose the win32 build.
# Install PostgreSQL and put its /bin directory on your path.
#
# Configure Using Gemfile
# gem 'pg'
#
default: &default
adapter: postgresql
encoding: unicode
# For details on connection pooling, see Rails configuration guide
# https://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
development:
<<: *default
database: fakebook_development

# The specified database role being used to connect to postgres.
# To create additional roles in postgres see `$ createuser - help`.
# When left blank, postgres will use the default role. 
# This is
# the same name as the operating system user that initialized the database.
username: rails_dev

# The password associated with the postgres role (username).
password: aqwe123

# Connect on a TCP socket. Omitted by default since the client uses 
# a domain socket that doesn't need configuration. Windows does not 
# have domain sockets, so uncomment these lines.
host: localhost
# The TCP port the server listens on. Defaults to 5432.
# If your server runs on a different port number, change
# accordingly.
port: 5432

# Schema search path. The server defaults to $user,public
#schema_search_path: myapp,sharedapp,public
# Minimum log levels, in increasing order:
# debug5, debug4, debug3, debug2, debug1,
# log, notice, warning, error, fatal, and panic
# Defaults to warning.
# min_messages: notice
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
  <<: *default
  database: fakebook_test
  host: localhost
  port: 5432
  username: rails_dev
  password: aqwe123
# As with config/credentials.yml, you never want to store sensitive # information,like your database password, in your source code.
# If your source code is
# ever seen by anyone, they now have access to your database.
#
# Instead, provide the password as a unix environment variable when # you boot the app.
# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full rundown on how to provide these environment variables
# in a production deployment.
#
# On Heroku and other platform providers, you may have a full 
# connection URL available as an environment variable. For example:
#
# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase"
#
# You can use this database configuration with:
#
# production:
# url: <%= ENV['DATABASE_URL'] %>
#
production:
  <<: *default
  database: fakebook_production
  username: rails_dev
  password: <%= ENV['FAKEBOOK_DATABASE_PASSWORD'] %>

Next, generate the database with the rails command:

$ rails db:setup

If you added the optional gem ‘gem ‘dotenv-rails’’ (Documentation) you can use a ‘.env’ file which you would create at the root directory to store a ‘USERNAME’ and ‘PASSWORD’ value. Within the file named ‘.env’ you would put:

USERNAME = 'rails_dev'
PASSWORD = 'aqwe123'

Then in the database.yml(

/config /database.yml/
) file you would swap all instances of the value of username and password:

username: rails_dev
password: aqwe123

would be changed to:

username: <%=ENV['USERNAME']%>
password: <%=ENV['PASSWORD']%>

Bootstrap (Optional)

Ensure that you have included the gem ‘bootstrap’ in your Gemfile.

Application.css to Application.scss

Look for the file ‘application.css’ (app/assets/stylesheets/application.css) and change the name of the file from ‘application.css to ‘application.scss’ switching it to a Sass file.

Open the file and Erase lines 13 and 14

*= require_tree .
*= require_self

and add in this new line to import Bootstrap CSS

// Custom bootstrap variables must be set or imported *before* bootstrap.
@import "bootstrap";
@import "custom";

Import Bootstrap module and dependencies

Now you will need to add a few files to your yarn file. Within the terminal run this command:

$ yarn add bootstrap jquery popper.js

Next, make your way into the ‘application.js’ file found in the directory (

app/javascript/packs/application.js
) and add these lines in

import 'bootstrap';
import 'jquery';
import 'popper.js';

This should allow you to use all of the latest bootstrap classes within your rails views.

Using Devise Gem

Source: Devise

After adding all necessary gems and running bundle install, you will need to install devise. In the terminal use the command:

$ rails generate devise:install

At this point, a number of instructions will appear in the console. Among these instructions, you’ll need to set up the default URL options for the Devise mailer in each environment.

Within the ‘development.rb’ file (

config/environments/development.rb
) add the following line:

config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

Users Model

After Devise has been properly installed you will now use it to generate the User model. Using the provided parameter on the rails generate command.

$ rails generate devise User

However, since we wish to also add a First name, Last name and Image column to our User model according to the ERD provided up above. We will add a few more parameters onto the terminal command.

$ rails generate devise User fname:string lname:string image:string

We won’t need to add email as a parameter or password digest because Devise handles this for us.

Afterwards we will migrate the database to Add the newly created Users Table to the Database:

$ rails db:migrate

– Don’t forget to run

$ rails db:migrate
after you create each model. Especially those that will be referenced

Permitting additional parameters with Devise

Since we are using Devise to handle the sign up and sign in processes, we will have to add a few lines within the application controller to allow the retrieval of the fname, lname and image columns parameters from our future forms for User creation. The change we will make will be similar to this:

app/controllers/application.rb

1  class ApplicationController < ActionController::Base
2    before_action :authenticate_user!
3    before_action :configure_permitted_parameters, if: devise_controller?
4  
5    protected
6  
7    def configure_permitted_parameters
8      devise_parameter_sanitizer.permit(:sign_up, keys: %i[fname lname image])
9      devise_parameter_sanitizer.permit(:account_update, keys: %i[fname lname image])
10   end
11 end

Notice that on lines 8 and 9, fname, lname and image, are added to the list of symbols within the keys: parameter. Also take note that on line 2 we added the before_action method and ordered it to run the :authenticate_user! method. Since this is placed within the application controller, this method is ran before every controller action, forcing users to sign in before they attempt to do anything.

Users Controller

Following the general Rails Model View Controller (MVC) model we will also generate a controller for our User model.

$ rails generate controller Users index show

The added parameters index and show causes Rails to create two empty methods within the generated Users controller(app/controllers/users_controller) as well as two related views (

app/views/users
) and routes (
config/routes.rb
) to these methods.

Devise Views

Now, since we added additional columns onto the base Devise User model (fnamelname and image), we will need to add those fields to the sign-up form. Since Devise handles log ins and sign-ups we will need access to the views that Devise creates. To gain access to these views we will need to run the following command in the terminal:

$ rails generate devise:views

This command creates and adds all of the views that Devise uses to the project folder. There is a way to add parameters to the function above to limit the amount of views created to avoid clutter, see devise documentation for that (Documentation).

Add first name and last name fields to registration page

<div class="col-md-6 mx-auto mb-5">
  <h1 class="center bold">Sign up</h1>
  <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
    <%= render "devise/shared/error_messages", resource: resource%>
    <div class="form-group row">
      <div class="col">
        <%= f.label :first_name %>
        <%= f.text_field :fname, class:"form-control col", autofocus: true, autocomplete: "fname" %>
      </div>
      <div class="col">
        <%= f.label :last_name %><br />
        <%= f.text_field :lname, class:"form-control col", autofocus: true, autocomplete: "lname" %>
      </div>
    </div>
    <div class="form-group">
      <%= f.label :email %><br />
      <%= f.email_field :email, class:"form-control", autofocus: true, autocomplete: "email" %>
   </div>
   <div class="form-group">
     <%= f.label :password %>
     <% if @minimum_password_length %>
       <em>(<%= @minimum_password_length %> characters minimum)</em>
     <% end %><br />
    <%= f.password_field :password, class:"form-control", autocomplete: "new-password" %>
   </div>
   <div class="form-group">
     <%= f.label :password_confirmation %><br />
     <%= f.password_field :password_confirmation, class:"form-control", autocomplete: "new-password" %>
   </div>
   <div class="row">
     <div class="actions col-md-3">
       <%= f.submit "Sign up", class:"btn btn-secondary" %>
     </div>
    <div class="col">
      <%= render "devise/shared/links" %>
    </div>
   </div>
  <% end %>
</div>

Bootstrap classes are used in the code above but you can use your own CSS classes or the defaults found in the file. Now with this code we should have the addition of two new form fields, first_name and last_name.

Adding Image Uploads

Source: Carrier Wave Uploader

Ensure that the ‘carrierwave’ and the ‘mini_magick’ gems are included in your Gemfile and are ‘bundle installed’.

Afterwards, run the command:

$ rails generate uploader Image

This should create a file ‘image_uploader.rb’ in the directory

app/uploaders/image_uploader.rb

Next, you are going to open this file and uncomment lines 4 and 38–40

Then you will add this code on line 5:

process resize_to_limit: [400, 400]

This line of code simply resizes any image uploaded which has a greater width or height than 400 pixels back to 400 pixels width and 400 pixels height. You may change this value as you see fit.

Add mount_uploader to User

1  class User < ApplicationRecord
2    mount_uploader :image, ImageUploader
3    # Include default devise modules. Others available are:
4    # :confirmable, :lockable, :timeoutable, :trackable and   
5    # :omniauthable
6    devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable,
7           :omniauthable, omniauth_providers: %i[facebook]
8    validate :picture_size
9  
10   private
11    # Validates the size of an uploaded picture.
12    def picture_size
13      errors.add(:image, 'should be less than 1MB') if image.size > 1.megabytes
14    end
15  end

On line 2, the mount_uploader method is added from the carrierwave gem and the ‘ImageUploader’ parameter supplied references the name of the uploader you generated. We use ‘ImageUploader’ since we generated an uploader called image (app/uploaders/image_uploader.rb).

On lines 12–14 we create a new private method called ‘picture_size’ which returns an error if the image uploaded is greater than 1 megabyte.

And on line 8 we add the ‘picture_size’ method into the validation requirements for the User model.

Add image field to registration page

app/views/devise/registrations/new.html.erb

<!-- ...Doesn't show other code up above -->
<div class="picture form-group">
  <%= f.label :upload_your_profile_picture %><br />
  <%= f.file_field :image, class:"form-control", accept: 'image/jpeg,image/gif,image/png' %>
</div>
<div class="row">
  <div class="actions col-md-3">
    <%= f.submit "Sign up", class:"btn btn-secondary" %>
  </div>
  <div class="col">
    <%= render "devise/shared/links" %>
  </div>
</div>
<% end %>
</div>

Following the same format we used earlier we add another field to the registration page form allowing the uploading of images.


<script type="text/javascript">
document.getElementById('user_image').addEventListener('change', function() {
  var size_in_megabytes = this.files[0].size/1024/1024;
  if (size_in_megabytes > 1) {
    alert('Maximum file size is 1 MB. Please choose a smaller      
    file.');
  }
});
</script>

Using the script tag we add some JavaScript to the end of the page which displays an alert error when the file uploaded is greater than 1 MB in size.

Posts Model

Using:

$ rails generate model Post content:text user:references

We will generate a Post model with two columns content and user_id. The user:references parameter will automatically add the line ‘ belongs_to :user’ in:

app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
end

This line creates an association between Posts and Users. Which basically says a Post can belong to a User leading to the inverse link of the association where a User is allowed to have many posts. To finish creating this link we will now need to go into the User model and add this line in:

app/models/user.rb

class User < ApplicationRecord
  has_many :posts
end

Posts Controller

Using:

$ rails generate controller Posts index show new create

Just as before when we generated the Users controller with additional parameters empty methods will be added into the controller file, views will be created and routes to these methods will be added into

config/routes.rb

Comments Model

Using:

$ rails generate model Comment content:text post:references user:references

This generates a Comment model with three columns content, post_id and user_id. The user:references parameter will automatically add the lines ‘belongs_to :user’ and ‘ belongs_to :post ’ in:

app/models/comment.rb

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :post
end

This line creates an association between Comments and Users as well as Comments and Posts. Which basically says a Comment can belong to one User and one Post leading to the inverse link of the association where a User is allowed to have many posts and a Post is allowed to have many comments. To finish creating this link we will now need to go into the User model and add this line in:

app/models/user.rb

class User < ApplicationRecord
  has_many :comments
end

as well as this line in the Post model:

app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
end

We will generate a Comments Controller using:

$ rails generate controller Comments new create

Since we only need to be able to create comments and all comments will only be view-able on the same page as the post it was commented under.

Likes Model

Using:

$ rails generate model Likes user:references post:references comment:references

This generates a Like model with three columns user_id, post_id and comment_id. The parameters given will automatically add the lines ‘belongs_to :user’ and ‘belongs_to :post’ and ‘belongs_to :comment’ in:

app/models/like.rb

class Like < ApplicationRecord
  belongs_to :user
  belongs_to :post
  belongs_to :comment
end

We will also want to add some additional values to the belongs_to method on post and comment to allow null values for the two foreign keys.

To do this we will add the ‘optional: true’ parameter.

app/models/like.rb

class Like < ApplicationRecord
  belongs_to :user
  belongs_to :post, optional: true
  belongs_to :comment, optional: true
end

This line creates an association between Likes and Users , Likes and Posts as well as Likes and Comments. Which basically says a Like can belong to one User and one Post or one Comment. 

Now, as we did previously we need to add the inverse of this association to the appropriate models:

User model

app/models/user.rb

class User < ApplicationRecord
  has_many :likes, dependent: :destroy
end

The added parameter ‘dependent :destroy‘ allows us to delete records from the Likes table without SQL association errors. 

If we intend on allowing comments or posts to be deleted, we can also add those parameters onto the has_many methods regarding posts and comments.

as well as in the Post model:

app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
  has_many :comments
  has_many :likes, dependent: :destroy
end

lastly, the Comment model:

app/models/comment.rb

class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :post
  has_many :likes, dependent: :destroy
end

Now, we want to allow the post and comment foreign key columns to be allowed to be null. To do this we edit the generated migration found in:

db/migrate/***_create_likes.rb

1  class CreateLikes < ActiveRecord::Migration[6.0]
2    def change
3      create_table :likes do |t|
4        t.references :user, null: false, foreign_key: true
5        t.references :post, null: true, foreign_key: true
6        t.references :comment, null: true, foreign_key: true
7
8        t.timestamps
9      end
10   end
11  end

On Lines 5 and 6 we set the parameter null where ‘null: false‘ to ‘null: true‘.

We will generate a Likes Controller using:

$ rails generate controller Likes create

Friendship Request Model

Using:

$ rails generate model Friendship sent_to:references sent_by:references

Now before we start editing the Friendship model that was created, we will need to add some code to the newly generated Migration:

db/migrate/***_create_friendships.rb

class CreateFriendships < ActiveRecord::Migration[6.0]
  def change
    create_table :friendships do |t|
      t.references :sent_by, null: false, foreign_key: { to_table: :users }
      t.references :sent_to, null: false, foreign_key: { to_table: :users }
      t.boolean :status, default: false
      
      t.timestamps
    end
  end
end

Originally ‘foreign_key:’ would have been set to ‘true’ but instead we set it to ‘foreign_key: { to_table: :users }’ linking it to the users table.

Afterwards, we can run $ rails db:migrate , then we are going to add some extra parameters into the Friendship.rb model created as well as two scopes:

app/models/Friendship.rb

class Friendship < ApplicationRecord
  belongs_to :sent_to, class_name: ‘User’, foreign_key: ‘sent_to_id’
  belongs_to :sent_by, class_name: ‘User’, foreign_key: ‘sent_by_id’
  scope :friends, -> { where(‘status =?’, true) }
  scope :not_friends, -> { where(‘status =?’, false) }
end

The scopes created simply provide all records in the Friendship Table where the status column is equal to true (for the friends scope) or false (not_friends scope).

The ‘belongs_to’ associative links create two associations between Friendships and Users. Sent_to and sent_by linking it to the columns ‘sent_to_id’ and ‘sent_by_id’ as foreign keys. To finish creating this link we will now need to go into the User model and add these lines in:

1  class User < ApplicationRecord
2    has_many :friend_sent, class_name: 'Friendship',
3                          foreign_key: 'sent_by_id',
4                          inverse_of: 'sent_by',
5                          dependent: :destroy
6    has_many :friend_request, class_name: 'Friendship',
7                          foreign_key: 'sent_to_id',
8                          inverse_of: 'sent_to',
9                          dependent: :destroy
10   has_many :friends, -> { merge(Friendship.friends) },
11            through: :friend_sent, source: :sent_to
12   has_many :pending_requests, -> { merge(Friendship.not_friends) },
13            through: :friend_sent, source: :sent_to
14   has_many :received_requests, -> { merge(Friendship.not_friends) },
15            through: :friend_request, source: :sent_by
16  end

Lines 2–5 and 6–9 are the inverse associations created to link to the sent_to and sent_by associations made in the Friendship model (

app/models/friendship.rb
).

On lines 2–5 an association named friend_sent is created, the class_name (The name of the table or model this association is linked to) assigned is the Friendship Table and the foreign_key that this association is linked to within the Friendship Table is ‘sent_by_id’.

It is also set as the inverse_of: sent_by’, sent_by being the name of the association we created in the Friendship model on Line 3

app/models/Friendship.rb

3  belongs_to :sent_by, class_name: 'User', foreign_key: 'sent_by_id'

which explicitly declares bi-directional associations. The friend_sent association also has the dependent: :destroy parameter to allow records relating to the association to be destroyed independent of the associative link.

On lines 6–9 an association named friend_request is created, the class_name assigned is the Friendship Table and the foreign_key that this association is linked to within the Friendship Table is ‘sent_to_id’.

It is also set as the inverse_of:sent_to’, sent_to being the name of the association we created in the Friendship model on Line 2:

app/models/Friendship.rb

2  belongs_to :sent_to, class_name: ‘User’, foreign_key: ‘sent_to_id’

On line 10–11 an association named friends is made, through the previously made association friend_sent.

10  has_many :friends, -> { merge(Friendship.friends) },
11           through: :friend_sent, source: :sent_to

The following is the SQL generated from the code:

SELECT "users".* FROM "users" INNER JOIN "friendships"
ON "users"."id" = "friendships"."sent_to_id" 
WHERE "friendships"."sent_by_id" = $1 AND (status =TRUE) LIMIT $2

This SQL statement SELECTS ALL columns from the Users Table WHERE the User.id is equivalent to the “sent_to_id” found in the Friendships Table AND the status column on that record has to be TRUE (Meaning that the two users are in a mutual friendship). 

The “sent_by_id” on that record will be equivalent to the User.id of the user of whom we wish to find out who all his friends are.

So, essentially this statement returns to Rails all the records from the Users Table where the supplied requirements are met. Records of all users considered to be the current user’s friends.

On line 12–13 an association named “pending_requests” is made, through the previously made association “friend_sent”.

12  has_many :pending_requests, -> { merge(Friendship.not_friends) },
13           through: :friend_sent, source: :sent_to

The following is the SQL generated from the code:

SELECT "users".* FROM "users" INNER JOIN "friendships"
ON "users"."id" = "friendships"."sent_to_id"
WHERE "friendships"."sent_by_id" = $1 AND (status =FALSE) LIMIT $2

This SQL statement SELECTS ALL columns from the Users Table WHERE the User.id is equivalent to the “sent_to_id” found in the Friendships Table AND the status column on that record has to be FALSE (Meaning that the two users are not in a mutual friendship). 

The “sent_by_id” on that record will be equivalent to the User.id of the user of whom we wish to know who all he sent friend requests to.

Therefore, this association returns the users that have received friend requests from the chosen user.

On line 14–15 an association named “received_requests” is made, through the previously made association “friend_request”.

14   has_many :received_requests, -> { merge(Friendship.not_friends) },
15            through: :friend_request, source: :sent_by

The following is the SQL generated from the code:

SELECT "users".* FROM "users" INNER JOIN "friendships"
ON "users"."id" = "friendships"."sent_by_id"
WHERE "friendships"."sent_to_id" = $1 AND (status =FALSE) LIMIT $2

This SQL statement SELECTS ALL columns from the Users Table WHERE the User.id is equivalent to the “sent_by_id” found in the Friendships Table AND the status column on that record has to be FALSE (Meaning that the two users are not in a mutual friendship).

The “sent_to_id” on that record will be equivalent to the User.id of the user of whom we wish to see who all sent him friend requests.

So, this association returns the users that has sent friend requests to a chosen user.

We will generate a Friendship Request Controller using:

Using:

$ rails generate controller Friendships create

Notifications Model

Using:

$ rails generate model Notification notice_id:integer notice_type:string user:references

This generates a Friendship model with 3 columns notice_id, notice_type and user_id

The notice_id will be the id of either the post that was written, comment that was added to one of the user’s posts or a friendship request that was sent to the user. 

The notice_type keeps a string value record of whether the notice made is a reference to either the Posts Table, Comments Table or Friendship Table

The user:references parameter will automatically add the line ‘belongs_to :user’ into (

app/models/notification.rb
).

Within the newly generated Notification model we are going to add three scopes:

app/models/notification.rb

class Notification < ApplicationRecord
  belongs_to :user
  scope :friend_requests, -> { where('notice_type = friendRquest') }
  scope :likes, -> { where('notice_type = like') }
  scope :comments, -> { where('notice_type = comment') }
end

The scopes created will allow us to single out notifications by type, e.g. Likes, comments or friend requests.

This line creates two associations between Notification and Users. To finish creating this link we will now need to go into the User model and add:

app/models/user.rb

class User < ApplicationRecord
  has_many :notifications, dependent: :destroy
end

There is no need for a Notifications controller, however, the main methods we will use to create notifications we will create in the application helper:

module ApplicationHelper 
  # Returns the new record created in notifications table
  def new_notification(user, notice_id, notice_type)
    notice = user.notifications.build(notice_id: notice_id,   
    notice_type: notice_type)
    user.notice_seen = false
    user.save
    notice
  end
  
  # Receives the notification object as parameter along with a type
  # and returns a User record, Post record or a Comment record
  # depending on the type supplied 
  def notification_find(notice, type)
    return User.find(notice.notice_id) if type == 'friendRequest'
    return Post.find(notice.notice_id) if type == 'comment'
    return Post.find(notice.notice_id) if type == 'like-post'
    return unless type == 'like-comment'
    comment = Comment.find(notice.notice_id)
    Post.find(comment.post_id)
  end
end

The new_notification(user, notice_id, notice_type) method will simply be used to create a new notification record and save it into the Notifications Table

Since a notification will only be created as the result of another method function being called and it has no real view. Instead of creating a controller we will simply create it as a helper method.

The notification_find(notice, type) method will be used to find a particular post, comment of friend request based on the notice_id and notice_type of the notification record saved.

The notice_type will determine which Table to look into and search for the notice_id within that Table.

Setting up Routes

config /routes.rb

Rails.application.routes.draw do
  root 'users#index'
  devise_for :users
  resources :users, only: %i[index show] do
    resources :friendships, only: %i[create]
  end
  resources :posts, only: %i[index new create show destroy] do
    resources :likes, only: %i[create]
  end
  resources :comments, only: %i[new create destroy] do
    resources :likes, only: %i[create]
  end
end

The order in which you define your routes is crucial for the devise and users routes since they can in some instances end up overlapping.

User’s and Posts

Adding methods into User model

app/models/user.rb

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and 
  # :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_many :posts
  has_many :comments, dependent: :destroy
  has_many :likes, dependent: :destroy
  has_many :friend_sent, class_name: 'Friendship',
                         foreign_key: 'sent_by_id',
                         inverse_of: 'sent_by',
                         dependent: :destroy
  has_many :friend_request, class_name: 'Friendship',
                         foreign_key: 'sent_to_id',
                         inverse_of: 'sent_to',
                         dependent: :destroy
  has_many :friends, -> { merge(Friendship.friends) },
           through: :friend_sent, source: :sent_to
  has_many :pending_requests, -> { merge(Friendship.not_friends) },
           through: :friend_sent, source: :sent_to
  has_many :received_requests, -> { merge(Friendship.not_friends) },
           through: :friend_request, source: :sent_by
  has_many :notifications, dependent: :destroy
  mount_uploader :image, PictureUploader
  validate :picture_size
  
  # Returns a string containing this user's first name and last name
  def full_name
    "#{fname} #{lname}"
  end
  
  # Returns all posts from this user's friends and self
  def friends_and_own_posts
    myfriends = friends
    our_posts = []
    myfriends.each do |f|
      f.posts.each do |p|
        our_posts << p
      end
    end
    posts.each do |p|
      our_posts << p
    end
    our_posts
  end
  
  private
  
  # Validates the size of an uploaded picture.
  def picture_size
    errors.add(:image, 'should be less than 1MB') if image.size > 
    1.megabytes
  end
end

We add two new methods into the User model called full_name and friends_and_own_posts.

Method full_name simply returns a string that concatenates both the user’s fname and lname.

def full_name
  "#{fname} #{lname}"
end

friends_and_own_posts method returns all posts of all the user’s friends along with the user’s post. 

def friends_and_own_posts
    myfriends = friends
    our_posts = []
    myfriends.each do |f|
      f.posts.each do |p|
        our_posts << p
      end
    end
    posts.each do |p|
      our_posts << p
    end
    our_posts
  end

The variable ‘myfriends’ is an array that is populated using the ‘has_many :friends’ association which returns all records of the user’s friends.

Afterwards, the posts of each of the friends along with the user’s own posts are pushed into ‘our_posts’.

Creating Posts

app/controllers/posts_controller.rb

class PostsController < ApplicationController
  def index
    @our_posts = current_user.friends_and_own_posts
  end
  
  def show
    @post = Post.find(params[:id])
  end
  
  def new
    @post = Post.new
  end
  
 def create
    @post = current_user.posts.build(posts_params)
    if @post.save
      redirect_to @post
    else
      render 'new'
    end
  end
  
  def destroy; end
  
  private
  
  def posts_params
    params.require(:post).permit(:content, :imageURL)
  end
end
def index
  @our_posts = current_user.friends_and_own_posts
end

The index method creates an instance variable which stores the result of the previously created method in the User model, ‘friends_and_own_posts’ to gather all the posts of this user and his friends.

def show
  @post = Post.find(params[:id])
end

The show method grabs a particular post depending on the id supplied by the route and stores it into an instance variable called @post.

def new
  @post = Post.new
end

The new method creates a new Post record and assigns it to the @post
 variable but doesn’t save it.

def create
  @post = current_user.posts.build(posts_params)
  if @post.save
    redirect_to @post
  else
    render 'new'
  end
end

The create method creates a new Post record, assigning the user_id of that post to that of the current_user that is signed in. (current_user is a devise included helper method)

It also creates the Post using the parameters permitted by the posts_params method. If the post was created and successfully saved the browser will redirect_to the show page view of the post created.

However, if the post was not able to be saved, the new page view of a post (which will be the form used to create a post) will be shown.

private
def posts_params
  params.require(:post).permit(:content, :imageURL)
end

posts_params method is declared as a private method (method is only accessible within current file) which permits the parameters :content and :imageURL provided by the create post route returning them in a Hash format.

# => <ActionController::Parameters {"content"=>"Example post", "imageURL"=>"http://example.com"} permitted: true>

Users Controller

app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.all
    @friends = current_user.friends
    @pending_requests = current_user.pending_requests
    @friend_requests = current_user.received_requests
  end
  
  def show
    @user = User.find(params[:id])
  end
  
  def update_img
    @user = User.find(params[:id])
    unless current_user.id == @user.id
      redirect_back(fallback_location: users_path(current_user))
      return
    end
    image = params[:user][:image] unless params[:user].nil?
    if image
      @user.image = image
      if @user.save
        flash[:success] = 'Image uploaded'
      else
        flash[:danger] = 'Image uploaded failed'
      end
    end
    redirect_back(fallback_location: root_path)
  end
end

Within the index method:

def index
    @users = User.all
    @friends = current_user.friends
    @pending_requests = current_user.pending_requests
    @friend_requests = current_user.recieved_requests
  end

We setup four(4) instance variables, 3 of which uses the associations we created within the User model.

  • @friends = current_user.friends’ gathers all the records of all this user’s friends.
  • @pending_requests = current_user.pending_requests’ gathers all the records of the users this user has sent friend requests to.
  • @friend_requests = current_user.recieved_requests’ gathers all records of the users that has sent this user a friend request.
def update_img
  @user = User.find(params[:id])
  unless current_user.id == @user.id
    redirect_back(fallback_location: users_path(current_user))
    return
  end
  image = params[:user][:image] unless params[:user].nil?
  if image
    @user.image = image
    if @user.save
      flash[:success] = 'Image uploaded'
    else
      flash[:danger] = 'Image uploaded failed'
    end
  end
  redirect_back(fallback_location: root_path)
 end

The update_img method is used to switch the image file associated with the User record. 

A simple and straight forward method which retrieves a User record based on the supplied :id parameter.

Then sets the image column of the User record to the image provided by the route :image parameter and saves the record’s new image value.

Comments & Likes

Filling out the new, create and destroy methods in the Comments controller

app/controllers/comments_controller.rb

class CommentsController < ApplicationController
  include ApplicationHelper
  
  def new
    @comment = Comment.new
  end
  
  def create
    @comment = current_user.comments.build(comment_params)
    @post = Post.find(params[:comment][:post_id])
    if @comment.save
      @notification = new_notification(@post.user, @post.id, 
                                      'comment')
      @notification.save
    end
    redirect_to @post
  end
  
  def destroy
    @comment = Comment.find(params[:id])
    return unless current_user.id == @comment.user_id
    @comment.destroy
    flash[:success] = 'Comment deleted'
    redirect_back(fallback_location: root_path)
  end
  
  private

  def comment_params
    params.require(:comment).permit(:content, :post_id)
  end
end

Starting off with the create method:

def create
  @post = Post.find(params[:comment][:post_id])    
  @comment = current_user.comments.build(comment_params)
  if @comment.save
    @notification = new_notification(@post.user, @post.id, 
                                     'comment')
    @notification.save
  end
  redirect_to @post
end

The instance variable @post is supplied the value of the Post record in which this comment is written in reference to.

@comment stores the value of a new comment record referencing the current_user as the owner and the return value of comment_params. A method which permits the retrieval of two fields from the form used to create a comment, :content and :post_id.

Once the comment record is successfully saved and committed to the database, a new notification record is created using the previously created notification helper (

app/helpers/application_helper.rb
).

  if @comment.save
    @notification = new_notification(@post.user, @post.id, 'comment')
    @notification.save
  end

The destroy comment method:

def destroy
    @comment = Comment.find(params[:id])
    return unless current_user.id == @comment.user_id

    @comment.destroy
    flash[:success] = 'Comment deleted'
    redirect_back(fallback_location: root_path) 
 end

The return unless statement prevents users from deleting commenting that aren’t their own. 

The redirect_back() method simply refreshes the current page.

Allowing Likes

app/controllers/likes_controller.rb

class LikesController < ApplicationController
  include ApplicationHelper
  
  def create
    type = type_subject?(params)[0]
    @subject = type_subject?(params)[1]
    notice_type = "like-#{type}"
    return unless @subject
    if already_liked?(type)
      dislike(type)
    else
      @like = @subject.likes.build(user_id: current_user.id)
      if @like.save
        flash[:success] = "#{type} liked!"
        @notification = new_notification(@subject.user, @subject.id,
                                         notice_type)
        @notification.save
      else
        flash[:danger] = "#{type} like failed!"
      end
      redirect_back(fallback_location: root_path)
    end
  end
  
  private
  
  def type_subject?(params)
    type = 'post' if params.key?('post_id')
    type = 'comment' if params.key?('comment_id')
    subject = Post.find(params[:post_id]) if type == 'post'
    subject = Comment.find(params[:comment_id]) if type == 'comment'
    [type, subject]
  end
  
  def already_liked?(type)
    result = false
    if type == 'post'
      result = Like.where(user_id: current_user.id,
                          post_id: params[:post_id]).exists?
    end
    if type == 'comment'
      result = Like.where(user_id: current_user.id,
                          comment_id: params[:comment_id]).exists?
    end
    result
  end
  
  def dislike(type)
    @like = Like.find_by(post_id: params[:post_id]) if type ==
                                                       'post'
    @like = Like.find_by(comment_id: params[:comment_id]) if type ==
                                                          'comment'
    return unless @like
    
    @like.destroy
    redirect_back(fallback_location: root_path)
  end
end

Since the create method depends on a few helper provide methods I will explain those first.

def type_subject?(params)
  type = 'post' if params.key?('post_id')
  type = 'comment' if params.key?('comment_id')
  subject = Post.find(params[:post_id]) if type == 'post'
  subject = Comment.find(params[:comment_id]) if type == 'comment'
  
  [type, subject]
end

The type_subject?() method receives the parameters from the route and determines if the id obtained is that of a comment or a post.

Since the create like method is accessible by two nested routes. One within a post route and another within a comment route, depending on the route used to call the method a different parameter is given.

resources :posts, only: %i[index new create show destroy] do
  resources :likes, only: %i[create]
end
resources :comments, only: %i[new create destroy] do
  resources :likes, only: %i[create]
end

After is it decided which route was used, the method returns an array containing the type, a string value of either ‘comment’ or ‘post’ and subject, the record of either the comment or post.

def already_liked?(type)
  result = false
  if type == 'post'
    result = Like.where(user_id: current_user.id,
                        post_id: params[:post_id]).exists?
  end
  if type == 'comment'
    result = Like.where(user_id: current_user.id,
                        comment_id: params[:comment_id]).exists?
  end
  result
end

Next up, the method already_liked? returns either a true or false value determining whether a Like record exits for the post or comment in question.

def dislike(type)
  @like = Like.find_by(post_id: params[:post_id]) if type ==
                                                  'post'
  @like = Like.find_by(comment_id: params[:comment_id]) if type ==
                                                        'comment'
  return unless @like
    
  @like.destroy
  redirect_back(fallback_location: root_path)
end

Then we have the dislike method which is essentially the destroy method for the Likes controller. It find the like record for the given post or comment based on the route used and destroys it.

Application helper Likes methods

app/helpers/application_helper.rb


module ApplicationHelper
  # Checks whether a post or comment has already been liked by the 
  # current user returning either true or false
  def liked?(subject, type)
    result = false
    result = Like.where(user_id: current_user.id, post_id: 
                        subject.id).exists? if type == 'post'
    result = Like.where(user_id: current_user.id, comment_id: 
                        subject.id).exists? if type == 'comment'
    result
  end
end

Similar to the already_liked method in the Likes controller, the liked?(subject, type) method takes two arguments. A record object, subject and the string literal of its type.

Friendship Helpers

Since the Friendship controller depends on some helper methods we will create them in the application helper.

app/helpers/application_helper.rb

module ApplicationHelper
  def friend_request_sent?(user)
    current_user.friend_sent.exists?(sent_to_id: user.id, status: false)
  end
  
  def friend_request_received?(user)
    current_user.friend_request.exists?(sent_by_id: user.id, status: false)
  end
  
  # Checks whether a user has had a friend request sent to them by the current user or 
  # if the current user has been sent a friend request by the user returning either true or false
  def possible_friend?(user)
    request_sent = current_user.friend_sent.exists?(sent_to_id: user.id)
    request_received = current_user.friend_request.exists?(sent_by_id: user.id)
    
    return true if request_sent != request_recieved    
    return true if request_sent == request_recieved && request_sent == true    
    return false if request_sent == request_recieved && request_sent == false
  end
end

Lets look a bit closer at this newly added methods.

def friend_request_sent?(user)
  current_user.friend_sent.exists?(sent_to_id: user.id, status: false)
end

The friend_request_sent? method checks whether a user has had a friend request sent to them by the current user returning either true or false.

def friend_request_received?(user)
  current_user.friend_request.exists?(sent_by_id: user.id, status: false)
end

This method checks whether a user has sent a friend request to the current user returning either true or false.

def possible_friend?(user)
  request_sent = current_user.friend_sent.exists?(sent_to_id: user.id)
  request_received = current_user.friend_request.exists? (sent_by_id: user.id)

  return true if request_sent != request_recieved
  return true if request_sent == request_recieved && request_sent == true
  return false if request_sent == request_recieved && request_sent == false
end

This method checks whether a user has had a friend request sent to them by the current user or if the current user has been sent a friend request by the user. This method returns either true or false.

Friendship Controller

app/controllers/friendships_controller.rb

class FriendshipsController < ApplicationController
  include ApplicationHelper

  def create
    return if current_user.id == params[:user_id] # Disallow the ability to send yourself a friend request
    # Disallow the ability to send friend request more than once to same person
    return if friend_request_sent?(User.find(params[:user_id]))
    # Disallow the ability to send friend request to someone who already sent you one
    return if friend_request_recieved?(User.find(params[:user_id]))

    @user = User.find(params[:user_id])
    @friendship = current_user.friend_sent.build(sent_to_id: params[:user_id])
    if @friendship.save
      flash[:success] = 'Friend Request Sent!'
      @notification = new_notification(@user, @current_user.id, 'friendRequest')
      @notification.save
    else
      flash[:danger] = 'Friend Request Failed!'
    end
    redirect_back(fallback_location: root_path)
  end

  def accept_friend
    @friendship = Friendship.find_by(sent_by_id: params[:user_id], sent_to_id: current_user.id, status: false)
    return unless @friendship # return if no record is found

    @friendship.status = true
    if @friendship.save
      flash[:success] = 'Friend Request Accepted!'
      @friendship2 = current_user.friend_sent.build(sent_to_id: params[:user_id], status: true)
      @friendship2.save
    else
      flash[:danger] = 'Friend Request could not be accepted!'
    end
    redirect_back(fallback_location: root_path)
  end

  def decline_friend
    @friendship = Friendship.find_by(sent_by_id: params[:user_id], sent_to_id: current_user.id, status: false)
    return unless @friendship # return if no record is found

    @friendship.destroy
    flash[:success] = 'Friend Request Declined!'
    redirect_back(fallback_location: root_path)
  end
end

The create method:

def create
  return if current_user.id == params[:user_id]
  return if friend_request_sent?(User.find(params[:user_id]))
  return if friend_request_received?(User.find(params[:user_id]))

  @user = User.find(params[:user_id])
  @friendship = current_user.friend_sent.build(sent_to_id: 
                                         params[:user_id])
  if @friendship.save
    flash[:success] = 'Friend Request Sent!'
    @notification = new_notification(@user, @current_user.id, 
                                     'friendRequest')
    @notification.save
  else
    flash[:danger] = 'Friend Request Failed!'
  end
  redirect_back(fallback_location: root_path)
end

The first return statement prevents the ability of sending yourself a friend request.

The second return statement uses the method friend_request_sent? to prevent the sending of friend requests more than once to same person.

The third return statement prevents the sending of friend requests to someone who has already sent you one, using method friend_request_received?.

Since this friendships controller create method is a nested route under the users resource the route created provides the parameter user_id for use within this function.

@friendship = current_user.friend_sent.build(sent_to_id: params[:user_id])

current_user.friend_sent.build() creates a new record in the Friendship table supplying the value of sent_by_id as that of the current user using the friend_sent association between the User model and Friendship model.

Once the Friendship record is successfully saved a new_notification() is created and linked.

def accept_friend
  @friendship = Friendship.find_by(sent_by_id: params[:user_id], sent_to_id: current_user.id, status: false)
  return unless @friendship # return if no record is found

  @friendship.status = true
  if @friendship.save
    flash[:success] = 'Friend Request Accepted!'
    @friendship2 = current_user.friend_sent.build(sent_to_id: params[:user_id], status: true)
    @friendship2.save
  else
    flash[:danger] = 'Friend Request could not be accepted!'
  end
  redirect_back(fallback_location: root_path)
end

The accept_friend method updates the friendship record in the Friendship table setting the status of the record to true which we used to signify that the users are friends. 

Once the original record is updated and saved a duplicate record is created. This duplicate record will have the inverse value for sent_by_id and sent_to_id. This makes it easier to perform friending tasks and database checks to determine friends lists.

For example, John sends Samantha a friend request. Samantha accepts the friend request. Upon accepting the friend request another friend request is made automatically. But instead this time it will be as if Samantha had sent John a friend request and John had accepted it.

def decline_friend
  @friendship = Friendship.find_by(sent_by_id: params[:user_id],
                                   sent_to_id: current_user.id,
                                   status: false)
  return unless @friendship # return if no record is found

    @friendship.destroy
    flash[:success] = 'Friend Request Declined!'
    redirect_back(fallback_location: root_path)
  end
end

If a user declines a friend request the record is deleted out of the Friendship table.

Editing Routes

config/routes.rb

Rails.application.routes.draw do
  root 'users#index'
  devise_for :users
  resources :users, only: %i[index show] do
    resources :friendships, only: %i[create] do
      collection do
        get 'accept_friend'
        get 'decline_friend'
      end
    end
  end
  
  put '/users/:id', to:  'users#update_img'

  resources :posts, only: %i[index new create show destroy] do
      resources :likes, only: %i[create]
  end
  resources :comments, only: %i[new create destroy] do
    resources :likes, only: %i[create]
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

We added two nested collection route methods under the already nested friendships route, accept_friend and decline_friend (more information on collection routes).

Also added a PUT route link to the update_img method in the Users controller. We used PUT because PUT and PATCH requests are used to update existing data.

Views

For the views we will be using Bootstrap, GoogleFonts and FontAwesome

However, these are all optional, you may use your own custom classes. 

The main point of this section is to show you how to use the helper functions and controller methods to achieve our goal.

Layout Views

app/views/layouts

We will only go over the routes, methods and helper methods used within the views. For the HTML format of the full view you can look at Spybook’s Github views.

Essentially these views are view-able on every page, so, this is where we will create our Flash notifications and Navigation Bar.

Flash Notifications

We create a new file titled ‘_flash.html.erb, the _ (underscore) preceding the name of the file dictates that this file will be a render.

app/views/layouts/_flash.html.erb

<% flash.each do |message_type, message| %>
  <%= content_tag(:div, message, class: "alert alert-#{message_type}") %>
<% end %>

Since flash is a Hash, flash.each do scans through each of the key, value pairs. The keys are between :notice:success:error and :alert. The values are the flash message supplied.

So content_tag(:div, message, class: “alert alert-#{message_type}”), creates a new div element with it’s TextContent equal to the ‘message’ supplied with a class equal to ‘alert alert-‘message_type’’ supplied. ‘message_type’ can be either notice, success, error or alert.

Navigation Bar

Create a new file titled ‘_header.html.erb’.

app/views/layouts/_header.html.erb

We will be referencing the ‘_header.html.erb’ file found in Spybook’s Layouts view

<div class= "container mx-auto"> 
  <div class= "row mx-auto"> 
    <div class= "col-auto"> 
      <%= link_to posts_path, class: "text-light nav-link font-
      raleway" do%> 
        <i class="fas fa-home fa-2x"></i> Timeline 
      <%end%> 
    </div>
<!-- ...Code below not shown -->

The ‘link_to posts_path’ route simply links to the Posts index view

app/views/posts/index.html.erb

<div class= "col-auto"> 
  <%= link_to users_path, class: "text-light nav-link font-raleway" 
  do%> 
    <i class="fas fa-users fa-2x"></i> Find Friends 
  <%end%> 
</div>

The ‘link_to users_path’ route simply links to the Users index view

app/views/users/index.html.erb

<ul class="navbar-nav ml-auto">
 <% if user_signed_in? %>
   <li class="nav-item">
     <button class="text-white btn btn-secondary", data-
     toggle="modal" data-target="#noticeModal">
       <i class="fas fa-bell"></i>
       <%= current_user.notifications.count%>
     </button>
   </li>
<!-- ... -->

The notification’s button will display an icon provided by FontAwesome and the number of notifications the current user has. 

The notifications system explained here in this tutorial is slightly different from the system used on the Github project.
<div class="dropdown-menu" aria-labelledby="navbarDropdown">
  <%= link_to "My Profile", user_path(current_user), class: "dropdown-item font-raleway" %>
  <%= link_to "Log out", destroy_user_session_path, class: "dropdown-item font-raleway", method: :delete %>
</div>

The ‘link_to user_path(current_user)’ route links to the Users show view

app/views/users/show.html.erb
using the id of current user to display information relative to that user.

The ‘link_to destroy_user_session_path’ route links to a devise sessions controller destroy method

<% if user_signed_in? %>
  <%= render 'shared/notifications', object:
  current_user.notifications%>
<%end%>

This render at the bottom of the page is necessary for the notifications button to work. As it allows a pop up to appear once clicked which shows all notifications the user has.

Application view

app/views/layouts/application.html.erb

We will be referencing the ‘application.html.erb’ file found in Spybook’s Layouts view.

Here we will render our Flash notifications(render ‘layouts/flash’) and our Navigation bar(render ‘layouts/header’) as well as add links to GoogleFonts and FontAwesome.

<body>
  <%= render 'layouts/header' %>
  <div class='container'>
    <br>
    <%= render 'layouts/flash' %>
    <%= yield %>
  </div>
</body>

<%= yield %>
is placeholder code where each of the other views will be displayed.

Next, we will create all of our shared views.

Shared Rendered Modal Views

app/views/shared

Image Upload Modal

Create a new file titled ‘_imageUploadModal.html.erb’.

app/views/shared/_imageUploadModal.html.erb

We will be referencing the ‘_imageUploadModal.html.erb’ file found in Spybook’s Shared view.

<div class="modal-body">
  <h5 class="modal-title" id="imageUploadModalLabel"> Change Profile
  Picture </h5>
  <%= form_for(object, html: { method: :put }) do |f| %>
    <%= render "devise/shared/error_messages", resource: object %>
    <div class="field">
      <%= f.label :Profile_image %><br />
      <%= f.file_field :image, accept:
      'image/jpeg,image/gif,image/png' %>
    </div>
    <div class="actions">
      <%= f.submit "Update" %>
    </div>
  <% end %>
</div>

Within the modal we will create a form which allows the user to upload any image of type jpeg, gif or png.

<%= form_for(object, html: { method: :put }) do |f| %>
    <%= render "devise/shared/error_messages", resource: object %>

The object variable you see being used is going to be the user object once passed as reference by the render method as we will see in the User show view.

app/views/users/show.html.erb

<%= render 'shared/imageUploadModal', object: @user%>

At the bottom of the ‘_imageUploadModal.html.erb’ file we will create a JavaScript function.

app/views/shared/_imageUploadModal.html.erb

<script type="text/javascript">
document.getElementById('user_image').addEventListener('change', function() {
  var size_in_megabytes = this.files[0].size/1024/1024;
  if (size_in_megabytes > 1) {
    alert('Maximum file size is 1 MB. Please choose a smaller      
    file.');
  }
});
</script>

This function returns an alert when the image file uploaded is greater than 1 Mb in size.

Notifications Modal

Create a new file titled ‘_notifications.html.erb’.

app/views/shared/_notifications.html.erb

We will be referencing the ‘_notifications.html.erb’ file found in Spybook’s Shared view.

<div class="modal-body">
  <% object.each do |n|%>
  <!--If Notification type is a Friend Request -->
    <% if n.notice_type == "friendRequest"%>
      <% user = notification_find(n, 'friendRequest')%>
      <%= "Friend Request sent from #{user.full_name}" %>
    <% end %>

Just like the Image modal render the Notifications modal render is passed an object variable:

app/views/layouts/_header.html.erb

<% if user_signed_in? %>
  <%= render 'shared/notifications', object:
  current_user.notifications%>
<%end%>

This object variable passed is the associative has many link between the User model and the Notifications model.

Since this is a has many link and it can return multiple records within the Notifications modal we run an each do loop to handle each notification separately. This allows us to categorize our notifications and the ability to show context correct text about each notification.

app/views/shared/_notifications.html.erb

<!-- ...Code above not shown -->
<div class="modal-body">
  <% object.each do |n|%>
    <!-- If Notification type is a Friend Request -->
    <% if n.notice_type == "friendRequest"%>
      <% user = notification_find(n, 'friendRequest')%>
      <%= "Friend Request sent from #{user.full_name}" %>
    <% end %>
    <!-- If Notification type is a comment -->
    <% if n.notice_type == "comment"%>
      <%= link_to post_path(notification_find(n, 'comment')) do %>
         Someone commented on your post
      <% end %>
    <% end %>
    <!-- If Notification type is a liked post -->
    <% if n.notice_type == "like-post"%>
      <%= link_to post_path(notification_find(n, 'like-post')) do %>
         Someone liked your post
      <% end %>
    <% end %>
    <!-- If Notification type is a liked comment -->
    <% if n.notice_type == "like-comment"%>
      <%= link_to post_path(notification_find(n, 'like-comment')) do %>
         Someone liked your comment under this post
      <% end %>
    <% end %>          
    <br>
  <% end %>
</div>

Aside from the friendship request notification all other notifications provide a link to the liked content using the notification_find helper we created in the application_helper file.

Comments Views

Comment Form View

Create a new file titled ‘_form.html.erb’.

app/views/comments/_form.html.erb

We will be referencing the ‘_form.html.erb’ file found in Spybook’s Comments view.

<%= form_for @comment = Comment.new do |f| %>

The comments form view is a straight forward view which is going to be rendered in multiple places. It uses the Rails form_for method to create a new Comment record and attempts to submit this record using the Comments controller Create method.

Comment Layout View

Create a new file titled ‘_comment.html.erb’.

app/views/comments/_comment.html.erb

We will be referencing the ‘_comment.html.erb’ file found in Spybook’s Comments view.

The comments layout is pretty straight forward it is a rendered view which is passed an object parameter which is generally going to be the comments associated with a particular post.

<%= distance_of_time_in_words(c.created_at, Time.now) %>

It uses a Rails method distance_of_time_in_words to display the amount of time that has passed from when the post was created to the current time.

<%= render 'likes/like_comments', object: c%>

It also renders a Likes view supplying the comment itself as an object variable.

Likes View

app/views/likes/_like_comments.html.erb

We will be referencing the ‘_like_comments.html.erb’ file and ‘_like_posts.html.erb’ found in Spybook’s Likes view.

<span>
<%= object.likes.count %>

<%= link_to comment_likes_path(object), class: "text-light", method: :post do %>
  <% if liked?(subject = object, type = 'comment') %>
      <button class="btn btn-liked size-12"><i class="fas fa-thumbs-up"></i></button>
    <% else %>
      <button class="btn btn-neutral size-12"><i class="fas fa-thumbs-up"></i></button>
  <% end %>
<% end %>
Likes
</span>

View uses object variable supplied to this rendered view to display the amount of likes the particular object has.

A link is created using the comment_likes_path route which is passed the object as a parameter. This nested route links to the create method in the Likes controller.

The liked? helper method which is created in the application_helper file is used to determine the color of the thumbs up icon.

app/views/likes/_like_posts.html.erb

<span>
<%= object.likes.count %>

<%= link_to post_likes_path(object), class: "text-light", method: :post do %>
  <% if liked?(object, 'post') %>
      <button class="btn btn-liked"><i class="fas fa-thumbs-up"></i></button>
    <% else %>
      <button class="btn btn-neutral"><i class="fas fa-thumbs-up"></i></button>
  <% end %>
<% end %>
Likes
</span>

The only difference between the two like views is the route used. 

<%= link_to post_likes_path(object), class: "text-light", method: :post do %>

Its also worth mentioning the ‘method: :post’ parameter on the link_to method. This is necessary to create a new Like object. Generally link_to is used to GET information.

Posts Views

New Post View

Create a new file titled ‘new.html.erb’.

app/views/posts/new.html.erb

We will be referencing the ‘new.html.erb’ file found in Spybook’s Posts view, to fill in the data.

Post Layout View

Create a new file titled ‘_post_layout.html.erb’.

app/views/posts/_post_layout.html.erb

We will be referencing the ‘_post_layout.html.erb’ file found in Spybook’s Posts view, to fill in the data.

This view is similar to the comments layout view as it renders comments, a comment form and a likes view within it.

Post Show View

Create a new file titled ‘show.html.erb’.

app/views/posts/show.html.erb

<h1>Post</h1>
<%= render 'posts/post_layout', object: @post %>

Timeline — Post Index View

Create a new file titled ‘index.html.erb’.

app/views/posts/index.html.erb

<h1> Timeline - My posts and my friends posts</h1>
<div class="center font-raleway ">
  <%= link_to new_post_path, class: "btn btn-secondary" do%>
    New Post?
  <%end%>
</div>

<% @our_posts.each do |p|%>
  <%= render 'posts/post_layout', object: p %>
<% end %>

Simple view which has a link to the new post view

It also renders the posts of the current user and the user’s friends using the instance variable created in the Posts controller, @our_posts.

Users Views

Find Friends — User Index View

Create a new file titled ‘index.html.erb’.

app/views/users/new.html.erb

We will be referencing the ‘index.html.erb’ file found in Spybook’s Users view, to fill in the data.

The index view uses the instance variables created in the Users controller to categorize all the users.

<% unless @friends.empty? %>
...
<% unless @pending_requests.empty? %>
...
<% unless @friend_requests.empty? %>
...
<!-- ...Doesn't show all code up above -->
<% unless @friend_requests.empty? %>
  <div class="card my-5 py-3 bg-light shadow">
      <h2 class="center pb-3 text-dark border-bottom">Pending Friend Requests</h2> 
      <% @friend_requests.each do |user|%> <!-- Shows all users friend requests has been sent to -->
        <div class="d-flex align-items-center mb-2 border-bottom py-2">
          <div class="col-auto p-0 pl-5 text-capitalize"> 
            <%= link_to user_path(user) do %>
            <%= user.full_name %>
            <% end %>
          </div>
          <div class="col-auto p-0 px-1">|</div>
          <div class="col-auto p-0">
            <button class= "btn btn-pending shadow" data-toggle="modal" data-target="#decisionModal">
                <i class="fas fa-envelope"></i> Pending Friend Request... 
            </button>
          </div>
        </div>
          <%= render 'friendships/decisionModal', object: user %>
        <br><br>
      <% end %>
  </div>
<% end %>
<!-- ...Doesn't show all code down below -->

All Pending Friend requests are created as buttons which once clicked opens a rendered modal view.

Friendship Decision Modal View

app/views/friendships/_decisionModal.html.erb

The full code referenced can be found here Spybook Friendship View Modal.

<!-- Modal -->
<div class="modal fade" id="decisionModal" tabindex="-1" role="dialog" aria-labelledby="decisionModal" aria-hidden="true">
  <div class="modal-dialog" role="document">
    <div class="modal-content">
      <div class="modal-body">
        <h5 class="modal-title" id="decisionModalLabel">Friend Request from <%= "#{object.full_name}" %></h5>
      </div>
      <div class="modal-footer">
        <%= link_to accept_friend_user_friendships_path(object) do %>
          <button type="button" class="btn btn-accept text-white font-weight-bold"> <i class="fas fa-user-check"></i> Accept</button>
        <% end %>
        <%= link_to decline_friend_user_friendships_path(object) do %>
          <button type="button" class="btn btn-decline text-white font-weight-bold"><i class="fas fa-user-times"></i> Decline</button>
        <% end %>
      </div>
    </div>
  </div>
</div>

Friend request decision modal contains links to the accept_friend and decline_friend methods from the Friendship Controller.

User Show View

Create a new file titled ‘show.html.erb’.

app/views/users/show.html.erb

We will be referencing the ‘show.html.erb’ file found in Spybook’s Users view, to fill in the data.

Omniauth Facebook Integration

Go to Facebook developer’s website

Create an account and then create a new app.

Take note of the App ID and the App Secret. You will need these values later on.

Then afterwards you will save the changes and now you should be able to switch the app development status to ‘Live’ instead of ‘In Development’.

Configuring Project for Facebook Use

Devise OmniAuth Documentation

You should already have the gem omniauth-facebook’ installed at this point.

If you added the optional gem dotenv-rails’ you can use a .env file which you would create at the root directory to store a ‘FACEBOOK_APP_ID’ and ‘FACEBOOK_APP_SECRET’ value. 

Within the ‘.env’ file you would put:

./.env

FACEBOOK_APP_ID = 'FILL IN WITH YOUR APP ID'
FACEBOOK_APP_SECRET = 'FILL IN WITH YOUR APP SECRET'

Update User model

Next up, you should add the columns “provider” (string) and “uid” (string) to your User model.

Using the below command:

$ rails g migration AddOmniauthToUsers provider:string uid:string

Then migrate the database:

$ rails db:migrate

Next, you will add two(2) OmniAuth methods to User model self.from_omniauth() and self.new_with_session(). And also add extra parameters onto the devise method.

app/models/user.rb

class User < ApplicationRecord
  has_many :posts
  has_many :comments, dependent: :destroy
  has_many :likes, dependent: :destroy
  has_many :friend_sent, class_name: 'Friendship', foreign_key: 'sent_by_id', inverse_of: 'sent_by', dependent: :destroy
  has_many :friend_request, class_name: 'Friendship', foreign_key: 'sent_to_id',
                            inverse_of: 'sent_to', dependent: :destroy
  has_many :friends, -> { merge(Friendship.friends) }, through: :friend_sent, source: :sent_to
  has_many :pending_requests, -> { merge(Friendship.not_friends) }, through: :friend_sent, source: :sent_to
  has_many :recieved_requests, -> { merge(Friendship.not_friends) }, through: :friend_request, source: :sent_by
  has_many :notifications, dependent: :destroy
  mount_uploader :image, PictureUploader
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :omniauthable, omniauth_providers: %i[facebook]
  validates :fname, length: { in: 3..15 }, presence: true
  validates :lname, length: { in: 3..15 }, presence: true
  validate :picture_size

  def full_name
    "#{fname} #{lname}"
  end

  # Returns all posts from this user's friends and self
  def friends_and_own_posts
    myfriends = friends
    our_posts = []
    myfriends.each do |f|
      f.posts.each do |p|
        our_posts << p
      end
    end

    posts.each do |p|
      our_posts << p
    end

    our_posts
  end

  def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
      user.email = auth.info.email
      user.password = Devise.friendly_token[0, 20]
      user.fname = auth.info.first_name # assuming the user model has a first name
      user.lname = auth.info.last_name # assuming the user model has a last name
      user.image = auth.info.image # assuming the user model has an image
      # If you are using confirmable and the provider(s) you use validate emails,
      # uncomment the line below to skip the confirmation emails.
      # user.skip_confirmation!
    end
  end

  def self.new_with_session(params, session)
    super.tap do |user|
      if (data = session['devise.facebook_data'] && session['devise.facebook_data']['extra']['raw_info'])
        user.email = data['email'] if user.email.blank?
      end
    end
  end

  private

  # Validates the size of an uploaded picture.
  def picture_size
    errors.add(:image, 'should be less than 1MB') if image.size > 1.megabytes
  end
end

We modify one of the newly added OmniAuth functions to work with our User model database columns.

def self.from_omniauth(auth)
    where(provider: auth.provider, uid: auth.uid).first_or_create do
    |user|
      user.email = auth.info.email
      user.password = Devise.friendly_token[0, 20]
      user.fname = auth.info.first_name
      user.lname = auth.info.last_name
      user.image = auth.info.image
    end
end

We edit the self.from_omniauth() method to use our User model’s columns fname, lname and image.

Edit Devise Initializer

Next, you need to declare the provider in your devise.rb by adding the code below

config/initializers/devise.rb

Devise.setup do |config|
  # ...Doesn't show other code up above
  config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
  
  config.omniauth :facebook,  ENV['FACEBOOK_APP_ID'], ENV['FACEBOOK_APP_SECRET'], 
                    token_params: { parse: :json }, scope: 'public_profile,email',
                    info_fields: 'email,first_name,last_name,gender,birthday,location,picture'
end

Update Routes

config /routes.rb

Rails.application.routes.draw do
  root 'users#index'
  devise_for :users, controllers: { omniauth_callbacks: 'users/omniauth_callbacks' }
  resources :users, only: %i[index show] do
  resources :users, only: %i[index show] do
    resources :friendships, only: %i[create] do
      collection do
        get 'accept_friend'
        get 'decline_friend'
      end
    end
  end
  
  put '/users/:id', to:  'users#update_img'

  resources :posts, only: %i[index new create show destroy] do
      resources :likes, only: %i[create]
  end
  resources :comments, only: %i[new create destroy] do
    resources :likes, only: %i[create]
  end
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end
devise_for :users, controllers: { omniauth_callbacks:'users/omniauth_callbacks' }

Added the additional parameter for controllers onto the devise_for route.

Test Omniauth Facebook Login

Run the test server and use the devise generated ‘Register with Facebook’ link provided to create an account.

After using the link to create an account it will add a new product to your Facebook app called ‘Facebook Login’.

You will then click onto the newly added Facebook Login product and go into its settings. 

After entering the settings page for the app, you will need to add the two routes created by the devise omniauth facebook gem. You will add these routes into the Valid OAuth Redirect URIs field and hit save changes.

The routes added will generally be the URL of the website, which in our case the name of the Heroku app live link, plus ‘/users/auth/facebook’ and ‘/users/auth/facebook/callback’

In my case it was https://spybook-v2.herokuapp.com/users/auth/facebook and https://spybook-v2.herokuapp.com/users/auth/facebook/callback

Now you can set the Facebook app from Development mode to Live mode.

Deployment to Heroku

Merge recent changes to the git hub master branch.

Create new Heroku application:

$ heroku create

Then push changes to Heroku master using:

$ git push heroku master

Then generate database on Heroku application:

$ heroku run rails db:migrate

Set Heroku config variables

Documentation

Using terminal Set Facebook App ID

$ heroku config:set FACEBOOK_APP_ID=YOURFACEBOOKAPPID

Set Facebook App Secret

$ heroku config:set FACEBOOK_APP_SECRET=YOURFACEBOOKAPPSECRET

Then open Heroku application

$ heroku open

Stay Tuned!