ruby-on-railswebsocketactioncableturbo

In my Rails app, I'm having trouble getting a notifications icon to update with turbo frames when a new notification is present


Bit of a long post but I'm at a dead end. I am relatively new to Rails but I'm developing an app that allows users to comment and vote on reviews, the review owner should get notified (by the presence of a dot next to the notifications icon in the navbar) when another user votes/comments on their review. I have set up notifications so that the new notifications appear in the notifications tab (index) immediately after a vote or comment is created but I'm struggling to apply the same logic to get the icon in the navbar to update (for a dot to appear) when a new notification arrives in the index. The dot appears after a page refresh but not instantly. Here is the navbar button:

<%= turbo_stream_from "notification_dot_#{current_user.id}" %>
<%= link_to notifications_path, class: "bottom_navbar-link position-relative #{'active' if current_page?(notifications_path)}" do %>
  <i class="fas fa-bell"></i>
    <% if current_user.notifications.where(read: false).any? %>
      <span class="notification-dot"><i class="fa-solid fa-circle"></i></span>
    <% end %>
<% end %>

and the notifications index page

<%= turbo_stream_from "notifications_#{current_user.id}" %>

<div class="notifications-list" id="notifications-list">
  <% if @notifications.any? %>
    <% @notifications.each do |notification| %>
      <%= render notification %>
    <% end %>
  <% else %>
    <p>Nothing to see here...</p>
  <% end %>

this is the partial used for the dot to appear next to the notifications icon

<% if user.notifications.where(read: false).exists? %>
  <span class="notification-dot"><i class="fa-solid fa-circle"></i></span>
<% end %>

and finally the comment/vote models to broadcast the notifications to the index page (working) and to broadcast the dot to appear instantly when there's unread notifications (not working)

class Comment < ApplicationRecord
  belongs_to :review
  belongs_to :user
  has_many :notifications, dependent: :destroy

  validates :content, presence: true, length: { minimum: 3, maximum: 140 }

  after_create_commit :broadcast_comment, :broadcast_comment_count
  after_destroy_commit :broadcast_comment_count

  after_create :notify_review_owner

  private

  def broadcast_comment
    broadcast_prepend_to "review_#{review.id}_comments",
                         target: "comments-#{review.id}",
                         partial: "comments/comment",
                         locals: { comment: self, user: self.user }
  end

  def broadcast_comment_count
    broadcast_replace_to "review_#{review.id}_comments",
                         target: "comment-count-#{review.id}",
                         partial: "comments/comment_count",
                         locals: { review: review }
  end

  def notify_review_owner
    return if user == review.user

    notification = Notification.create(
      user: review.user,
      comment: self,
      notification_type: 'comment',
      content: "#{user.username} commented on your review.",
      read: false
    )

    Rails.logger.info "Notification created: #{notification.inspect}"
    Rails.logger.info "Broadcasting notification dot update for User ID: #{review.user.id}"

    broadcast_replace_to(
      "notification_dot_#{review.user.id}",
      target: "notification_dot_#{review.user.id}",
      partial: "notifications/notification_dot",
      locals: { user: review.user }
    )

    broadcast_prepend_later_to(
      "notifications_#{review.user.id}",
      target: "notifications-list",
      partial: "notifications/notification",
      locals: { notification: notification }
    )
  end
end
class Vote < ApplicationRecord
  belongs_to :review
  belongs_to :user
  has_many :notifications, dependent: :destroy

  validates :user_id, uniqueness: { scope: :review_id, message: "You can only vote once on a review" }

  after_create :notify_review_owner

  private

  def notify_review_owner # rubocop:disable Metrics/MethodLength
    return if user == review.user

    notification = Notification.create(
      user: review.user,
      vote_id: self.id,
      notification_type: 'vote',
      content: "#{user.username} upvoted your review.",
      read: false
    )

    Rails.logger.info "Notification created: #{notification.inspect}"
    Rails.logger.info "Broadcasting notification dot update for User ID: #{review.user.id}"

    broadcast_replace_to(
      "notification_dot_#{review.user.id}",
      target: "notification_dot_#{review.user.id}",
      partial: "notifications/notification_dot",
      locals: { user: review.user }
    )

    broadcast_prepend_later_to(
      "notifications_#{review.user.id}",
      target: "notifications-list",
      partial: "notifications/notification",
      locals: { notification: notification }
    )
  end
end

I've tried to implement similar logic to what I have working to send notifications to the index page. The server log seems to show that the notification dot broadcast is reaching the client with the broadcast but isn't being rendered on the frontend


Solution

  • You need to subscribe to a stream (action cable kind) and have a target for turbo streams that arrive through that subscription:

    # subscribe
    <%= turbo_stream_from "notification_dot", current_user %>
    
    <%= link_to notifications_path,
      class: ["bottom_navbar-link position-relative", active: current_page?(notifications_path)] do %>
    
      <i class="fas fa-bell"></i> <!--/-->
      <%= render "notifications/notification_dot" %>
    <% end %>
    

    if you're using replace action you need to have the whole div you're replacing in the partial:

    # app/views/notifications/_notification_dot.html.erb
    
    # target
    <%= tag.div id: "notification_dot_#{current_user.id}" do %>
      <% if current_user.notifications.where(read: false).any? %>
        <span class="notification-dot"><i class="fa-solid fa-circle"></i></span>
      <% end %>
    <% end %>
    

    and broadcast from models:

    broadcast_replace_to(
      "notification_dot", review.user,              # stream name matching turbo_stream_from
      target: "notification_dot_#{review.user.id}", # a div you want to replace
      partial: "notifications/notification_dot",
      locals: {current_user: review.user}
    )