activr 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +20 -0
- data/README.md +764 -0
- data/Rakefile +50 -0
- data/lib/activr.rb +206 -0
- data/lib/activr/activity.rb +446 -0
- data/lib/activr/async.rb +92 -0
- data/lib/activr/async/resque.rb +58 -0
- data/lib/activr/configuration.rb +40 -0
- data/lib/activr/dispatcher.rb +70 -0
- data/lib/activr/entity.rb +103 -0
- data/lib/activr/entity/model_mixin.rb +117 -0
- data/lib/activr/rails_ctx.rb +37 -0
- data/lib/activr/railtie.rb +73 -0
- data/lib/activr/railties/activr.rake +14 -0
- data/lib/activr/registry.rb +268 -0
- data/lib/activr/storage.rb +404 -0
- data/lib/activr/storage/mongo_driver.rb +645 -0
- data/lib/activr/timeline.rb +441 -0
- data/lib/activr/timeline/entry.rb +165 -0
- data/lib/activr/timeline/route.rb +161 -0
- data/lib/activr/utils.rb +74 -0
- data/lib/activr/version.rb +6 -0
- data/lib/generators/activr/activity_generator.rb +91 -0
- data/lib/generators/activr/templates/activity.rb +28 -0
- data/lib/generators/activr/templates/timeline.rb +42 -0
- data/lib/generators/activr/timeline_generator.rb +24 -0
- metadata +197 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: a8c5213239d234ced0af5ebb399a7eda41e0a4d5
|
4
|
+
data.tar.gz: 21e2e0097d635de10733d743c371dc2ba5d8dbb4
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 30fed199b4ebce7be3858d8c9979ba9c423bc320a395a6b885477819024c3e34ec35c650671fd94ecc3de2a69a2ff5a21cd64598fa24add61ff765e3a41300b2
|
7
|
+
data.tar.gz: 1aa847be13816b6a8588de4b5373c85e4f7bd53eb838cb3daad0ad479acffe66da65cf6c10d35c19c8f378f1dd0d67008148aca4b48d5a88240502d7045b5110
|
data/LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2013-2014 Fotonauts
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,764 @@
|
|
1
|
+
Activr
|
2
|
+
======
|
3
|
+
|
4
|
+
Activr is the Ruby gem created by Fotonauts to manage activity feeds on [Fotopedia](http://www.fotopedia.com).
|
5
|
+
|
6
|
+
With Activr you can create:
|
7
|
+
|
8
|
+
- a Global Activity Feed to display all activities in your website in a single feed
|
9
|
+
- a User Activity Feed to display all actions performed by a specific user
|
10
|
+
- a User News Feeds so thar each user can get news from friends they follow, from albums they own or follow, etc.
|
11
|
+
- an Album Activity Feed to display what happens in a specific album
|
12
|
+
- ...
|
13
|
+
|
14
|
+
Activities are stored in a [MongoDB](http://www.mongodb.org/) database.
|
15
|
+
|
16
|
+
Some magic is invoked when running inside a [Rails](http://www.rubyonrails.com) application but Activr can be used without Rails.
|
17
|
+
|
18
|
+
If [Resque](https://github.com/resque/resque) is detected in a Rails application then it is automatically used to run some parts of Activr code asynchronously.
|
19
|
+
|
20
|
+
A demo app is available [on heroku](http://activr-demo.herokuapp.com), feel free to create an account and try it. Demo source code is [on github](https://github.com/fotonauts/activr_demo) too.
|
21
|
+
|
22
|
+
- More information [on our tumblr](http://fotopedia-code.tumblr.com)
|
23
|
+
- Source code [on github](http://github.com/fotonauts/activr)
|
24
|
+
- Code documentation [on rubydoc](http://rubydoc.info/github/fotonauts/activr/frames)
|
25
|
+
|
26
|
+
[![Build Status](https://travis-ci.org/fotonauts/activr.png)](https://travis-ci.org/fotonauts/activr)
|
27
|
+
[![Coverage Status](https://coveralls.io/repos/fotonauts/activr/badge.png)](https://coveralls.io/r/fotonauts/activr)
|
28
|
+
|
29
|
+
|
30
|
+
Install
|
31
|
+
=======
|
32
|
+
|
33
|
+
```bash
|
34
|
+
$ [sudo] gem install activr
|
35
|
+
```
|
36
|
+
|
37
|
+
In Rails, add it to your Gemfile:
|
38
|
+
|
39
|
+
```
|
40
|
+
gem 'activr'
|
41
|
+
```
|
42
|
+
|
43
|
+
|
44
|
+
Quick start
|
45
|
+
===========
|
46
|
+
|
47
|
+
Define an activity
|
48
|
+
------------------
|
49
|
+
|
50
|
+
An activity is an event that is (most of the time) performed by a user in your application.
|
51
|
+
|
52
|
+
When defining an activity you specify allowed entities and a humanization template.
|
53
|
+
|
54
|
+
Let's generate our first activity that will be dispatched when a user adds a picture to an album:
|
55
|
+
|
56
|
+
```bash
|
57
|
+
$ rails g activr:activity add_picture actor:User picture album
|
58
|
+
```
|
59
|
+
|
60
|
+
The file `app/activities/add_picture_activity.rb` is created:
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
class AddPictureActivity < Activr::Activity
|
64
|
+
|
65
|
+
entity :actor, :class => User, :humanize => :fullname
|
66
|
+
entity :picture, :humanize => :title
|
67
|
+
entity :album, :humanize => :name
|
68
|
+
|
69
|
+
humanize "{{{actor}}} add picture {{{picture}}} {{{album}}}"
|
70
|
+
|
71
|
+
end
|
72
|
+
```
|
73
|
+
|
74
|
+
|
75
|
+
### Entities
|
76
|
+
|
77
|
+
An entity represents one of your application models that is involved in the activity.
|
78
|
+
|
79
|
+
By convention, the entity that corresponds to the user performing the action should be named `:actor`.
|
80
|
+
|
81
|
+
The entity class is inferred thanks to entity name, so by default the `:picture` entity has the `Picture` class, but you can still provide the `:class` option to specify another class.
|
82
|
+
|
83
|
+
|
84
|
+
### Activity humanization
|
85
|
+
|
86
|
+
The `humanize` method defines a sentence that describes the activity and it is a [Mustache](http://mustache.github.io) template. Let's change the generated sentence by a better one:
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
humanize "{{{actor}}} added picture {{{picture}}} to the album {{{album}}}"
|
90
|
+
```
|
91
|
+
|
92
|
+
The `:humanize` option on entity corresponds to a method that is called on the entity instance to humanize it. Note that the generator tries to find that method by itself.
|
93
|
+
|
94
|
+
|
95
|
+
### Usage
|
96
|
+
|
97
|
+
Here is an example of activity instanciation and humanization:
|
98
|
+
|
99
|
+
```ruby
|
100
|
+
user = User.create!({ :_id => 'john', :first_name => "John", :last_name => "WILLIAMS"})
|
101
|
+
picture = Picture.create!({ :_id => 'my_face', :title => "My Face"})
|
102
|
+
album = Album.create!({ :name => "My Selfies"})
|
103
|
+
|
104
|
+
activity = AddPictureActivity.new(:actor => user, :picture => picture, :album => album)
|
105
|
+
|
106
|
+
activity.humanize
|
107
|
+
# => John WILLIAMS added picture My Face to the album My Selfies
|
108
|
+
|
109
|
+
activity.humanize(:html => true)
|
110
|
+
# => <a href="/users/john">John WILLIAMS</a> added picture <a href="/pictures/my_face">My Face</a> to the album <a href="/albums/5295bc9261796d649f080000">My Selfies</a>
|
111
|
+
```
|
112
|
+
|
113
|
+
|
114
|
+
Dispatch an activity
|
115
|
+
--------------------
|
116
|
+
|
117
|
+
You can now dispatch this activity in your application when a picture is added to an album:
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
class Album
|
121
|
+
|
122
|
+
include Mongoid::Document
|
123
|
+
|
124
|
+
field :name, :type => String
|
125
|
+
has_and_belongs_to_many :pictures, :class_name => "Picture", :inverse_of => :albums
|
126
|
+
|
127
|
+
def add_picture(picture, user)
|
128
|
+
unless self.pictures.include?(picture)
|
129
|
+
self.pictures << picture
|
130
|
+
|
131
|
+
# dispatch activity
|
132
|
+
Activr.dispatch!(AddPictureActivity.new(:actor => user, :picture => picture, :album => self))
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
```
|
138
|
+
|
139
|
+
Once dispatched the activity is stored in the `activities` MongoDB collection:
|
140
|
+
|
141
|
+
```
|
142
|
+
> db.activities.findOne()
|
143
|
+
{
|
144
|
+
"_id" : ObjectId("5295bc9f61796d649f140000"),
|
145
|
+
"at" : ISODate("2013-11-27T09:34:23.850Z"),
|
146
|
+
"kind" : "add_picture",
|
147
|
+
"actor" : "john",
|
148
|
+
"picture" : "my_face",
|
149
|
+
"album" : ObjectId("5295bc9261796d649f080000")
|
150
|
+
}
|
151
|
+
```
|
152
|
+
|
153
|
+
|
154
|
+
Basic activity feeds
|
155
|
+
--------------------
|
156
|
+
|
157
|
+
Several basic activity feeds are now available:
|
158
|
+
|
159
|
+
- the global feed: all activities in your application
|
160
|
+
- per entity feed
|
161
|
+
|
162
|
+
|
163
|
+
### Global activity feed
|
164
|
+
|
165
|
+
Use `Activr#activities` to fetch the latest activities in your application:
|
166
|
+
|
167
|
+
```ruby
|
168
|
+
puts "There are #{Activr.activities_count} activites. Here are the 10 most recent:"
|
169
|
+
|
170
|
+
activities = Activr.activities(10)
|
171
|
+
activities.each do |activity|
|
172
|
+
puts activity.humanize
|
173
|
+
end
|
174
|
+
```
|
175
|
+
|
176
|
+
Note that you can paginate thanks to the `:skip` option of the `#activities` method.
|
177
|
+
|
178
|
+
|
179
|
+
### Entity activity feed
|
180
|
+
|
181
|
+
Each entity involved in an activity can have its own activity feed.
|
182
|
+
|
183
|
+
To activate entity activity feed, include the mixin `Activr::Entity::ModelMixin` into the corresponding model class, and setup the `:feed_index` option:
|
184
|
+
|
185
|
+
```ruby
|
186
|
+
include Activr::Entity::ModelMixin
|
187
|
+
|
188
|
+
activr_entity :feed_index => true
|
189
|
+
```
|
190
|
+
|
191
|
+
Then launch the task that setup indexes on the `activities` collection:
|
192
|
+
|
193
|
+
```
|
194
|
+
$ rake activr:create_indexes
|
195
|
+
```
|
196
|
+
|
197
|
+
|
198
|
+
#### Example: actor activity feed
|
199
|
+
|
200
|
+
To fetch actor activities, include the mixin `Activr::Entity::ModelMixin` into your actor class:
|
201
|
+
|
202
|
+
```ruby
|
203
|
+
class User
|
204
|
+
|
205
|
+
# inject sugar methods
|
206
|
+
include Activr::Entity::ModelMixin
|
207
|
+
|
208
|
+
activr_entity :feed_index => true
|
209
|
+
|
210
|
+
include Mongoid::Document
|
211
|
+
|
212
|
+
field :_id, :type => String
|
213
|
+
field :first_name, :type => String
|
214
|
+
field :last_name, :type => String
|
215
|
+
|
216
|
+
def fullname
|
217
|
+
"#{self.first_name} #{self.last_name}"
|
218
|
+
end
|
219
|
+
|
220
|
+
end
|
221
|
+
```
|
222
|
+
|
223
|
+
Now the `User` class has two new methods: `#activities` and `#activities_count`:
|
224
|
+
|
225
|
+
```ruby
|
226
|
+
user = User.find('john')
|
227
|
+
|
228
|
+
puts "#{user.fullname} have #{user.activities_count} activites. Here are the 10 most recent:"
|
229
|
+
|
230
|
+
user.activities(10).each do |activity|
|
231
|
+
puts activity.humanize
|
232
|
+
end
|
233
|
+
```
|
234
|
+
|
235
|
+
|
236
|
+
#### Example: album activity feed
|
237
|
+
|
238
|
+
You can also fetch a per-album activity feed by including the mixin `Activr::Entity::ModelMixin` into the `Album` class:
|
239
|
+
|
240
|
+
```ruby
|
241
|
+
class Album
|
242
|
+
|
243
|
+
# inject sugar methods
|
244
|
+
include Activr::Entity::ModelMixin
|
245
|
+
|
246
|
+
activr_entity :feed_index => true
|
247
|
+
|
248
|
+
# ...
|
249
|
+
|
250
|
+
end
|
251
|
+
```
|
252
|
+
|
253
|
+
Example:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
album = Album.find(BSON::ObjectId.from_string('5295bc9261796d649f080000'))
|
257
|
+
|
258
|
+
puts "There are #{album.activities_count} activites in the album #{album.name}. Here are the 10 most recent:"
|
259
|
+
|
260
|
+
album.activities(10).each do |activity|
|
261
|
+
puts activity.humanize
|
262
|
+
end
|
263
|
+
```
|
264
|
+
|
265
|
+
|
266
|
+
News Feed
|
267
|
+
---------
|
268
|
+
|
269
|
+
Now we want a User News Feed, so that each user can get news from friends he follows and from albums he owns or follows. That is the goal of a timeline: to create a complex activity feed.
|
270
|
+
|
271
|
+
|
272
|
+
### Timeline
|
273
|
+
|
274
|
+
Let's generate a timeline class:
|
275
|
+
|
276
|
+
```bash
|
277
|
+
$ rails g activr:timeline user_news_feed User
|
278
|
+
```
|
279
|
+
|
280
|
+
The file `app/timelines/user_news_feed_timeline.rb` is created:
|
281
|
+
|
282
|
+
```ruby
|
283
|
+
class UserNewsFeedTimeline < Activr::Timeline
|
284
|
+
|
285
|
+
recipient User
|
286
|
+
|
287
|
+
|
288
|
+
#
|
289
|
+
# Routes
|
290
|
+
#
|
291
|
+
|
292
|
+
# route FollowBuddyActivity, :to => 'buddy', :humanize => "{{{actor}}} is now following you"
|
293
|
+
|
294
|
+
|
295
|
+
#
|
296
|
+
# Callbacks
|
297
|
+
#
|
298
|
+
|
299
|
+
# def self.should_route_activity?(activity)
|
300
|
+
# # return `false` to cancel activity routing
|
301
|
+
# true
|
302
|
+
# end
|
303
|
+
|
304
|
+
# def should_handle_activity?(activity, route)
|
305
|
+
# # return `false` to skip routed activity
|
306
|
+
# true
|
307
|
+
# end
|
308
|
+
|
309
|
+
# def should_store_timeline_entry?(timeline_entry)
|
310
|
+
# # return `false` to cancel timeline entry storing
|
311
|
+
# true
|
312
|
+
# end
|
313
|
+
|
314
|
+
# def will_store_timeline_entry(timeline_entry)
|
315
|
+
# # this is your last chance to modify timeline entry before it is stored
|
316
|
+
# end
|
317
|
+
|
318
|
+
# def did_store_timeline_entry(timeline_entry)
|
319
|
+
# # the timeline entry was stored, you can now do some post-processing
|
320
|
+
# end
|
321
|
+
|
322
|
+
end
|
323
|
+
```
|
324
|
+
|
325
|
+
When defining a `Timeline` class you specify:
|
326
|
+
|
327
|
+
- what model in your application _owns_ that timeline: the `recipient`
|
328
|
+
- which activities are displayed in that timeline: the `routes`
|
329
|
+
|
330
|
+
|
331
|
+
### Routes
|
332
|
+
|
333
|
+
Routes describe which activities must be stored in the timeline and how to resolve recipients for those activities.
|
334
|
+
|
335
|
+
When an activity is dispatched, Activr tries to resolve all routes of every timeline with that activity. The result of a route resolving must be either an array of recipient instances/ids or a unique recipient instance/id.
|
336
|
+
|
337
|
+
Let's add some routes:
|
338
|
+
|
339
|
+
```ruby
|
340
|
+
class UserNewsFeedTimeline < Activr::Timeline
|
341
|
+
|
342
|
+
recipient User
|
343
|
+
|
344
|
+
# this is a predefined routing, to fetch all followers of an activity actor
|
345
|
+
routing :actor_follower, :to => Proc.new{ |activity| activity.actor.followers }
|
346
|
+
|
347
|
+
# define a routing with a class method, to fetch all followers of an activity album
|
348
|
+
def self.album_follower(activity)
|
349
|
+
activity.album.followers
|
350
|
+
end
|
351
|
+
|
352
|
+
|
353
|
+
#
|
354
|
+
# Routes
|
355
|
+
#
|
356
|
+
|
357
|
+
# activity path: users will see in their news feed when someone adds a picture in one of their albums
|
358
|
+
route AddPictureActivity, :to => 'album.owner'
|
359
|
+
|
360
|
+
# predefined routing: users will see in their news feed when a friend they follow likes a picture
|
361
|
+
route AddPictureActivity, :using => :actor_follower
|
362
|
+
|
363
|
+
# method call: users will see in their news feed when someone adds a picture in an album they follow
|
364
|
+
route AddPictureActivity, :using => :album_follower
|
365
|
+
|
366
|
+
|
367
|
+
# ...
|
368
|
+
|
369
|
+
end
|
370
|
+
```
|
371
|
+
|
372
|
+
As you can see there are several ways to define a route:
|
373
|
+
|
374
|
+
#### Route with an activity path
|
375
|
+
|
376
|
+
```ruby
|
377
|
+
# activity path: users will see in their news feed when someone adds a picture in one of their albums
|
378
|
+
route AddPictureActivity, :to => 'album.owner'
|
379
|
+
```
|
380
|
+
|
381
|
+
The _path_ is specified with the `:to` route setting. It describes a method chaining to call on dispatched activities.
|
382
|
+
|
383
|
+
So with our example the route is resolved that way:
|
384
|
+
|
385
|
+
```ruby
|
386
|
+
album = activity.album
|
387
|
+
recipient = album.owner
|
388
|
+
```
|
389
|
+
|
390
|
+
#### Route with a predefined routing
|
391
|
+
|
392
|
+
First, declare a predefined `routing`:
|
393
|
+
|
394
|
+
```ruby
|
395
|
+
# this is a predefined routing, to fetch all followers of an activity actor
|
396
|
+
routing :actor_follower, :to => Proc.new{ |activity| activity.actor.followers }
|
397
|
+
```
|
398
|
+
|
399
|
+
Then use it with the `:using` route setting:
|
400
|
+
|
401
|
+
```ruby
|
402
|
+
# predefined routing: users will see in their news feed when a friend they follow likes a picture
|
403
|
+
route AddPictureActivity, :using => :actor_follower
|
404
|
+
```
|
405
|
+
|
406
|
+
Note that you can also use a block syntax:
|
407
|
+
|
408
|
+
```ruby
|
409
|
+
routing :actor_follower do |activity|
|
410
|
+
activity.actor.followers
|
411
|
+
end
|
412
|
+
```
|
413
|
+
|
414
|
+
#### Route with a call on timeline class method
|
415
|
+
|
416
|
+
You can resolve a route with a timeline class method:
|
417
|
+
|
418
|
+
```ruby
|
419
|
+
# define a routing with a class method, to fetch all followers of an activity album
|
420
|
+
def self.album_follower(activity)
|
421
|
+
activity.album.followers
|
422
|
+
end
|
423
|
+
```
|
424
|
+
|
425
|
+
Then use it with the `:using` route setting:
|
426
|
+
|
427
|
+
```ruby
|
428
|
+
# method call: users will see in their news feed when someone adds a picture in an album they follow
|
429
|
+
route AddPictureActivity, :using => :album_follower
|
430
|
+
```
|
431
|
+
|
432
|
+
#### Preferred route syntax
|
433
|
+
|
434
|
+
For the sake of demonstration you can see the three ways in previous timeline code example, but when a route is simple to resolve it is preferred to use an _activity path_ like that:
|
435
|
+
|
436
|
+
```ruby
|
437
|
+
class UserNewsFeedTimeline < Activr::Timeline
|
438
|
+
|
439
|
+
recipient User
|
440
|
+
|
441
|
+
|
442
|
+
#
|
443
|
+
# Routes
|
444
|
+
#
|
445
|
+
|
446
|
+
# activity path: users will see in their news feed when someone adds a picture in one of their albums
|
447
|
+
route AddPictureActivity, :to => 'album.owner'
|
448
|
+
|
449
|
+
# predefined routing: users will see in their news feed when a friend they follow likes a picture
|
450
|
+
route AddPictureActivity, :to => 'actor.followers'
|
451
|
+
|
452
|
+
# method call: users will see in their news feed when someone adds a picture in an album they follow
|
453
|
+
route AddPictureActivity, :to => 'album.followers'
|
454
|
+
|
455
|
+
# ...
|
456
|
+
|
457
|
+
end
|
458
|
+
```
|
459
|
+
|
460
|
+
|
461
|
+
### Timeline Entry
|
462
|
+
|
463
|
+
When an activity is routed to a timeline, that activity is copied to a _Timeline Entry_ that is then stored into database (so Activr uses a _Fanout on write_ mecanism to dispatch activities to timelines).
|
464
|
+
|
465
|
+
A routed timeline entry is stored in the `<timeline kind>_timelines` MongoDB collection.
|
466
|
+
|
467
|
+
For example, Corinne received the previously generated activity because John added a picture to an album she owns:
|
468
|
+
|
469
|
+
```
|
470
|
+
> db.user_news_feed_timelines.findOne({"rcpt": "corinne"})
|
471
|
+
{
|
472
|
+
"_id" : ObjectId("5295c06b61796d673b010000"),
|
473
|
+
"rcpt" : "corinne",
|
474
|
+
"routing" : "album_owner",
|
475
|
+
"activity" : {
|
476
|
+
"_id" : ObjectId("5295bc9f61796d649f140000"),
|
477
|
+
"at" : ISODate("2013-11-27T09:34:23.850Z"),
|
478
|
+
"kind" : "add_picture",
|
479
|
+
"actor" : "john",
|
480
|
+
"picture" : "my_face",
|
481
|
+
"album" : ObjectId("5295bc9261796d649f080000")
|
482
|
+
}
|
483
|
+
}
|
484
|
+
```
|
485
|
+
|
486
|
+
As you can see, a Timeline Entry contains:
|
487
|
+
|
488
|
+
- a copy of the original activity
|
489
|
+
- the recipient id: `rcpt`
|
490
|
+
- the `routing` kind: here, `album_owner` means that Corinne received that activity in her News Feed because she is the owner of the album
|
491
|
+
|
492
|
+
You can also add meta data. For example you may add a `read` meta data if you want to implement a read/unread mecanism in your News Feed.
|
493
|
+
|
494
|
+
When you create a new timeline class don't forget to launch the task that setup indexes in the corresponding `timelines` collection:
|
495
|
+
|
496
|
+
```
|
497
|
+
$ rake activr:create_indexes
|
498
|
+
```
|
499
|
+
|
500
|
+
|
501
|
+
#### Timeline Entry humanization
|
502
|
+
|
503
|
+
Specify a `:humanize` setting on a `route` to specialize humanization of corresponding timeline entries. For example:
|
504
|
+
|
505
|
+
```ruby
|
506
|
+
# activity path: users will see in their news feed when someone adds a picture in one of their albums
|
507
|
+
route AddPictureActivity, :to => 'album.owner', :humanize => "{{{actor}}} added a picture to your album {{{album}}}"
|
508
|
+
```
|
509
|
+
|
510
|
+
If you do not set a `:humanize` setting then the humanization of the embedded activity is used instead.
|
511
|
+
|
512
|
+
|
513
|
+
### Callbacks
|
514
|
+
|
515
|
+
Several callbacks are invoked on timeline instance so you can hook your own code during the activity dispatching workflow:
|
516
|
+
|
517
|
+
```ruby
|
518
|
+
class UserNewsFeedTimeline < Activr::Timeline
|
519
|
+
|
520
|
+
# ...
|
521
|
+
|
522
|
+
#
|
523
|
+
# Callbacks
|
524
|
+
#
|
525
|
+
|
526
|
+
def self.should_route_activity?(activity)
|
527
|
+
# if you return `false` then nobody will receive that activity for that timeline class
|
528
|
+
true
|
529
|
+
end
|
530
|
+
|
531
|
+
def should_handle_activity?(activity, route)
|
532
|
+
# if you return `false` then current recipient won't receive that routed activity
|
533
|
+
true
|
534
|
+
end
|
535
|
+
|
536
|
+
def should_store_timeline_entry?(timeline_entry)
|
537
|
+
# if you return `false` then current recipient won't receive that timeline entry
|
538
|
+
true
|
539
|
+
end
|
540
|
+
|
541
|
+
def will_store_timeline_entry(timeline_entry)
|
542
|
+
# this is your last chance to modify timeline entry before it is stored
|
543
|
+
end
|
544
|
+
|
545
|
+
def did_store_timeline_entry(timeline_entry)
|
546
|
+
# the timeline entry was stored, you can now do some post-processing
|
547
|
+
# for example you can send notifications
|
548
|
+
end
|
549
|
+
|
550
|
+
end
|
551
|
+
```
|
552
|
+
|
553
|
+
|
554
|
+
### Fetching / Display
|
555
|
+
|
556
|
+
Two methods are injected in the timeline recipient class: `#<timeline_kind>` and `#<timeline_kind>_count`. So in our case: `#user_news_feed` and `#user_news_feed_count`:
|
557
|
+
|
558
|
+
```ruby
|
559
|
+
class UsersController < ApplicationController
|
560
|
+
|
561
|
+
def news_feed
|
562
|
+
user = User.find(params[:id])
|
563
|
+
|
564
|
+
@news_feed = user.user_news_feed(10)
|
565
|
+
@news_feed_count = user.user_news_feed_count
|
566
|
+
end
|
567
|
+
|
568
|
+
end
|
569
|
+
```
|
570
|
+
|
571
|
+
Here is simple view:
|
572
|
+
|
573
|
+
```erb
|
574
|
+
<p>
|
575
|
+
You have <%= @news_feed_count %> entries in your News Feed. Here are the 10 most recent:
|
576
|
+
</p>
|
577
|
+
<ul id='news_feed'>
|
578
|
+
<% @news_feed.each do |timeline_entry| %>
|
579
|
+
<li><%= raw timeline_entry.humanize(:html => true) %></li>
|
580
|
+
<% end %>
|
581
|
+
</ul>
|
582
|
+
<% end %>
|
583
|
+
```
|
584
|
+
|
585
|
+
Here is a view taken from [Activr Demo](https://github.com/fotonauts/activr_demo):
|
586
|
+
|
587
|
+
```erb
|
588
|
+
<div id='news_feed'>
|
589
|
+
<% @news_feed.each do |timeline_entry| %>
|
590
|
+
<% activity = timeline_entry.activity %>
|
591
|
+
<div class="activity <%= activity.kind %>">
|
592
|
+
<div class="icon">
|
593
|
+
<%= link_to(image_tag(activity.actor.avatar.thumb.url, :title => activity.actor.fullname), activity.actor) %>
|
594
|
+
</div>
|
595
|
+
<div class="content">
|
596
|
+
<div class="title"><%= timeline_entry.humanize(:html => true).html_safe %></div>
|
597
|
+
<% if activity.buddy %>
|
598
|
+
<div class="buddy">
|
599
|
+
<%= link_to(image_tag(activity.buddy.avatar.url, :title => activity.buddy.fullname), activity.buddy) %>
|
600
|
+
</div>
|
601
|
+
<% elsif activity.picture %>
|
602
|
+
<div class="picture">
|
603
|
+
<%= link_to(image_tag(activity.picture.image.small.url, :title => activity.picture.title), activity.picture) %>
|
604
|
+
</div>
|
605
|
+
<% elsif activity.album %>
|
606
|
+
<div class="album">
|
607
|
+
<%= link_to(image_tag(activity.album.cover.image.small.url, :title => activity.album.name), activity.album) %>
|
608
|
+
</div>
|
609
|
+
<% end %>
|
610
|
+
<small class="date text-muted"><%= distance_of_time_in_words_to_now(activity.at, :include_seconds => true) %> ago</small>
|
611
|
+
</div>
|
612
|
+
</div>
|
613
|
+
<% end %>
|
614
|
+
</div>
|
615
|
+
```
|
616
|
+
|
617
|
+
|
618
|
+
Entity model deletion
|
619
|
+
=====================
|
620
|
+
|
621
|
+
When one of your entities models instance is deleted you should probably call the `delete_activities!` method. This method deletes all activities that refer to the deleted entity from the `activities` and `timelines` collections.
|
622
|
+
|
623
|
+
You should too add `activr_entity :deletable => true` to your model class to ensure that a deletion index is correctly setup when running the `rake activr:create_indexes` task.
|
624
|
+
|
625
|
+
```ruby
|
626
|
+
class Picture
|
627
|
+
|
628
|
+
include Activr::Entity::ModelMixin
|
629
|
+
|
630
|
+
# picture can be deleted
|
631
|
+
activr_entity :deletable => true
|
632
|
+
|
633
|
+
include Mongoid::Document
|
634
|
+
|
635
|
+
# ...
|
636
|
+
|
637
|
+
# delete all activities
|
638
|
+
after_destroy :delete_activities!
|
639
|
+
|
640
|
+
end
|
641
|
+
```
|
642
|
+
|
643
|
+
|
644
|
+
Async
|
645
|
+
=====
|
646
|
+
|
647
|
+
You can plug a job system to run some parts of Activr code asynchronously.
|
648
|
+
|
649
|
+
Possible hooks are:
|
650
|
+
|
651
|
+
- `:route_activity` - Activity is routed by the dispatcher
|
652
|
+
- `:timeline_handle` - Activity is handled by a timeline
|
653
|
+
|
654
|
+
For example, here is the default `:route_activity` hook handler that is provided out of the box when [Resque](https://github.com/resque/resque) is detected in a Rails application:
|
655
|
+
|
656
|
+
|
657
|
+
```ruby
|
658
|
+
# config
|
659
|
+
Activr.configure do |config|
|
660
|
+
config.async[:route_activity] ||= Activr::Async::Resque::RouteActivity
|
661
|
+
end
|
662
|
+
```
|
663
|
+
|
664
|
+
```ruby
|
665
|
+
class Activr::Async::Resque::RouteActivity
|
666
|
+
@queue = 'activr_route_activity'
|
667
|
+
|
668
|
+
class << self
|
669
|
+
def enqueue(activity)
|
670
|
+
::Resque.enqueue(self, activity.to_hash)
|
671
|
+
end
|
672
|
+
|
673
|
+
def perform(activity_hash)
|
674
|
+
# unserialize argument
|
675
|
+
activity_hash = Activr::Activity.unserialize_hash(activity_hash)
|
676
|
+
activity = Activr::Activity.from_hash(activity_hash)
|
677
|
+
|
678
|
+
# call hook
|
679
|
+
Activr::Async.route_activity(activity)
|
680
|
+
end
|
681
|
+
end # class << self
|
682
|
+
end # class RouteActivity
|
683
|
+
```
|
684
|
+
|
685
|
+
A hook class:
|
686
|
+
|
687
|
+
- implements an `#enqueue` method, used to enqueue the async job
|
688
|
+
- calls `Activr::Async.<hook_name>` method in the async job
|
689
|
+
|
690
|
+
Hook classes are specified thanks to the `config.async` hash.
|
691
|
+
|
692
|
+
If you are writing a Rails application you just need to add the `Resque` gem to your `Gemfile` to enable async hooks. If you want to use another job system then you have to write your own async hook handlers. If you want to force disabling of async hooks, for example when deploying your app on Heroku with only one dyno, just set the environment variable `ACTIVR_FORCE_SYNC` to `true`.
|
693
|
+
|
694
|
+
|
695
|
+
Railties
|
696
|
+
========
|
697
|
+
|
698
|
+
The default mongodb connection uri is `mongodb://127.0.0.1/activr`, but if you are using Activr inside a Rails application with mongoid gem loaded then the mongoid database connection will be used instead. If you don't want that behaviour then set the environment variable `ACTIVR_SKIP_MONGOID_RAILTIE` to `true`, or set the [Fwissr](https://github.com/fotonauts/fwissr) key `/activr/skip_mongoid_railtie` to true.
|
699
|
+
|
700
|
+
|
701
|
+
Skip duplicates activities
|
702
|
+
==========================
|
703
|
+
|
704
|
+
Use the `:skip_dup_period` option when dispatching an activity to avoid duplicates.
|
705
|
+
|
706
|
+
```ruby
|
707
|
+
# User is now following Buddy
|
708
|
+
activity = FollowBuddyActivity.new(:actor => user, :buddy => followee)
|
709
|
+
|
710
|
+
# skip activity if User already followed Buddy during the last hour
|
711
|
+
Activr.dispatch!(activity, :skip_dup_period => 3600)
|
712
|
+
```
|
713
|
+
|
714
|
+
Or you can set that option in global activr configuration:
|
715
|
+
|
716
|
+
```ruby
|
717
|
+
Activr.config.skip_dup_period = 3600
|
718
|
+
```
|
719
|
+
|
720
|
+
|
721
|
+
Trim Timelines
|
722
|
+
==============
|
723
|
+
|
724
|
+
Set `max_length` on a timeline class to specify the maximum number of timeline entries allowed. When a recipient timeline exceed that number then old timeline entries are automatically deleted.
|
725
|
+
|
726
|
+
|
727
|
+
```ruby
|
728
|
+
class UserNewsFeedTimeline < Activr::Timeline
|
729
|
+
|
730
|
+
recipient User
|
731
|
+
|
732
|
+
max_length 100
|
733
|
+
|
734
|
+
# ...
|
735
|
+
|
736
|
+
end
|
737
|
+
```
|
738
|
+
|
739
|
+
|
740
|
+
Todo
|
741
|
+
====
|
742
|
+
|
743
|
+
- Activities aggregation in timelines
|
744
|
+
- Rails generator to setup basic views
|
745
|
+
- Rails generator to setup admin controllers
|
746
|
+
- Permits "Fanout on read" for inactive entities, to preserve db size
|
747
|
+
- Permits "Fanout on write with buckets", for maximum read perfs
|
748
|
+
|
749
|
+
|
750
|
+
References
|
751
|
+
==========
|
752
|
+
|
753
|
+
- <http://blog.mongodb.org/post/65612078649/using-mongodb-schema-design-to-create-inboxes>
|
754
|
+
- <http://www.slideshare.net/danmckinley/etsy-activity-feeds-architecture>
|
755
|
+
|
756
|
+
|
757
|
+
Credits
|
758
|
+
=======
|
759
|
+
|
760
|
+
From Fotonauts:
|
761
|
+
|
762
|
+
- Aymerick Jéhanne [@aymerick](https://github.com/aymerick)
|
763
|
+
|
764
|
+
Copyright (c) 2013-2014 Fotonauts released under the MIT license.
|