homeASCIIcasts

406: Public Activity 

(view original Railscast)

It’s a common request to have a user activity feed on a website, similar to the one that Github has. This is great for social network-style applications so that we can see what other users have been up to.

We have a cookbook application where users can create, edit and share recipes. Users can add comments to recipes and mark other users as friends. We’ll add an activity page to this application so that we can see all our friends’ activity and know what they’ve been up to, be it posting new recipes or adding comments.

Our cookbook application.

We’ll accomplish this with a gem called Public Activity. To use it in our application we’ll add it to the gemfile then run bundle to install it.

/Gemfile

gem 'public_activity'

To set up the database tables that the gem needs we need to run a generator called publish_activity:migration then migrate the database.

$ rails g public_activity:migration
$ rake db:migrate

This creates an activities table to go with the ActiveRecord model for managing activities. Public Activity is also compatible with Mongoid and these steps aren’t necessary if you’re using that. See the documentation if you’re using this setup. The next step is include a PublicActivity::Model module in any model that we want to track the activity of and to call a method called tracked. We’ll do this in our Recipe model.

/app/models/recipe.rb

class Recipe < ActiveRecord::Base
  include PublicActivity::Model
  tracked
  attr_accessible :description, :image_url, :name
  belongs_to :user
  has_many :comments, dependent: :destroy
end

The tracked method sets up some callbacks to automatically create activity records after a model is created, updated or destroyed. We’ll do this in the Comment model as well as we also want to track those. If we add a comment to a recipe now it will be automatically tracked by Public Activity.

The Activities Page

Next we need to create a page that displays the activities. We’ll generate a new controller with an index action for this.

$ rails g controller activities index

Next we’ll modify the routes file and replace the generated route with an activities resource.

/config/routes.rb

resources :activities

In the this controller’s index action we want to want to list all the activities. Calling PublicActivity::Activity will return the ActiveRecord models so we can query the database like we would normally. We’ll order the results by the time they were created at so that the most recent activities are displayed at the top.

/app/config/activities_controller.rb

class ActivitiesController < ApplicationController
  def index
    @activities = PublicActivity::Activity.order("created_at desc")
  end
end

In the view we can loop through this data and display it. For now we’ll just inspect each activity to see what it contains.

/app/views/activities/index.html.erb

Friends’ Activities

<% @activities.each do |activity| %> <%= activity.inspect %> <% end %>

When we reload the page now we’ll see the one activity that we’ve already added.

The activities page showing the single activity that we’ve added.

We can see the different attributes that this activity has including trackable_id and trackable_type columns. This is a polymorphic association and we know that this activity is associated with a Comment model. There are a couple of other polymorphic associations here, too: recipient and owner. The owner is the user who performed the activity and we’ll want to set this so that we can display the user’s name next to their activity. We’ll do this next.

We mentioned earlier that this gem uses callbacks to record activity. This presents a problem, however, as the model layer doesn’t have access to the current user so we can’t set the owner when we record the activity? Public Activity has a workaround for this; to use it we need to include a module in the ApplicationController.

/app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include PublicActivity::StoreController
  # Rest of class omitted
end

This records the controller on each request allowing us to access it from the models. We can do this in the Comment model by adding an owner option to tracked.

/app/models/comment.rb

tracked owner: ->(controller, model) { controller.current_user }

We set this option to a lambda and this is evaluated each time it tracks an activity. The controller and model are passed in to this and we can use the controller to set the owner to the current user. This presents a small problem, however, as current_user is a private method in our ApplicationController. We’ll make it public and use hide_action to stop it from being considered an action.

/app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include PublicActivity::StoreController
  protect_from_forgery
  def current_user
    @current_user ||= User.find(session[:user_id]) if session[:user_id]
  end
  helper_method :current_user
  hide_action :current_user
private
  def require_login
    redirect_to login_url, alert: "You must first log in or sign up." if current_user.nil?
  end
end

Another potential issue is that in Comment calling controller.current_user raises an exception if we try to create a record outside the current request as there won’t be a controller. We’ll check to see if there is a controller here before trying to fetch the current user from it.

/app/models/comment.rb

tracked owner: ->(controller, model) { controller && controller.current_user }

This workaround isn’t ideal and it feels a little ugly to have to access the controller in the model list but it does work. We’ll copy this into the Recipe model so that we can fetch the user here, too. We’ll then try it out by adding another comment then visiting the activities page again.

The second activity shows its owner_id.

