Update 2021-05-06: Add option to exclude the user pertaining to the model from the broadcast - assuming she’s notified otherwise.
Note: I’m not too happy with the usage of the term Notification here, but naming, as we all know, is hard. If you know of any better term, please let me know 🙌
⬇️Important: The examples below are based on an unreleased but stable CableReady feature called cable_car
. Learn more about it here.
Problem: Scattered Notification Logic
Often in your reflexes, you end up wanting to notify other users than the current
one hanging off a certain resource to be notified about changes, in one way or other. One user might add a comment or an emoji reaction that you want the others to see instantly, other times you might just want to dispatch a toast notification, what have you. You can easily make use of the implicit cable_ready
channel that lives on every reflex:
# app/reflexes/reaction_reflex.rb
class ReactionReflex < ApplicationReflex
def toggle
# ... do some work
@reaction.users.each do |user|
cable_ready[UserNotificationChannel].inner_html({
selector: "#{dom_id(@reaction)}",
html: "some rendered html depending on #{user}"
}).broadcast_to(user)
end
end
end
Now I tend to defer this into a job so as not to inadvertently block the app server, and because usually it does not matter as much if the other users see the emoji a few milliseconds later:
# app/reflexes/reaction_reflex.rb
class ReactionReflex < ApplicationReflex
after_reflex do
@reaction.users.each do |user|
StreamReactionJob.perform_later(reaction: @reaction, user: notified_user)
end
end
def toggle
# ... do some work
end
end
# app/jobs/stream_reaction_job.rb
class StreamReactionJob < ApplicationJob
include CableReady::Broadcaster
queue_as :default
def perform(reaction:, user:)
cable_ready[UserNotificationChannel].inner_html({
selector: "#{dom_id(reaction)}",
html: "some rendered html depending on #{user}"
}).broadcast_to(user)
end
end
Now let’s suppose we have some branching to do depending on the type of the reaction:
# app/jobs/stream_reaction_job.rb
class StreamReactionJob < ApplicationJob
include CableReady::Broadcaster
queue_as :default
def perform(reaction:, user:)
if reaction.thumbs_up?
cable_ready[UserNotificationChannel].play_sound(
src: "fanfare.mp3"
)
end
cable_ready[UserNotificationChannel].inner_html({
selector: "#{dom_id(reaction)}",
html: "some rendered html depending on #{user}"
}).broadcast_to(user)
end
end
In this (contrived) example we play a fanfare through CableReady’s play_sound
operation if the reaction was positive. However, now this begins to smell of Feature Envy (or in violation of tell, don’t ask): We are asking the reactions questions about its internal state.
Moreover, would we want to expand this to multiple reflexes or resources, we would quickly assemble a hotchpotch of custom CableReady code all over your application. What’s actually at the center of this problem is the individual resource, which has the knowledge (and thus the responsibility) to discern what should be broadcast to the user.
Solution: A UserNotifiable Concern
Here’s my take on a solution to this problem. First, we create a generalized NotifyUsersJob
that we can reuse:
class NotifyUsersJob < ApplicationJob
include CableReady::Broadcaster
queue_as :default
def perform(changes:)
changes.each do |user, operations|
cable_ready[UserNotificationChannel].apply!(operations).broadcast_to(user)
end
end
end
The apply!
method is part of CableReady’s new operation serializer functionality aptly called cable_car
which we need in order to be able to pass them to the job (because you cannot serialize Procs
). Okay, where do those changes
come from, and what do they contain? For that, let’s look at a UserNotifiable
model concern I’ve crafted:
module UserNotifiable
extend ActiveSupport::Concern
include CableReady::Broadcaster
included do
after_commit :notify_users
end
def notify_users
NotifyUsersJob.perform_later(changes: cable_ready_changes)
end
def cable_ready_changes
users = self.users&.without(user) if user.present?
users.map do |user|
operations = yield user
[user, operations]
end
end
end
There’s one duck type, or contract the model it’s being included into has to fulfill, and that’s that it has a users
accessor, be it through an association, a delegation to an association, or any other kind of entity such as a Redis set. Now in a method called cable_ready_changes
we can iterate over those users and assemble CableReady operations in the including subclasses (we’ll get to that in a moment). Edit: broadcast to all users but the current one, if one is present - assuming this user will get notified by another mechanism.
We return an array of [user, operations]
, which is picked up by the job above. The models it’s being included can now implement it:
class Reaction < ApplicationRecord
include UserNotifiable
def cable_ready_changes
super do |user|
if thumbs_up?
cable_car.play_sound(
src: "fanfare.mp3"
)
end
cable_car.inner_html({
selector: "#{dom_id(self)}",
html: "some rendered html depending on #{user}"
}).dispatch
end
end
end
Here’s where cable_car
comes into play: It basically assembles operations like a regular cable_ready
call would do (the syntax is the same), but when being sent the dispatch
message, returns a hash that can be sent to a job, or rendered as JSON, etc. By calling apply!
on a cable_ready
channel as we did above, those operations are then broadcast to the respective user
.
Bonus: Now that the model itself is in charge of gathering the necessary operations, you could also act according to what has changed in the respective commit. For example, suppose you have a Rating
and would like to send out different notifications whether it has increased by one, two, or three stars: Use previous_changes
!
Conclusion
What have we gained? Three things basically:
- We have a flexible, reusable module (
UserNotifiable
) we can mix into any model we want to broadcast changes of to the pertaining users. Just make sure it has ausers
accessor. - The model is the object that knows best what has changed about it, so it is the proper thing to ask “what are your cable ready changes”?
- We have successfully isolated all common functionality in a single concern and a single job, leaving the developer only with the need to
include
it and specify a hash ofcable_ready_changes
.
Would you like to learn more? Look here.