(t) 514 312 4307 (email) hello@giraffesoft.ca

How to Use TimelineFu: Building an activity feed for a social application

Posted by: François Beausoleil on 2009-02-26

In this article, I would like to show how to use TimelineFu to build a timeline. This is the kind of list of events that we often see on dashboards, such as on GitHub:

An example of a timeline from GitHub

I will assume you already know your way around Rails, so I won't describe the basic steps in excruciating details.

Let's start by making the application and models (I'm using Rails 2.2.2). This is a social application, with people and relationships between people as the basic entities in the domain.

$ rails myfriends
$ cd myfriends
$ git init
$ echo "*.log"     > log/.gitignore
$ echo "*.sqlite3" > db/.gitignore
$ echo "*"         > tmp/.gitignore
$ git add .
$ git commit --message "Initial commit"
$ script/generate scaffold person name:string
$ script/generate scaffold relationship person_id:integer friend_id:integer
$ rake db:migrate
$ git add .
$ git commit --message "Scaffolded models"

Time to write a minimal set of tests.

class PersonTest < ActiveSupport::TestCase
  should_have_valid_fixtures
  should_require_attributes :name
  should_allow_mass_assignment_of :name
  should_not_allow_mass_assignment_of :relationships, :friends, :relationship_ids, :friend_ids
  should_have_many :relationships
  should_have_many :friends
end

And make those pass:

class Person < ActiveRecord::Base
  validates_presence_of :name
  attr_accessible :name

  has_many :relationships
  has_many :friends, :through => :relationships
end

Turning to relationships:

class RelationshipTest < ActiveSupport::TestCase
  should_have_valid_fixtures
  should_require_attributes :friend_id, :person_id
  should_allow_mass_assignment_of :friend, :person
  should_not_allow_mass_assignment_of :friend_id, :person_id

  should_belong_to :person, :friend
end
class Relationship < ActiveRecord::Base
  belongs_to :person
  belongs_to :friend, :class_name => "Person"

  validates_presence_of :person_id, :friend_id
  attr_accessible :person, :friend
end

All pretty standard stuff, really.

Installation

Installation is pretty straightforward:

$ script/plugin install git://github.com/giraffesoft/timeline_fu.git

TimelineFu comes with a generator that will do the basics for you. The generator is completely optional. See the README for details.

$ script/generate timeline_fu
     exists  db/migrate
     create  db/migrate/20090225144815_create_timeline_events.rb
     create  app/models/timeline_event.rb

Let's see what TimelineFu generated for us:

class CreateTimelineEvents < ActiveRecord::Migration
  def self.up
    create_table :timeline_events do |t|
      t.string   :event_type, :subject_type,  :actor_type,  :secondary_subject_type
      t.integer               :subject_id,    :actor_id,    :secondary_subject_id
      t.timestamps
    end
  end
 
  def self.down
    drop_table :timeline_events
  end
end

class TimelineEvent < ActiveRecord::Base
  belongs_to :actor,              :polymorphic => true
  belongs_to :subject,            :polymorphic => true
  belongs_to :secondary_subject,  :polymorphic => true
end

TimelineFu's glossary / terminology

A basic timeline event looks like this:

François added you as a friend

The actor is the person or thing that did an action. The subject is what was acted against. The secondary subject is supporting documentation about the subject. And the event type is pretty self-explanatory. The example above looks like:

  • Actor: François
  • Subject: You (a Person instance)
  • Secondary Subject: the Relationship instance
  • Event Type: "added you as a friend" (friended)

Another example:

François posted a new comment on "How to use TimelineFu to build timelines"

  • Actor: François
  • Subject: Comment
  • Secondary Subject: the Post
  • Event Type: "posted a new comment" (commented)

Let's begin by writing a failing test.

class RelationshipTest < Test::Unit::TestCase
  context "Adding another person as a friend" do
    setup do
      @francois = person(:francois)
      @james    = person(:james)
      @francois.friends << @james
    end

    should_change "TimelineEvent.count", :by => 1
  end
end

Run and see the test fail. Open up TimelineFu's README and look at how timeline events are created. Hint: it's called #fires.

class Relationship < ActiveRecord::Base
  fires :friended, :on => :create
end

If you were to look at the attributes of the timeline_event as it was created, here's what you would find:

--- !ruby/object:TimelineEvent 
attributes: 
  id: "1"
  event_type: friended
  actor_type: 
  actor_id: 
  subject_type: Relationship
  subject_id: "1"
  secondary_subject_type: 
  secondary_subject_id: 
  created_at: 2009-02-25 15:39:54
  updated_at: 2009-02-25 15:39:54

Notice that actor and secondary subject are nil. This is because we haven't specified any options to the #fires call. Let's remedy the situation with a new set of failing tests:

