notably 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: bd247eba7aa38faf56a73e9eb6bb8734d22e4358
4
+ data.tar.gz: 739b8d7bb0d16402b478e2c79d3c30e1d3822813
5
+ SHA512:
6
+ metadata.gz: cc524ed6ef36a6fe5f56ded1cc91b58011603426af5e9876ec6dc0fd33d35dcea1945d4d86bf6321b3a7cc61a6c8b55c03857823b892913209513b57fd8e8a4e
7
+ data.tar.gz: fcb35392ccaf761b3a6a666a1752668e5114621539fc578c7dfe64ea34b40e0ea8a684c8b1248d8c5809d66eae2ce1dd6efa8c8fb418cc280a17f9d8c5c9e8d4
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in notably.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 UpTrending LLC
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,251 @@
1
+ # Notably
2
+
3
+ Notably is a redis-backed notification system.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'notably'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+
21
+ ```
22
+ $ gem install notably
23
+ ```
24
+
25
+ ## Concepts
26
+
27
+ Before we dive right in to usage, let me quickly go over a few of the basic concepts of Notably.
28
+
29
+ ### Notification
30
+
31
+ Most simply, we have a Notification module which you can include in your own classes. It expects a few methods to be overridden, and provides a few helper methods.
32
+
33
+ Notifications have required attributes, which you set. For instance a CommentNotification might have required attributes of `:comment_id` and `:author_id`. Those attributes become accessor methods that you can use inside your class. Then you must define two methods: `to_html` and `receivers`.
34
+
35
+ `to_html` should return an html string, which is what you will use in the view to display the notification to the user. (If you're using Rails, you'll have access to all the standard view helpers)
36
+
37
+ `receivers` should return an array of models to be notified by this notification. All the models returned should be of a class that includes the `Notably::Notifiable` module. Speaking of...
38
+
39
+ ### Notifiable
40
+
41
+ The Notifiable module is what you include in the classes that should be notified of things. The only demand placed on the class that includes it is that it responds to and returns a unique value for `id`. So if you're including it in an `ActiveRecord::Base` subclass, you should be good to go.
42
+
43
+ Notifiable adds these public methods to your class:
44
+
45
+ * `notifications` Return an array of all notifications
46
+ * `notifications_since(time)` Return an array of all notifications that happened after the time parameter
47
+ * `unread_notifications` Return an array of all unread notifications
48
+ * `unread_notifications!` Return an array of all unread notifications, and update the last_notification_read_at time atomically
49
+ * `read_notifications` Return an array of all read notifications
50
+ * `read_notifications!` Update the last_notification_read_at time
51
+ * `last_notification_read_at` Return an integer representing the time the last notification was read
52
+ * `notification_key` The key in redis where the notifications will get stored
53
+ * `last_notification_read_at_key` The key in redis where the last_notification_read_at will get stored
54
+
55
+ For most setups, you should probably only really need access to three or so of those methods.
56
+
57
+ ## Usage
58
+
59
+ Lets try implementing a comment notification in a sample Rails app with a User model and a Comment model. We'll start from here:
60
+
61
+ ```ruby
62
+ # app/models/user.rb
63
+ class User < ActiveRecord::Base
64
+ has_many :comments, foreign_key: :author_id
65
+ end
66
+
67
+ # app/models/comment.rb
68
+ class Comment < ActiveRecord::Base
69
+ belongs_to :author, class_name: User
70
+ belongs_to :commentable, polymorphic: true, touch: true
71
+ end
72
+
73
+ # app/controllers/comments_controller.rb
74
+ class CommentsController < ApplicationController
75
+ before_filter :require_login
76
+ respond_to :json
77
+ def create
78
+ @comment = current_user.comments.create(comment_params)
79
+ respond_with @comment
80
+ end
81
+
82
+ private
83
+
84
+ def comment_params
85
+ params.require(:comment).permit(:body, :commentable_type, :commentable_id)
86
+ end
87
+ end
88
+ ```
89
+
90
+ So to begin we know we want our Users to be the one getting the notifications, so lets go ahead and include the necessary module
91
+
92
+ ```ruby
93
+ # app/models/user.rb
94
+ class User < ActiveRecord::Base
95
+ include Notably::Notifiable
96
+ # ...
97
+ end
98
+ ```
99
+
100
+ Now we need to create our `CommentNotification` class. I like to do that in an app/notifications directory. I'll show the finished product here, and then walk through it line by line.
101
+
102
+ ```ruby
103
+ # app/notifications/comment_notification.rb
104
+ class CommentNotification
105
+ include Notably::Notification
106
+ required_attributes :commentable_type, :commentable_id, :author_id
107
+
108
+ def to_html
109
+ "#{author.short_name} commented on #{link_to commentable.name, polymorphic_path(commentable)}"
110
+ end
111
+
112
+ def receivers
113
+ commentable.comments.pluck(:author_id).uniq - [author_id]
114
+ end
115
+
116
+ def commentable
117
+ @commentable ||= commentable_type.constantize.find(commentable_id)
118
+ end
119
+
120
+ def author
121
+ @author ||= User.where(id: author_id)
122
+ end
123
+ end
124
+ ```
125
+
126
+ So first we include our Notification module, then define the required attributes. The next thing I did was set up the `commentable` and `author` methods for convenience sake, which uses the required attributes to look up the models they point to. Then I wrote the `to_html` method which would return something that looks like:
127
+
128
+ > Michael B. commented on [Save Our Bluths](#)
129
+
130
+ Then I define the `receivers` method which will return a list of users that have also commented on whatever it is I'm commenting on, minus the author of the comment we're currently notifying people about.
131
+
132
+ Ok, so far so good. Now we just need to hook up the notification creation. I'm sure there's some debate to be had about where the best place to put Notification creation would be, but to me it makes the most sense to have it in the controller. So...
133
+
134
+ ```ruby
135
+ # app/controllers/comments_controller.rb
136
+ class CommentsController < ApplicationController
137
+ # ...
138
+ def create
139
+ @comment = current_user.comments.create(comment_params)
140
+ CommentNotification.create(@comment)
141
+ respond_with @comment
142
+ end
143
+ # ...
144
+ end
145
+ ```
146
+
147
+ And... we're done. Let me explain a bit about how that create method is working. You can pass it an object, or a hash. The object must respond to all the required attributes. And if it's a hash, it must have a key-value for each required attribute. So I could have just as easily have done
148
+
149
+ ```ruby
150
+ CommentNotification.create(
151
+ commentable_type: @comment.commentable_type,
152
+ commentable_id: @comment.commentable_id,
153
+ author_id: current_user.id})
154
+ ```
155
+
156
+ But where's the fun in that?
157
+
158
+ ## Grouping
159
+
160
+ Ok so things are looking pretty good, except the Save Our Bluths post is getting kind of popular, and my notification feed looks like this:
161
+
162
+ > Michael B. commented on [Save Our Bluths](#)
163
+
164
+ > Lucille B. commented on [Save Our Bluths](#)
165
+
166
+ > Buster B. commented on [Save Our Bluths](#)
167
+
168
+ > Tobius F. commented on [Save Our Bluths](#)
169
+
170
+ It would be nicer if it looked like
171
+
172
+ > Buster B., Lucille B., Michael B., and Tobius F. commented on [Save Our Bluths](#)
173
+
174
+ And what a wonderful time to show you Notably's grouping feature! It works by defining a subset of the required attributes that you group by. So if we wanted to group our `CommentNotification` like I did above, we would write this:
175
+
176
+ ```ruby
177
+ # app/notifications/comment_notification.rb
178
+ class CommentNotification
179
+ include Notably::Notification
180
+ required_attributes :commentable_type, :commentable_id, :author_id
181
+ group_by :commentable_type, :commentable_id
182
+
183
+ # ...
184
+ end
185
+ ```
186
+
187
+ So let me explain how it's doing this. When a new notification is being saved, it's going to look at the receiver's current notification list, and see if any of them match the `group_by` attributes of the one that is currently saving. If there are any, than it adds the attributes of those that are not being grouped by (in our case, just `:author_id`) to an array that is accessible to you through the `groups` method. As soon as you add the `group_by` line, you have access to all non-grouped-by attributes through the `groups` method. So lets see how that would affect our `CommentNotification` class.
188
+
189
+ ```ruby
190
+ # app/notifications/comment_notification.rb
191
+ class CommentNotification
192
+ include Notably::Notification
193
+ required_attributes :commentable_type, :commentable_id, :author_id
194
+ group_by :commentable_type, :commentable_id
195
+
196
+ def to_html
197
+ "#{authors.collect(&:short_name).to_sentence} commented on #{link_to commentable.name, polymorphic_path([commentable.project, commentable])}"
198
+ end
199
+
200
+ def receivers
201
+ commentable.comments.pluck(:author_id).uniq - [author_id]
202
+ end
203
+
204
+ def commentable
205
+ @commentable ||= commentable_type.constantize.find(commentable_id)
206
+ end
207
+
208
+ def authors
209
+ @authors ||= User.where(id: groups.collect(&:author_id)).order(:first_name)
210
+ end
211
+ end
212
+ ```
213
+
214
+ And that's really it. `groups` returns an array of OpenStructs that have all the non-grouped-by attributes of all the notifications that are being grouped, including the current notification. But notice that (as I use in the `receivers` method) you can still access `author_id` directly, which will just give you the author_id of the current notification that's being saved.
215
+
216
+ There's one part of Grouping that I haven't mention yet, and that is `group_within`, which lets you specify the time range that the notification has to fall into in order to be eligable to be grouped. You probably don't want a notification from last week to be grouped with one that just happened. Or maybe you do, and you can set that. By default, it groups within the last_notification_read_at
217
+ time. Which I think is a good sensible default, if you're using last_notification_read_at. Otherwise it might be best to set it to a generic `4.hours.ago`. You set it by passing it a lambda or Proc. The lambda or Proc should have one argument, which will be the receiver who's notification list it's grouping from. So this might look like:
218
+
219
+ ```ruby
220
+ # app/notifications/comment_notification.rb
221
+ class CommentNotification
222
+ include Notably::Notification
223
+ required_attributes :commentable_type, :commentable_id, :author_id
224
+ group_by :commentable_type, :commentable_id
225
+ group_within ->(receiver) { 4.hours.ago }
226
+ # ...
227
+ end
228
+ ```
229
+
230
+ Or
231
+
232
+ ```ruby
233
+ group_within ->(receiver) { receiver.updated_at }
234
+ ```
235
+
236
+ Or
237
+
238
+ ```ruby
239
+ group_within ->(receiver) { receiver.last_notification_read_at } # default
240
+ ```
241
+
242
+ (Just a warning, even if you're setting it to something that doesn't need the receiver passed in to calculate it, you need to specify it as an argument if you're going to use lambdas. They don't like it when arguments get ignored.)
243
+
244
+
245
+ ## Contributing
246
+
247
+ 1. Fork it
248
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
249
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
250
+ 4. Push to the branch (`git push origin my-new-feature`)
251
+ 5. Create new Pull Request
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,14 @@
1
+ require "notably/configuration"
2
+ require "notably/notification"
3
+ require "notably/notifiable"
4
+ require "notably/version"
5
+
6
+ module Notably
7
+ module_function
8
+
9
+ def config
10
+ @config ||= Configuration.new
11
+ yield @config if block_given?
12
+ @config
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module Notably
2
+ class Configuration
3
+ attr_writer :redis
4
+
5
+ def initialize
6
+ end
7
+
8
+ def redis
9
+ @redis ||= Redis.current
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,46 @@
1
+ module Notably
2
+ module Notifiable
3
+
4
+ def notifications
5
+ parse_notifications(Notably.config.redis.zrevrangebyscore(notification_key, Time.now.to_i, 0))
6
+ end
7
+
8
+ def notifications_since(time)
9
+ parse_notifications(Notably.config.redis.zrevrangebyscore(notification_key, Time.now.to_i, time.to_i))
10
+ end
11
+
12
+ def unread_notifications
13
+ notifications_since(last_notification_read_at)
14
+ end
15
+
16
+ def unread_notifications!
17
+ notifications_since(Notably.config.redis.getset(last_notification_read_at_key, Time.now.to_i))
18
+ end
19
+
20
+ def read_notifications
21
+ parse_notifications(Notably.config.redis.zrevrangebyscore(notification_key, last_notification_read_at, 0))
22
+ end
23
+
24
+ def read_notifications!
25
+ parse_notifications(Notably.config.redis.set(last_notification_read_at_key, Time.now.to_i))
26
+ end
27
+
28
+ def last_notification_read_at
29
+ Notably.config.redis.get(last_notification_read_at_key).to_i
30
+ end
31
+
32
+ def notification_key
33
+ "notably:notifications:#{self.class}:#{self.id}"
34
+ end
35
+
36
+ def last_notification_read_at_key
37
+ "notably:last_read_at:#{self.class}:#{self.id}"
38
+ end
39
+
40
+ private
41
+
42
+ def parse_notifications(notifications)
43
+ notifications.collect { |n| Marshal.load(n) }
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,136 @@
1
+ module Notably
2
+ module Notification
3
+ attr_accessor :data, :created_at, :groups
4
+
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ if defined?(Rails) && defined?(ActionView)
8
+ base.send(:include, ActionView::Helpers)
9
+ base.send(:include, Rails.application.routes.url_helpers)
10
+ end
11
+ end
12
+
13
+ def to_s
14
+ ActionView::Base.full_sanitizer.sanitize(to_html) if defined?(ActionView)
15
+ end
16
+
17
+ def to_html
18
+ ""
19
+ end
20
+
21
+ def receivers
22
+ []
23
+ end
24
+
25
+ def initialize(*attributes_hashes)
26
+ @data = {}
27
+ @groups = []
28
+ attributes_hashes.each do |attributes|
29
+ case attributes
30
+ when Hash
31
+ raise ArgumentError, "Hash does not have all required attributes" unless self.class.required_attributes.all? { |k| attributes.key? k }
32
+ if @data.any?
33
+ raise ArgumentError, "Group by fields do not have shared values" unless @data == attributes.slice(*self.class.group_by)
34
+ else
35
+ @data = attributes
36
+ end
37
+ @groups << OpenStruct.new(attributes.except(*self.class.group_by))
38
+ else
39
+ raise ArgumentError, "Object #{attributes} does not respond to all required attributes" unless self.class.required_attributes.all? { |k| attributes.respond_to? k }
40
+ if @data.any?
41
+ raise ArgumentError, "Group by fields do not have shared values" unless @data == Hash[self.class.group_by.collect { |k| [k, attributes.send(k)] }]
42
+ else
43
+ @data = Hash[self.class.required_attributes.collect { |k| [k, attributes.send(k)] }]
44
+ end
45
+ @groups << OpenStruct.new(Hash[(self.class.required_attributes - self.class.group_by).collect { |k| [k, attributes.send(k)] }])
46
+ end
47
+ end
48
+ end
49
+
50
+ def save
51
+ receivers.each do |receiver|
52
+ # look for groupable messages within group_within
53
+ # group_within = self.class.group_within.arity == 1 ? self.class.group_within.call(user) : self.class.group_within.call
54
+ group_within = self.class.group_within.call(receiver)
55
+ groupable_notifications = receiver.notifications_since(group_within)
56
+ groupable_notifications.select! { |notification| notification[:data].slice(*self.class.group_by) == data.slice(*self.class.group_by) }
57
+ groupable_notifications.each do |notification|
58
+ @groups += notification[:groups]
59
+ end
60
+ Notably.config.redis.pipelined do
61
+ Notably.config.redis.zadd(receiver.send(:notification_key), created_at.to_i, marshal)
62
+ groupable_notifications.each do |notification|
63
+ Notably.config.redis.zrem(receiver.send(:notification_key), Marshal.dump(notification))
64
+ @groups -= notification[:groups]
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ def to_h
71
+ {
72
+ created_at: created_at,
73
+ data: data,
74
+ groups: groups,
75
+ message: to_s,
76
+ html: to_html
77
+ }
78
+ end
79
+
80
+ def marshal
81
+ Marshal.dump(to_h)
82
+ end
83
+
84
+ def created_at
85
+ @created_at ||= Time.now
86
+ end
87
+
88
+ def method_missing(method, *args, &block)
89
+ if method.to_s =~ /=/
90
+ method = method.to_s.gsub!('=', '')
91
+ if @data.key? method
92
+ @data[method.to_sym] = *args
93
+ end
94
+ else
95
+ if @data.key? method
96
+ @data[method]
97
+ else
98
+ super
99
+ end
100
+ end
101
+ end
102
+
103
+ module ClassMethods
104
+ def create(attributes={})
105
+ new(attributes).save
106
+ end
107
+
108
+ def required_attributes(*args)
109
+ if args.any?
110
+ @required_attributes ||= []
111
+ @required_attributes += args
112
+ else
113
+ @required_attributes ||= []
114
+ end
115
+ end
116
+
117
+ def group_by(*args)
118
+ if args.any?
119
+ @group_by ||= []
120
+ @group_by += args
121
+ else
122
+ @group_by ||= []
123
+ end
124
+ end
125
+
126
+ def group_within(block=nil)
127
+ if block
128
+ @group_within = block
129
+ else
130
+ @group_within ||= ->(receiver) { receiver.last_notification_read_at }
131
+ end
132
+ end
133
+ end
134
+
135
+ end
136
+ end
@@ -0,0 +1,3 @@
1
+ module Notably
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,23 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'notably/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "notably"
8
+ spec.version = Notably::VERSION
9
+ spec.authors = ["Will Cosgrove"]
10
+ spec.email = ["will@willcosgrove.com"]
11
+ spec.description = %q{A redis backed notification system}
12
+ spec.summary = %q{A redis backed notification system}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake"
23
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: notably
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Will Cosgrove
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-08-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: A redis backed notification system
42
+ email:
43
+ - will@willcosgrove.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - .gitignore
49
+ - Gemfile
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - lib/notably.rb
54
+ - lib/notably/configuration.rb
55
+ - lib/notably/notifiable.rb
56
+ - lib/notably/notification.rb
57
+ - lib/notably/version.rb
58
+ - notably.gemspec
59
+ homepage: ''
60
+ licenses:
61
+ - MIT
62
+ metadata: {}
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - '>='
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - '>='
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubyforge_project:
79
+ rubygems_version: 2.0.3
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: A redis backed notification system
83
+ test_files: []