├── .rspec ├── lib ├── chalk_dust │ ├── version.rb │ ├── rails.rb │ ├── activity_item.rb │ ├── rails │ │ └── generators │ │ │ ├── install_migrations.rb │ │ │ └── templates │ │ │ └── migration.rb │ └── connection.rb └── chalk_dust.rb ├── Rakefile ├── Guardfile ├── gemfiles ├── activerecord-3.0 └── activerecord-4.0 ├── spec ├── support │ ├── models.rb │ └── schema.rb ├── spec_helper.rb └── lib │ └── chalk_dust │ ├── unsubscribing_spec.rb │ ├── publishing_spec.rb │ ├── activity_feeds_spec.rb │ └── subscribing_spec.rb ├── .gitignore ├── Gemfile ├── .travis.yml ├── chalk_dust.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /lib/chalk_dust/version.rb: -------------------------------------------------------------------------------- 1 | module ChalkDust 2 | VERSION = "0.0.2" 3 | end 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new 7 | 8 | task :default => :spec 9 | -------------------------------------------------------------------------------- /lib/chalk_dust/rails.rb: -------------------------------------------------------------------------------- 1 | class ChalkDustRailtie < Rails::Railtie 2 | generators do 3 | require "chalk_dust/rails/generators/install_migrations" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec' do 2 | watch(%r{^spec/.+_spec\.rb$}) 3 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } 4 | watch('spec/spec_helper.rb') { "spec" } 5 | watch(%r{^spec/support/(.+)\.rb$}) { "spec" } 6 | end 7 | 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-3.0: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "sqlite3", :platform => :ruby 4 | gem "activerecord-jdbcsqlite3-adapter", :platform => :jruby 5 | 6 | gem 'activerecord', '~> 3.0' 7 | 8 | gemspec :path => '../' 9 | -------------------------------------------------------------------------------- /gemfiles/activerecord-4.0: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "sqlite3", :platform => :ruby 4 | 5 | gem "activerecord-jdbcsqlite3-adapter", :platform => :jruby 6 | 7 | gem 'activerecord', '4.0.0' 8 | 9 | gemspec :path => '../' 10 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | has_many :comments 3 | end 4 | 5 | class Post < ActiveRecord::Base 6 | has_many :comments 7 | end 8 | 9 | class Comment < ActiveRecord::Base 10 | belongs_to :post 11 | belongs_to :user 12 | end 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .rbx 7 | Gemfile.lock 8 | gemfiles/*.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | spec/db.sqlite3 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "sqlite3", :platform => :ruby 7 | gem "activerecord-jdbcsqlite3-adapter", :platform => :jruby 8 | end 9 | 10 | group :development do 11 | gem 'rake' 12 | gem 'bundler' 13 | gem 'simplecov' 14 | gem 'guard-rspec' 15 | gem 'wwtd' 16 | end 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | script: rspec spec 3 | gemfile: 4 | - gemfiles/activerecord-3.0 5 | - gemfiles/activerecord-4.0 6 | rvm: 7 | - '1.9.2' 8 | - '1.9.3' 9 | - '2.0.0' 10 | - jruby-19mode 11 | - jruby-20mode 12 | matrix: 13 | exclude: 14 | # ActiveRecord 4.0 requires Ruby 1.9.3 or better 15 | - rvm: 1.9.2 16 | gemfile: gemfiles/activerecord-4.0 17 | -------------------------------------------------------------------------------- /lib/chalk_dust/activity_item.rb: -------------------------------------------------------------------------------- 1 | module ChalkDust 2 | class ActivityItem < ActiveRecord::Base 3 | belongs_to :performer, :polymorphic => true 4 | belongs_to :target, :polymorphic => true 5 | belongs_to :owner, :polymorphic => true 6 | 7 | validates :event, :presence => true 8 | 9 | def self.for_owner(owner) 10 | where(:owner_id => owner.id, 11 | :owner_type => owner.class.to_s) 12 | end 13 | 14 | def self.with_topic(topic) 15 | where(:topic => topic) 16 | end 17 | 18 | def self.since(time) 19 | where("created_at >= ?", time) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/chalk_dust/rails/generators/install_migrations.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators/migration' 2 | require 'rails/generators/active_record' 3 | 4 | module ChalkDust 5 | module Generators 6 | class InstallMigrations < Rails::Generators::Base 7 | include Rails::Generators::Migration 8 | 9 | source_root File.expand_path("../templates", __FILE__) 10 | 11 | desc "Generates (but does not run) the migrations for chalk dust" 12 | def install_migrations 13 | migration_template "migration.rb", "db/migrate/chalk_dust_create_tables" 14 | end 15 | def self.next_migration_number(dirname) 16 | ActiveRecord::Generators::Base.next_migration_number(dirname) 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'simplecov' 3 | SimpleCov.start 4 | rescue LoadError 5 | end 6 | 7 | require 'active_record' 8 | require 'chalk_dust' 9 | 10 | puts "Using ActiveRecord #{ActiveRecord::VERSION::STRING}" 11 | 12 | adapter = RUBY_PLATFORM == "java" ? 'jdbcsqlite3' : 'sqlite3' 13 | 14 | ActiveRecord::Base.establish_connection(:adapter => adapter, 15 | :database => File.dirname(__FILE__) + "/db.sqlite3") 16 | 17 | load File.dirname(__FILE__) + '/support/schema.rb' 18 | 19 | require 'support/models' 20 | 21 | RSpec.configure do |config| 22 | config.treat_symbols_as_metadata_keys_with_true_values = true 23 | config.run_all_when_everything_filtered = true 24 | config.filter_run :focus 25 | config.order = 'random' 26 | end 27 | -------------------------------------------------------------------------------- /lib/chalk_dust/rails/generators/templates/migration.rb: -------------------------------------------------------------------------------- 1 | class ChalkDustCreateTables < ActiveRecord::Migration 2 | def change 3 | create_table :connections, :force => true do |t| 4 | t.integer :subscriber_id 5 | t.string :subscriber_type 6 | t.integer :publisher_id 7 | t.string :publisher_type 8 | t.string :topic 9 | t.timestamps 10 | end 11 | 12 | add_index :connections, [:publisher_id, :publisher_type, :topic], :unique => true 13 | 14 | create_table :activity_items, :force => true do |t| 15 | t.integer :performer_id 16 | t.string :performer_type 17 | t.string :event 18 | t.integer :target_id 19 | t.string :target_type 20 | t.integer :owner_id 21 | t.string :owner_type 22 | t.string :topic 23 | t.timestamps 24 | end 25 | 26 | add_index :activity_items, [:owner_id, :owner_type, :created_at] 27 | add_index :activity_items, [:owner_id, :owner_type, :created_at, :topic], :name => 'activity_items_owner_id_type_created_at_topic' 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/chalk_dust/connection.rb: -------------------------------------------------------------------------------- 1 | module ChalkDust 2 | class Connection < ActiveRecord::Base 3 | belongs_to :publisher, :polymorphic => true 4 | belongs_to :subscriber, :polymorphic => true 5 | 6 | def self.for_publisher(publisher, options) 7 | topic = options.fetch(:topic) 8 | 9 | where(:publisher_id => publisher.id, 10 | :publisher_type => publisher.class.to_s, 11 | :topic => topic) 12 | end 13 | 14 | def self.for_subscriber(subscriber) 15 | where(:subscriber_id => subscriber.id, 16 | :subscriber_type => subscriber.class.to_s) 17 | end 18 | 19 | def self.delete(options) 20 | publisher = options.fetch(:publisher) 21 | subscriber = options.fetch(:subscriber) 22 | topic = options.fetch(:topic) 23 | conditions = { :publisher_id => publisher.id, 24 | :publisher_type => publisher.class.to_s, 25 | :subscriber_id => subscriber.id, 26 | :subscriber_type => subscriber.class.to_s } 27 | conditions = conditions.merge(:topic => topic) unless topic == :all 28 | destroy_all(conditions) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | self.verbose = false 3 | 4 | create_table :users, :force => true do |t| 5 | t.string :name 6 | t.string :email 7 | t.timestamps 8 | end 9 | 10 | create_table :posts, :force => true do |t| 11 | t.string :title 12 | t.string :body 13 | t.timestamps 14 | end 15 | 16 | create_table :comments, :force => true do |t| 17 | t.integer :user_id 18 | t.integer :post_id 19 | t.string :body 20 | t.timestamps 21 | end 22 | 23 | create_table :connections, :force => true do |t| 24 | t.integer :subscriber_id 25 | t.string :subscriber_type 26 | t.integer :publisher_id 27 | t.string :publisher_type 28 | t.string :topic 29 | t.timestamps 30 | end 31 | 32 | add_index :connections, [:publisher_id, :publisher_type, :topic], :unique => true 33 | 34 | create_table :activity_items, :force => true do |t| 35 | t.integer :performer_id 36 | t.string :performer_type 37 | 38 | t.string :event 39 | 40 | t.integer :target_id 41 | t.string :target_type 42 | 43 | t.integer :owner_id 44 | t.string :owner_type 45 | 46 | t.string :topic 47 | 48 | t.timestamps 49 | end 50 | 51 | add_index :activity_items, [:owner_id, :owner_type, :created_at] 52 | add_index :activity_items, [:owner_id, :owner_type, :created_at, :topic], :name => 'activity_items_owner_id_type_created_at_topic' 53 | end 54 | -------------------------------------------------------------------------------- /chalk_dust.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/chalk_dust/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Kris Leech"] 6 | gem.email = ["kris.leech@gmail.com"] 7 | gem.description = <<-DESC 8 | Subscriptions connect models, events build activity feeds. 9 | 10 | Designed to scale. 11 | 12 | ChalkDust can be used to build activty feeds such as followings and 13 | friendships by allowing models to subscribe to activity feeds published by 14 | other models. 15 | 16 | Every time an activity occurs it is copied to all subscribers of the target 17 | of that activity. This creates an activty feed per subscriber. This results 18 | in more data but scales better and allows additional features such as the 19 | ability of the subscriber to delete/hide activity items. 20 | 21 | Each publisher can create multiple feeds by means of topics. For example a 22 | user might publish activities with topics of 'family' or 'work'. 23 | 24 | Please check the documentation . 25 | DESC 26 | gem.summary = %q{Subscribe to and build activity feeds for your models, for example followings and friendships. Build to scale.} 27 | gem.homepage = "https://github.com/krisleech/chalk_dust" 28 | gem.license = "MIT" 29 | 30 | gem.files = `git ls-files`.split($\) 31 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 32 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 33 | gem.name = "chalk_dust" 34 | gem.require_paths = ["lib"] 35 | gem.version = ChalkDust::VERSION 36 | gem.required_ruby_version = ">= 1.9.2" 37 | 38 | gem.add_dependency "activerecord", ">= 3.0.0" 39 | 40 | gem.add_development_dependency "rspec", "~> 2.0" 41 | end 42 | -------------------------------------------------------------------------------- /spec/lib/chalk_dust/unsubscribing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ChalkDust do 4 | before(:each) { ChalkDust::Connection.delete_all } 5 | 6 | describe '.unsubscribe' do 7 | it 'disconnects two objects with no topic' do 8 | user = User.create! 9 | post = Post.create! 10 | 11 | with_topic = ChalkDust::Connection.create!(:subscriber => user, 12 | :publisher => post, 13 | :topic => 'family') 14 | 15 | without_topic = ChalkDust::Connection.create!(:subscriber => user, 16 | :publisher => post) 17 | 18 | ChalkDust.unsubscribe(user, :from => post) 19 | 20 | ChalkDust::Connection.all.should == [with_topic] 21 | end 22 | 23 | describe 'options' do 24 | it ':topic disconnects only connections with given topic' do 25 | user = User.create! 26 | post = Post.create! 27 | 28 | work_topic = ChalkDust::Connection.create!(:subscriber => user, 29 | :publisher => post, 30 | :topic => 'work') 31 | 32 | family_topic = ChalkDust::Connection.create!(:subscriber => user, 33 | :publisher => post, 34 | :topic => 'family') 35 | 36 | without_topic = ChalkDust::Connection.create!(:subscriber => user, 37 | :publisher => post) 38 | 39 | ChalkDust.unsubscribe(user, :from => post, :topic => 'family') 40 | 41 | ChalkDust::Connection.all.should == [work_topic, without_topic] 42 | end 43 | 44 | it ':topic unsubscribes connections with/out topics when give :all' do 45 | user = User.create! 46 | post = Post.create! 47 | 48 | work_topic = ChalkDust::Connection.create!(:subscriber => user, 49 | :publisher => post, 50 | :topic => 'work') 51 | 52 | family_topic = ChalkDust::Connection.create!(:subscriber => user, 53 | :publisher => post, 54 | :topic => 'family') 55 | 56 | without_topic = ChalkDust::Connection.create!(:subscriber => user, 57 | :publisher => post) 58 | 59 | ChalkDust.unsubscribe(user, :from => post, :topic => :all) 60 | 61 | ChalkDust::Connection.all.should be_empty 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/chalk_dust.rb: -------------------------------------------------------------------------------- 1 | require "chalk_dust/version" 2 | require "chalk_dust/connection" 3 | require "chalk_dust/activity_item" 4 | require "chalk_dust/rails" if defined?(Rails) 5 | 6 | module ChalkDust 7 | def self.subscribe(subscriber, options) 8 | publisher = options.fetch(:to) 9 | undirected = options.fetch(:undirected, false) 10 | topic = options.fetch(:topic, blank_topic) 11 | 12 | return if subscribed?(subscriber, :to => publisher, :topic => topic) 13 | 14 | Connection.create(:subscriber => subscriber, 15 | :publisher => publisher, 16 | :topic => topic) 17 | 18 | Connection.create(:subscriber => publisher, 19 | :publisher => subscriber, 20 | :topic => topic) if undirected 21 | end 22 | 23 | def self.unsubscribe(subscriber, options) 24 | publisher = options.fetch(:from) 25 | topic = options.fetch(:topic, blank_topic) 26 | Connection.delete(:subscriber => subscriber, 27 | :publisher => publisher, 28 | :topic => topic) 29 | end 30 | 31 | def self.subscribers_of(publisher, options = {}) 32 | topic = options.fetch(:topic, blank_topic) 33 | Connection.for_publisher(publisher, :topic => topic).map(&:subscriber) 34 | end 35 | 36 | def self.publishers_of(subscriber) 37 | Connection.for_subscriber(subscriber).map(&:publisher) 38 | end 39 | 40 | def self.subscribed?(subscriber, options) 41 | publisher = options.fetch(:to) 42 | topic = options.fetch(:topic, blank_topic) 43 | subscribers_of(publisher, :topic => topic).include?(subscriber) 44 | end 45 | 46 | def self.self_subscribe(publisher_subscriber) 47 | subscribe(publisher_subscriber, :to => publisher_subscriber) 48 | end 49 | 50 | # publishes an event where X (performer) did Y (event) to Z (target) to every 51 | # subscriber of the target 52 | def self.publish_event(performer, event, target, options = {}) 53 | root_publisher = options.fetch(:root, target) 54 | topic = options.fetch(:topic, blank_topic) 55 | subscribers_of(root_publisher, :topic => topic).map do |subscriber| 56 | ActivityItem.create(:performer => performer, 57 | :event => event, 58 | :target => target, 59 | :owner => subscriber, 60 | :topic => topic) 61 | end 62 | end 63 | 64 | def self.activity_feed_for(subscriber, options = {}) 65 | topic = options.fetch(:topic, blank_topic) 66 | activity_items = ActivityItem.for_owner(subscriber) 67 | activity_items = activity_items.since(options[:since]) if options[:since].present? 68 | activity_items = activity_items.with_topic(topic) unless topic == :all 69 | activity_items 70 | end 71 | 72 | private 73 | 74 | def self.blank_topic 75 | nil 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/lib/chalk_dust/publishing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'publishing' do 4 | before(:each) do 5 | ChalkDust::Connection.delete_all 6 | ChalkDust::ActivityItem.delete_all 7 | end 8 | 9 | describe '.publish_event' do 10 | it 'creates an event for every subscriber' do 11 | kris = User.create! 12 | lindsey = User.create! 13 | hallie = User.create! 14 | post = Post.create! 15 | 16 | ChalkDust::Connection.create(:subscriber => kris, :publisher => post) 17 | ChalkDust::Connection.create(:subscriber => lindsey, :publisher => post) 18 | 19 | activity_items = ChalkDust.publish_event(kris, 'editted', post) 20 | 21 | activity_items.size.should == 2 22 | 23 | activity_item = activity_items.first 24 | activity_item.performer.should == kris 25 | activity_item.event.should == 'editted' 26 | activity_item.target.should == post 27 | activity_item.owner.should == kris 28 | 29 | activity_item = activity_items.last 30 | activity_item.performer.should == kris 31 | activity_item.event.should == 'editted' 32 | activity_item.target.should == post 33 | activity_item.owner.should == lindsey 34 | end 35 | 36 | describe 'options' do 37 | describe ':root' do 38 | it 'sets the root object of the target' do 39 | kris = User.create! 40 | lindsey = User.create! 41 | post = Post.create! 42 | comment = Comment.create!(:post => post) 43 | 44 | ChalkDust::Connection.create(:subscriber => lindsey, :publisher => post) 45 | 46 | activity_items = ChalkDust.publish_event(kris, 'added', comment, :root => comment.post) 47 | 48 | activity_items.size.should == 1 49 | 50 | activity_item = activity_items.first 51 | activity_item.performer.should == kris 52 | activity_item.event.should == 'added' 53 | activity_item.target.should == comment 54 | activity_item.owner.should == lindsey 55 | end 56 | end 57 | 58 | describe ':topic' do 59 | it 'sets the topic' do 60 | user = User.create! 61 | post = Post.create! 62 | 63 | ChalkDust::Connection.create(:subscriber => user, 64 | :publisher => post, 65 | :topic => 'family') 66 | 67 | activity_items = ChalkDust.publish_event(user, 'liked', post, :topic => 'family') 68 | 69 | activity_item = activity_items.first 70 | activity_item.topic.should == 'family' 71 | end 72 | end 73 | end 74 | 75 | # pending 'target root can be set by the `activity_root` method on the target' 76 | end 77 | 78 | describe 'fetching publications' do 79 | it '.publishers_of returns publishers of given object' do 80 | user = User.create! 81 | post = Post.create! 82 | 83 | ChalkDust::Connection.create!(:subscriber => user, :publisher => post) 84 | 85 | ChalkDust.publishers_of(user).should == [post] 86 | end 87 | end 88 | 89 | end 90 | 91 | -------------------------------------------------------------------------------- /spec/lib/chalk_dust/activity_feeds_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'activity feeds' do 4 | before(:each) do 5 | ChalkDust::ActivityItem.delete_all 6 | end 7 | 8 | describe '.activity_feed_for' do 9 | it 'returns activity feed for given object' do 10 | kris = User.create! 11 | lindsey = User.create! 12 | hallie = User.create! 13 | post = Post.create! 14 | comment = Comment.create! 15 | 16 | activity_item_1 = ChalkDust::ActivityItem.create(:performer => kris, 17 | :event => 'editted', 18 | :target => post, 19 | :owner => hallie) 20 | 21 | activity_item_2 = ChalkDust::ActivityItem.create(:performer => lindsey, 22 | :event => 'added', 23 | :target => comment, 24 | :owner => hallie) 25 | 26 | activity_items = ChalkDust.activity_feed_for(hallie) 27 | activity_items.should == [activity_item_1, activity_item_2] 28 | end 29 | 30 | describe 'options' do 31 | it ':since limits to activities created since the given date' do 32 | kris = User.create! 33 | lindsey = User.create! 34 | hallie = User.create! 35 | post = Post.create! 36 | comment = Comment.create! 37 | 38 | activity_item_1 = ChalkDust::ActivityItem.create(:performer => kris, 39 | :event => 'editted', 40 | :target => post, 41 | :owner => hallie, 42 | :created_at => 2.months.ago) 43 | 44 | activity_item_2 = ChalkDust::ActivityItem.create(:performer => lindsey, 45 | :event => 'added', 46 | :target => comment, 47 | :owner => hallie) 48 | 49 | activity_items = ChalkDust.activity_feed_for(hallie, :since => Time.now - 1.week) 50 | activity_items.should == [activity_item_2] 51 | end 52 | 53 | describe ':topic' do 54 | it 'limits activites to those without a topic when no topic given' do 55 | kris = User.create! 56 | hallie = User.create! 57 | post = Post.create! 58 | 59 | activity_item = ChalkDust::ActivityItem.create(:performer => kris, 60 | :event => 'liked', 61 | :target => post, 62 | :owner => hallie, 63 | :topic => 'family') 64 | 65 | activity_items = ChalkDust.activity_feed_for(hallie) 66 | activity_items.should_not include activity_item 67 | end 68 | 69 | it 'limits activites to those with given topic' do 70 | kris = User.create! 71 | hallie = User.create! 72 | post = Post.create! 73 | 74 | activity_item_1 = ChalkDust::ActivityItem.create(:performer => kris, 75 | :event => 'editted', 76 | :target => post, 77 | :owner => hallie) 78 | 79 | activity_item_2 = ChalkDust::ActivityItem.create(:performer => kris, 80 | :event => 'editted', 81 | :target => post, 82 | :owner => hallie, 83 | :topic => 'work') 84 | 85 | activity_item_3 = ChalkDust::ActivityItem.create(:performer => kris, 86 | :event => 'liked', 87 | :target => post, 88 | :owner => hallie, 89 | :topic => 'family') 90 | 91 | activity_items = ChalkDust.activity_feed_for(hallie, :topic => 'family') 92 | activity_items.should == [activity_item_3] 93 | end 94 | 95 | it 'given :all returns all activities' do 96 | kris = User.create! 97 | hallie = User.create! 98 | post = Post.create! 99 | 100 | activity_item_1 = ChalkDust::ActivityItem.create(:performer => kris, 101 | :event => 'editted', 102 | :target => post, 103 | :owner => hallie) 104 | 105 | activity_item_2 = ChalkDust::ActivityItem.create(:performer => kris, 106 | :event => 'editted', 107 | :target => post, 108 | :owner => hallie, 109 | :topic => 'work') 110 | 111 | activity_items = ChalkDust.activity_feed_for(hallie, :topic => :all) 112 | activity_items.should == [activity_item_1, activity_item_2] 113 | end 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/lib/chalk_dust/subscribing_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ChalkDust do 4 | before(:each) { ChalkDust::Connection.delete_all } 5 | 6 | describe 'subscribing' do 7 | 8 | describe '.subscribe' do 9 | it 'connects given objects' do 10 | user = User.create! 11 | post = Post.create! 12 | 13 | ChalkDust.subscribe(user, :to => post) 14 | 15 | connection = ChalkDust::Connection.first 16 | connection.subscriber.should == user 17 | connection.publisher.should == post 18 | end 19 | 20 | it 'does not connect objects multiple times when no topic given' do 21 | user = User.create! 22 | post = Post.create! 23 | 24 | expect { 25 | ChalkDust.subscribe(user, :to => post) 26 | ChalkDust.subscribe(user, :to => post) 27 | }.to change{ ChalkDust::Connection.count }.by(1) 28 | end 29 | 30 | it 'does not connect objects multiple times when the same topic given' do 31 | user = User.create! 32 | post = Post.create! 33 | 34 | expect { 35 | ChalkDust.subscribe(user, :to => post, :topic => 'family') 36 | ChalkDust.subscribe(user, :to => post, :topic => 'family') 37 | }.to change{ ChalkDust::Connection.count }.by(1) 38 | end 39 | 40 | it 'does connect objects multiple times when different topics given' do 41 | user = User.create! 42 | post = Post.create! 43 | 44 | expect { 45 | ChalkDust.subscribe(user, :to => post, :topic => 'family') 46 | ChalkDust.subscribe(user, :to => post, :topic => 'work') 47 | }.to change{ ChalkDust::Connection.count }.by(2) 48 | end 49 | end 50 | 51 | it '.self_subscribe connects object to itself' do 52 | user = User.create! 53 | 54 | ChalkDust.self_subscribe(user) 55 | 56 | connection = ChalkDust::Connection.first 57 | connection.subscriber.should == user 58 | connection.publisher.should == user 59 | end 60 | 61 | describe 'options' do 62 | it ':undirected subscribes in both directions' do 63 | bob = User.create! 64 | alice = User.create! 65 | 66 | ChalkDust.subscribe(bob, :to => alice, :undirected => true) 67 | 68 | ChalkDust::Connection.count.should == 2 69 | 70 | connection_1 = ChalkDust::Connection.first 71 | connection_1.subscriber.should == bob 72 | connection_1.publisher.should == alice 73 | 74 | connection_2 = ChalkDust::Connection.last 75 | connection_2.subscriber.should == alice 76 | connection_2.publisher.should == bob 77 | end 78 | 79 | it ':topic sets the subscription topic' do 80 | bob = User.create! 81 | alice = User.create! 82 | 83 | ChalkDust.subscribe(bob, :to => alice, :topic => 'work') 84 | 85 | connection = ChalkDust::Connection.first 86 | connection.topic.should == 'work' 87 | end 88 | end 89 | end 90 | 91 | describe 'fetching subscriptions' do 92 | let(:user) { User.create! } 93 | let(:post) { Post.create! } 94 | 95 | context 'objects connected with no topic' do 96 | before { ChalkDust::Connection.create!(:subscriber => user, :publisher => post) } 97 | 98 | it { ChalkDust.subscribers_of(post).should == [user] } 99 | it { ChalkDust.subscribers_of(post, :topic => 'family').should == [] } 100 | end 101 | 102 | context 'objects connected with topic' do 103 | before { ChalkDust::Connection.create!(:subscriber => user, :publisher => post, :topic => 'family') } 104 | 105 | it { ChalkDust.subscribers_of(post).should == [] } 106 | it { ChalkDust.subscribers_of(post, :topic => 'family').should == [user] } 107 | it { ChalkDust.subscribers_of(post, :topic => 'work').should == [] } 108 | end 109 | end 110 | 111 | describe 'querying subscriptions' do 112 | let(:user) { User.create! } 113 | let(:post) { Post.create! } 114 | 115 | context 'no connection between objects' do 116 | it '.subscribed? returns false when no topic given' do 117 | ChalkDust.subscribed?(user, :to => post).should be_false 118 | end 119 | 120 | it '.subscribed? returns false when topic given' do 121 | ChalkDust.subscribed?(user, :to => post, :topic => 'family').should be_false 122 | end 123 | end 124 | 125 | context 'connection with topic between objects' do 126 | before { ChalkDust::Connection.create!(:subscriber => user, :publisher => post, :topic => 'family') } 127 | 128 | it '.subscribed? returns true when given topic matches' do 129 | ChalkDust.subscribed?(user, :to => post, :topic => 'family').should be_true 130 | end 131 | 132 | it '.subscribed? returns false when no topic given' do 133 | ChalkDust.subscribed?(user, :to => post).should be_false 134 | end 135 | 136 | it '.subscribed? returns false when topic does not match' do 137 | ChalkDust.subscribed?(user, :to => post, :topic => 'work').should be_false 138 | end 139 | end 140 | 141 | context 'connection with no topic between objects' do 142 | before { ChalkDust::Connection.create!(:subscriber => user, :publisher => post) } 143 | 144 | it '.subscribed? returns true when no topic given' do 145 | ChalkDust.subscribed?(user, :to => post).should be_true 146 | end 147 | 148 | it '.subscribed? returns false when topic given' do 149 | ChalkDust.subscribed?(user, :to => post, :topic => 'family').should be_false 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChalkDust 2 | 3 | Subscriptions connect models, events build activity feeds. 4 | 5 | Designed to scale. 6 | 7 | ChalkDust can be used to build activty feeds such as followings and friendships 8 | by allowing models to subscribe to activity feeds published by other models. 9 | 10 | Every time an activity occurs it is copied to all subscribers of the target of 11 | that activity. This creates an activity feed per subscriber. This results in 12 | more data but scales better and allows additional features such as the ability 13 | of the subscriber to delete/hide activity items. 14 | 15 | Each publisher can create multiple feeds by means of topics. For example a 16 | user might publish activities with topics of 'family' or 'work'. 17 | 18 | [![Code Climate](https://codeclimate.com/github/krisleech/chalk_dust.png)](https://codeclimate.com/github/krisleech/chalk_dust) 19 | [![Build Status](https://travis-ci.org/krisleech/chalk_dust.png?branch=master)](https://travis-ci.org/krisleech/chalk_dust) 20 | [![endorse](https://api.coderwall.com/krisleech/endorsecount.png)](https://coderwall.com/krisleech) 21 | [![Bitdeli Badge](https://d2weczhvl823v0.cloudfront.net/krisleech/chalk_dust/trend.png)](https://bitdeli.com/free "Bitdeli Badge") 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'chalk_dust' 29 | ``` 30 | 31 | ChalkDust has a dependency on ActiveRecord (3.0 or 4.0) and a soft dependency 32 | on Rails. If you are using Rails you can install the migrations as follows: 33 | 34 | ``` 35 | rails generate chalk_dust:install_migrations 36 | ``` 37 | 38 | ## Usage 39 | 40 | ### Subscribing 41 | 42 | A subscription connects two objects, e.g "Sam" follows "My first blog post" 43 | 44 | ```ruby 45 | ChalkDust.subscribe(user, :to => post) 46 | ``` 47 | 48 | ```ruby 49 | ChalkDust.subscribers_of(post) # => [user] 50 | ``` 51 | 52 | #### Self-subscribing 53 | 54 | Typically you will want to self-subscribe models on creation to themseleves so 55 | an activity feed is built for the model itself. 56 | 57 | ```ruby 58 | ChalkDust.self_subscribe(user) 59 | # or 60 | ChalkDust.subscribe(user, :to => user) 61 | ``` 62 | 63 | #### Undirected subscriptions 64 | 65 | All subscriptions are directed. You can create a two way subscription, e.g a 66 | friendship, as follows: 67 | 68 | ```ruby 69 | ChalkDust.subscribe(bob, :to => alice, :undirected => true) 70 | # or 71 | ChalkDust.subscribe(bob, :to => alice) 72 | ChalkDust.subscribe(alice, :to => bob) 73 | ``` 74 | 75 | #### Topics 76 | 77 | If you want the subscription to only apply to activities with a particular 78 | topic you can do so as follows: 79 | 80 | ```ruby 81 | ChalkDust.subscribe(alice, :to => bob, :topic => 'work') 82 | ChalkDust.subscribe(alice, :to => bob, :topic => 'family') 83 | ``` 84 | 85 | This is useful if you need to publish multiple activity feeds for an object. 86 | 87 | Example uses for this would be seperate public and private activity streams 88 | for an object, or something like circles. 89 | 90 | ### Publishing activity 91 | 92 | Describes an event where X (performer) did Y (activity) to Z (target). 93 | 94 | ```ruby 95 | ChalkDust.publish_event(user, 'added', comment) 96 | ``` 97 | 98 | If the activity should be published to the feed of an object other than the 99 | target then it can be either be passed with the `:root` argument: 100 | 101 | ```ruby 102 | ChalkDust.publish_event(user, 'added', comment, :root => comment.post) 103 | ``` 104 | 105 | #### Topics 106 | 107 | Optionally publish an event to a specific topic: 108 | 109 | ```ruby 110 | ChalkDust.publish_event(user, 'uploaded', photo, :root => user, 111 | :topic => 'family') 112 | ``` 113 | 114 | ### Activity Feeds 115 | 116 | ```ruby 117 | ChalkDust.activity_feed_for(user, :since => 2.weeks.ago) 118 | ChalkDust.activity_feed_for(post, :since => 2.weeks.ago) 119 | ``` 120 | 121 | #### Topics 122 | 123 | Fetch an activity feed for a specific topic: 124 | 125 | ```ruby 126 | ChalkDust.activity_feed_for(user, :topic => 'family') 127 | ``` 128 | 129 | If you do not specify a topic you will only get activities which where not 130 | published with a topic. To get all activities (those with and without a topic) 131 | pass `:all` to `:topic`: 132 | 133 | ```ruby 134 | ChalkDust.activity_feed_for(user, :topic => :all) 135 | ``` 136 | 137 | You can still use `'all'` as a regular topic, but you can not specify it as a 138 | symbol as you can with other topics. 139 | 140 | ### Unsubscribing 141 | 142 | ```ruby 143 | ChalkDust.unsubscribe(user, :from => post) 144 | ChalkDust.unsubscribe(user, :from => post, :topic => 'work') 145 | ChalkDust.unsubscribe(user, :from => post, :topic => :all) 146 | ``` 147 | 148 | ## Compatibility 149 | 150 | Tested with MRI 1.9.x, MRI 2.0.0, JRuby (1.9 and 2.0 mode) against both 151 | ActiveRecord 3.0 and 4.0. 152 | 153 | See the [build status](https://travis-ci.org/krisleech/chalk_dust) for details. 154 | 155 | ## Contributing 156 | 157 | Contributions welcome, please fork and send a pull request. 158 | 159 | ## Running Specs 160 | 161 | To run the specs for all supported combinations of Ruby and ActiveRecord: 162 | 163 | ``` 164 | wwtd 165 | ``` 166 | 167 | To run the specs for a specific version of ActiveRecord: 168 | 169 | ``` 170 | env BUNDLE_GEMFILE=$PWD/gemfiles/activerecord-3.0 bundle exec rspec spec 171 | env BUNDLE_GEMFILE=$PWD/gemfiles/activerecord-4.0 bundle exec rspec spec 172 | ``` 173 | 174 | ## Thanks 175 | 176 | Thanks to Igor @ Lifetrip Limited for allowing this code to be open sourced. 177 | 178 | ## License 179 | 180 | Copyright (c) 2013 Lifetrip Limited 181 | 182 | MIT License 183 | 184 | Permission is hereby granted, free of charge, to any person obtaining 185 | a copy of this software and associated documentation files (the 186 | "Software"), to deal in the Software without restriction, including 187 | without limitation the rights to use, copy, modify, merge, publish, 188 | distribute, sublicense, and/or sell copies of the Software, and to 189 | permit persons to whom the Software is furnished to do so, subject to 190 | the following conditions: 191 | 192 | The above copyright notice and this permission notice shall be 193 | included in all copies or substantial portions of the Software. 194 | 195 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 196 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 197 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 198 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 199 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 200 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 201 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 202 | --------------------------------------------------------------------------------