Now we have two activities and the first one, which is the most-recently added, has its owner_id set. We’ll fix up this view now so that it displays the activities instead.

/app/views/activities/index.html.erb

Friends’ Activities

<% @activities.each do |activity| %>
<%= link_to activity.owner.name, activity.owner if activity.owner %> added comment to <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %>
<% end %>

We have a div with a class of activity for each activity and inside it we display the activity’s owner’s name, but only if the activity has an owner. Next we describe the activity and this can be a little difficult as each one is unique so for now we’ve hardcoded this as all our activities are comments. We then display the recipe’s name in a link to the recipe. To get the recipe we use activity.trackable which is the polymorphic association which references the model that the activity is for, in this case Comment. We’ll also want some styling for our list of activities so we’ll add that now.

/app/assets/activities.css.scss

.activity {
  border-bottom: solid 1px #CCC;
  padding: 16px 0;
  em {
    color: #777;
    font-size: 12px;
    padding-left: 5px;
  }
}

When we reload the page now we should see our list of activities.

The page now shows the list of activities.

This looks quite good but we’re hard-coding the description. How can we change it depending on the type of activity? Public Activity provides a helper method called render_activity that we can use; all we need to do is pass it the activity.

/app/views/activities/index.html.erb

Friends’ Activities

<% @activities.each do |activity| %>
<%= link_to activity.owner.name, activity.owner if activity.owner %> <%= render_activity activity %>
<% end %>

This will render a partial for the activity’s action. It will look for these in a public_activity directory and in there we’ll need a directory for each different type of model that we track. We want a partial that will be displayed when a comment is created so we’ll call it _create.html.erb. In it we can describe the activity like we did before.

/app/views/public_activity/comment/_create.html.erb

added comment to <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %>

This looks like it did before but we can now define partials for the different types of activities including _destroy and _update. Once we’ve done this and added or updated some comments we can see how these appear on the activities page.

Each activity is now described correctly.

This all looks good but if we remove a record, say a recipe, then visit the activities page we’ll get an error message.

Viewing the activities page after deleting a recipe throws an exception.

This is because we’re calling activity.trackable.recipe for a recipe that no longer exists. It’s important in each activity partial to take into consideration the fact that the object may no longer exist so we’ll modify each one like this.

/app/views/public_activity/comment/_create.html.erb

added comment to 
<% if activity.trackable %>
  <%= link_to activity.trackable.recipe.name, activity.trackable.recipe %>
<% else %>
  which has since been removed 
<% end %>

When we reload the activities page now it works again.

The activities page now handles deleted records.

Excluding Actions

Next we’ll show you how to exclude certain actions. For example updated comments aren’t that interesting so we might not want to show them in the activities list. We can do this by passing an option to the call to tracked in the Comment model: either only to specify which activities should be tracked or except to specify the ones to exclude.

/app/models/comment.rb

tracked except: :update, owner: ->(controller, model) { controller && controller.current_user }

The tracked method is starting to get a little complex with all these options and it’s not always the best approach to perform activity tracking through callbacks on the model so instead we’ll handle it through the controllers. To do this we include the PublicActivity::Common in the model instead of PublicActivity::Model. We can also removed the call to tracked here, too.

/app/models/comment.rb

class Comment < ActiveRecord::Base
 include PublicActivity::Common
  attr_accessible :content
  belongs_to :user
  belongs_to :recipe
end

Now in the CommentsController we can record the activity whenever we save, update or destroy a comment.

/app/controllers/comments_controller.rb

if @comment.save
  @comment.create_activity :create, owner: current_user
  redirect_to @recipe, notice: "Comment was created."
else
  render :new
end

This gives us more control over when and how activities are created and it means that we can avoid the workaround for setting the current user. This approach also means that we can create custom activities very easily.

To finish this episode off we’ll change the list of activities so that is only shows our fiends’ activities instead of everyone’s. We can do this by adding a scope to our ActivitiesController.

/app/controllers/activities_controller.rb

class ActivitiesController < ApplicationController
  def index
    @activities = PublicActivity::Activity.order("created_at desc").where(owner_id: current_user.friend_ids, owner_type: "User")
  end
end

Now only the activities that belong to the current user’s friends will be shown. As owner can be a polymorphic association it’s a good idea to ensure that the owner_type is User. When we reload the page now we don’t see any activities as we don’t have any other users marked as friends, but if we mark another user who has made comments as a friend then try again we’ll see their activities listed.