class RelationshipTest < ActiveSupport::TestCase
  context "Adding another person as a friend" do
    context "the timeline event" do
      setup do
        @event = TimelineEvent.last
      end

      should "set the actor to be the person who created the friendship" do
        assert_equal @francois, @event.actor
      end

      should "set the subject to the relationship" do
        assert_equal @francois.relationships.first, @event.subject
      end

      should "set the secondary subject to the new friend" do
        assert_equal @james, @event.secondary_subject
      end

      should "set the event type to be 'friended'" do
        assert_equal "friended", @event.event_type
      end
    end
  end
end
class Relationship < ActiveRecord::Base
  fires :friended, :on => :create, :actor => :person, :subject => :friend, :secondary_subject => :self
end

It's nice to know when someone friends you, but isn't it even better to know when they don't want you as friends anymore? This way, you'll know not to give them gifts when it's their birthday. A new failing test:

class RelationshipTest < Test::Unit::TestCase
  context "Deleting the relationship" do
    setup do
      relationships(:james_to_francois).destroy
    end

    should_change "TimelineEvent.count", :by => 1

    context "the timeline event" do
      setup do
        @event = TimelineEvent.last
      end

      should "set the actor to the person who destroyed the friendship" do
        assert_equal people(:james), @event.actor
      end

      should "set the subject to the relationship" do
        # It's been deleted...  Oops!
      end

      should "set the secondary subject to the old friend" do
        assert_equal people(:francois), @event.secondary_subject
      end

      should "set the event type to 'unfriended'" do
        assert_equal "unfriended", @event.event_type
      end
    end
  end
end

And the implementation:

class Relationship < ActiveRecord::Base
  fires :unfriended, :on => :destroy, :actor => :person, :secondary_subject => :friend
end

Rendering the events feed

We need a way of getting recent events for any person. Let's analyze what we want to achieve. If someone adds me as a friend, I want that event to appear in my timeline (timeline_events WHERE subject == self). I also want to see events of my friends, such as "James added Mat as a friend" (timeline_events WHERE actor IN (my friends)). Let's write a first failing test:

class PersonTest < ActiveSupport::TestCase
  context "A new person" do
    context "where James friends self" do
      setup do
        people(:james).friends << @person
      end

      should_change "@person.recent_events.count", :by => 1
    end
  end
end

Let's do the simplest thing that could possibly work:

class Person < ActiveRecord::Base
  has_many :recent_events, :as => :subject, :class_name => "TimelineEvent", :order => "timeline_events.created_at DESC"
end

That works just fine. But how do I get to the events of my friends? My friends events are the ones where actor_id is one of my friends. Let's write another failing test:

class PersonTest < ActiveSupport::TestCase
  context "A new person" do
    context "where James friends self" do
      context "where James friends someone else when James is my friend" do
        setup do
          @person.friends << people(:james)
          people(:james).friends << Person.create!(:name => "Daniel")
        end

        should_change "@person.recent_events.count", :to => 2
      end
    end
  end
end

And since we want both where subject = X or actor = X, we must use the :finder_sql option of has_many:

class Person < ActiveRecord::Base
  RECENT_EVENTS_CONDITION = 
    '(subject_id = #{id} AND subject_type = \'Person\')
    OR (actor_type = \'Person\'
    AND actor_id IN (SELECT friend_id
                    FROM relationships
                    WHERE relationships.person_id = #{id}))'
    has_many :recent_events,
      :class_name  => "TimelineEvent",
      :finder_sql  => 'SELECT timeline_events.* FROM timeline_events
                      WHERE ' + RECENT_EVENTS_CONDITION + '
                      ORDER BY timeline_events.created_at DESC',
      :counter_sql => 'SELECT COUNT(*) FROM timeline_events
                      WHERE ' + RECENT_EVENTS_CONDITION
end

Now we turn our attention to the user interface. How do we present this information in a nice way to the user? At giraffesoft, we do not write view tests. Views change too often for the tests to be useful. We do use Cucumber though. Anyway, do the simplest thing that could possibly work:

<%= render_timeline @person.recent_events %>

Since TimelineEvent has a field called event_type, let's use that to render a different partial.

module RenderHelper
  def render_timeline(events)
    events.map do |event|
      render(:partial => "timeline_events/#{event.event_type}", :object => event)
    end.join
  end
end

I cannot use render :partial => events here, because all the events have the same type, namely TimelineEvent. I want to render a different partial depending on the value of event_type.

Introducing the timeline_fu Example App

The code for this article is available on github, as the timeline_fu-example application. It is a sample / starter application for rendering event feeds. Fork away!

blog comments powered by Disqus