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:

  1. 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 a users accessor.
  2. 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”?
  3. 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 of cable_ready_changes.

Would you like to learn more? Look here.