ActionCable - how to display number of connected users? - ruby-on-rails

I'm trying to create a simple chat-like application (planning poker app) with Action Cable. I'm a little bit confused by the terminology, files hierarchy and how the callbacks work.
This is the action that creates user session:
class SessionsController < ApplicationController
def create
cookies.signed[:username] = params[:session][:username]
redirect_to votes_path
end
end
A user can then post a vote that should be broadcasted to everyone:
class VotesController < ApplicationController
def create
ActionCable.server.broadcast 'poker',
vote: params[:vote][:body],
username: cookies.signed[:username]
head :ok
end
end
Up to this point everything is clear for me and works fine. The problem is - how do I display the number of connected users? Is there a callback that fires in JS when a user (consumer?) connects? What I want is when I open 3 tabs in 3 different browsers in incognito mode I would like to display "3". When a new user connects, I would like the number to increment. If any user disconnects, the number should decrement.
My PokerChannel:
class PokerChannel < ApplicationCable::Channel
def subscribed
stream_from 'poker'
end
end
app/assets/javascripts/poker.coffee:
App.poker = App.cable.subscriptions.create 'PokerChannel',
received: (data) ->
$('#votes').append #renderMessage(data)
renderMessage: (data) ->
"<p><b>[#{data.username}]:</b> #{data.vote}</p>"

Seems that one way is to use
ActionCable.server.connections.length
(See caveats in the comments)

For a quick (and probably not ideal) solution you can write a module that tracks subscription counts (using Redis to store data):
#app/lib/subscriber_tracker.rb
module SubscriberTracker
#add a subscriber to a Chat rooms channel
def self.add_sub(room)
count = sub_count(room)
$redis.set(room, count + 1)
end
def self.remove_sub(room)
count = sub_count(room)
if count == 1
$redis.del(room)
else
$redis.set(room, count - 1)
end
end
def self.sub_count(room)
$redis.get(room).to_i
end
end
And update your subscribed and unsubscribed methods in the channel class:
class ChatRoomsChannel < ApplicationCable::Channel
def subscribed
SubscriberTracker.add_sub params['room_id']
end
def unsubscribed
SubscriberTracker.remove_sub params['chat_room_id']
end
end

In a related question on who is connected, there was an answer for those who uses redis:
Redis.new.pubsub("channels", "action_cable/*")
and if you just want number of connections:
Redis.new.pubsub("NUMPAT", "action_cable/*")
This will summarize connections from all your servers.
All the magic covered inside RemoteConnections class and InternalChannel module.
TL;DR all connections subscibed on special channels with a prefix action_cable/* with only purpose of disconnecting sockets from main rails app.

I think i found a answer for you.
Try this:
ActionCable.server.connections.select { |con| con.current_room == room }.length?
I can use it everywhere in my code and check amount of connected users to selected stream :)
in my connection.rb I have something like this:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_room
def connect
self.current_room = find_room
end
private
def find_room
.....
end
end
end
Hope that helps anyone.

With
ActionCable.server.pubsub.send(:listener).instance_variable_get("#subscribers")
you can get map with subscription identifier in the key and array of procedures which will be executed on the broadcast. All procedures accept message as argument and have memoized connection.

Related

Using ActionCable With a Service Object and Responding to a Message

Making the title for this question was extremely difficult, but essentially, I have a service object in my Rails app that creates a flow for Resume Processing. I am trying to use ActionCable to connect my frontend with my backend. The current way I do this is by instantiating my Service Object in my controller:
def create_from_resume
...
ResumeParseService.new(#candidate, current_user)
end
My Service then begins by broadcasting to my front end to open the corresponding modal:
Service:
class ResumeParseService
attr_reader :user
attr_reader :employee
attr_reader :candidate
def initialize(candidate, user)
#user = user
#employee = user.employee
#candidate = candidate
#progress = 0
--> broadcast_begin
end
def begin_from_parse_modal
broadcast_progress(10)
parsed_resume = get_a_resume_while_hiding_implementation_details
broadcast_progress(rand(40..60))
...
broadcast_progress(100 - #progress)
...
end
private
def broadcast_begin
ResumeParseChannel.broadcast_and_set_service(self, user, {
event_name: 'transition_screen',
props: {
to: 'parse',
},
})
end
def broadcast_progress(addition)
#progress += addition
ResumeParseChannel.broadcast_to(user, {
event_name: 'progress',
props: {
progress: #progress,
},
})
end
def broadcast_transition_screen(screen_name, body = nil)
ResumeParseChannel.broadcast_to(user, {
event_name: 'transition_screen',
props: {
to: screen_name,
data: body,
},
})
end
end
Rails Channel:
# frozen_string_literal: true
class ResumeParseChannel < ApplicationCable::Channel
def subscribed
stream_for(current_user)
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def self.broadcast_and_set_service(service, *args)
#service = service
broadcast_to *args
end
def screen_transitioned(data)
case data['screen_name']
when 'parse'
pp #service
#service.begin_from_parse_modal
else
# type code here
end
end
private
def current_user
if (current_user = env["warden"].user)
current_user
else
reject_unauthorized_connection
end
end
end
Which my channel then takes care of. Later, my channel will send back a 'progress update' to let my service know the modal opened successfully:
JS Channel:
consumer.subscriptions.create(
{ channel: "ResumeParseChannel" },
{
connected() {
document.addEventListener("resume-parse:screen_transitioned", event =>
--> this.perform("screen_transitioned", event.detail)
);
},
}
);
Now, my problem is that once that message gets sent back to my (ruby) channel, I can't think of a way for it to find my existing instance of my service object and use it. As you can see, I tried to set an instance var on the channel with the service object instance on the first broadcast, but that (and a million other things) did not work. I need to call #begin_from_parse_modal once I get the 'screen_transitioned' with the screen_name of 'parse'. Ideally, I'd like to separate the broadcasting logic and the parsing logic as much as possible.
I understand that the instance of the channel can be thought of as the actual subscription, but I just don't understand what the best practice is of a system where I can send a "do this" message, and then do something once I get a "its been done" message.
Please let me know if I missed anything in terms of explanation and/or code. Feel free to also let me know if I should do something differently next time I ask something! This is my first time asking on stackoverflow, but it's about my billionth time looking for an answer :)
edit: I'm still dumbfounded by this seemingly common scenario. Could it possibly be best practice to just simply have the channel act as the service object? If so, how would we store state on it? The only possible way I can think of this working in any form is to send the full state in each WS message. Or at least the id's to each record thats in state and then lookup each record on each message. This seems unreasonably complex and expensive. I have scoured other questions and even ActionCable tutorials to find anyone using a service object with receiving messages and have found nothing. SOS!

multiple users chat receiving on action-cable

I have created one project where I have one customer and another contractor. I implemented ruby on rails actioncable for chat. All it is going good but issue is coming when two different people chat with one person, that person is receiving both messages in socket window. I realised that I have setup conversation-#{user_id} as a channel, so user is listening on this channel now two people send chat to him, they both will come on same channel. How can I avoid this? or can I add another user in channel string, but I found it is very difficult. Any idea where I have to send params to subscribe method.
connection
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_session_user
end
def find_session_user
current_user = User.find_by(id: cookies.signed[:actioncable_user_id])
current_user || reject_unauthorized_connection
end
end
My conversation channel
class ConversationChannel < ApplicationCable::Channel
def subscribed
stream_from "conversations-#{current_user.id}"
end
def unsubscribed
stop_all_streams
end
def speak(data)
message_params = data["message"].each_with_object({}) do |el, hash|
hash[el.values.first] = el.values.last
end
end
ActionCable.server.broadcast(
"conversations-#{current_user.id}",
message: message_params,
)
end
This code is just condense version, but as it will start conversation-user_id as a channel, so definitely when it is connected and other people send message, this user will receive them in same socket. so I have to do like `conversation-user_id-anotehr_user. Right now it is working on web/mobile and all good, but when two user communicate with one user it create issue by displaying two users chat on one socket.
Any idea how can I create such channel.
I have solved this issue by binding chat with 2 persons and I have another requirement of job specific chats, so have bound it with it too. Now my conversation channel is like conversation-(talk_id)-(listern_id)-job_id so it all good now. Following are changes I did
1. I removed channel file from assets/javascript as it is automatically load on my application, but I want to bound it with few parameters so I added it to specific view. My controller has already few parameters so I have changed this javascript to following
<script>
App.conversation = App.cable.subscriptions.create({channel: "ConversationChannel",
job_id: <%= #job.id %>,
contractor: <%= #contractor %>
}, {
connected: function() {},
disconnected: function() {},
received: function(data) {
var conversation = $('#conversations-list').find("[data-conversation-id='" + data['conversation_id'] + "']");
conversation.find('.messages-list').find('ul').append(data['message']);
var messages_list = conversation.find('.messages-list');
var height = messages_list[0].scrollHeight;
messages_list.scrollTop(height);
},
speak: function(message) {
return this.perform('speak', {
message: message
});
}
});
Now when connection establish it sends both parameters and channel channel properly individual. On my conversation.rb I have just minor change
def subscribed
stream_from "conversations-#{current_user.id}-#{params[:contractor]}-#{params[:job_id]}"
end
Now everything working perfectly as per our requirements, each channel is being made with 2 users+job Id so I they can communicate on specific job and with specific users, so there no more other person can listen other conversation.
Just posting may help someone.

How should Sidekiq notify user on the web page when job is finished?

A user can upload multiple files in my system via a web interface that is handled by a controller action.
def import
paths = params[:files].map(&:path) if params[:files].present?
if paths
#results = MyRecord.create_from paths
end
redirect_to confirmation_my_records_path
end
class MyRecord < ActiveRecord::Base
def self.create_from(paths)
paths.each do |path|
MyRecordWorker.perform path
end
end
end
works/my_record_worker.rb
class MyRecordWorker
include Sidekiq::Worker
def perform(path)
# each time this is run, it is no I/O, just expensive math calculations that take minutes
collection = ExpensiveOperation.new(path).run
if collection && collection.any?
save_records(collection)
else
[]
end
end
end
Because the sidekiq jobs will run in the background, how do I notify the user through the confirmation page that the jobs are done? We don't know when the job will finish and therefore the Rails request and response cycle cannot determine anything when the confirmation page is loaded. Should the confirmation page just say "Processing..." and then have Faye update the page when the job is finished?
Add a RecordJobs model to your application that stores who uploaded the records, the number of records, the upload time and so on.
Create a new record_job each time someone uploads new records and pass its id to the worker. The worker can update that instance to indicate its progress.
At the same time, the application can query for existing record_job and show their progress. That can be done whenever the user reloads it page, by active polling or with web sockets (depending on your needs).
use ActionCable, here is the example with Report channel
In routes.rb add the following line
mount ActionCable.server => '/cable'
create a ReportChannel.rb inside app/channels with following code
class ReportChannel < ApplicationCable::Channel
def subscribed
stream_from "report_#{current_user.id}"
end
end
create reports.coffee inside app/assets/javascripts/channels with the following code
window.App.Channels ||= {}
window.App.Channels.Report ||= {}
window.App.Channels.Report.subscribe = ->
App.report = App.cable.subscriptions.create "ReportChannel",
connected: ->
console.log('connected ')
disconnected: ->
console.log('diconnected ')
received: (data) ->
console.log('returned ')
data = $.parseJSON(data)
#write your code here to update or notify user#
and add the following lines at the end of your job
ActionCable.server.broadcast("*report_*#{current_user_id}", data_in_json_format)
replace report with your specific model
seems simple right :)

How to call ActionCable method from controller or model - "uninitialized constant QuizSession::App" Error

I'm trying to write a simple quiz app with Ruby on Rails with 2 users, who get to see different views:
the quiz hoster view shows the current question and a "next question"-button (which he is supposed to project onto a wall for the audience) and
the participant view shows the 4 buttons with answer options corresponding to the current question (so the audience can participate in the quiz from their smartphones).
I'm at the point, where I'm trying to use ActionCable to broadcast these 4 answer buttons to my channel, but when I try calling the method I defined in my channel I get the error "uninitialized constant QuizSession::App".
These are the steps I've taken to enable ActionCable:
1) I generated a new channel in /app/channels/quiz_data_channel.rb:
class QuizDataChannel < ApplicationCable::Channel
def subscribed
stream_from "quiz_data"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def send_data
Services::QuizDataCreation.new(user: current_user).create
end
end
# /app/assets/javascripts/channels/quiz_data.coffee:
App.quiz_data = App.cable.subscriptions.create "QuizDataChannel",
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
# Called when there's incoming data on the websocket for this channel
$('answer-buttons').append(data.partial)
send_data: ->
#perform 'send_data'
2) Then I made a new service in app/models/services/quiz_data_creation.rb:
module Services
class QuizDataCreation
def initialize(user)
self.user = user
end
def create
create_quiz_data
broadcast_creation
end
private
attr_accessor :user, :answers
def create_quiz_data #not sure if this will work
#quiz_session = user.quiz_session
self.answers = #quiz_session.quiz.questions[#quiz_session.current_question_index].answers
end
def broadcast_creation
QuizDataBroadcastJob.perform_now(answers)
end
end
end
3) Then I generated a new job at /app/jobs/quiz_data_broadcast_job.rb:
class QuizDataBroadcastJob < ApplicationJob
queue_as :default
def perform(answers)
ActionCable.server.broadcast('quiz_data', data: render_answer_options(answers))
end
private
def render_answer_options(answers)
answers.each do |answer|
ApplicationController.render(
#render student/quiz_question page and render as many answer_option partials as needed
partial: 'pages/student/answer_option',
locals: {answer: answer}
)
end
end
end
4) I mounted ActionCable in my routes.rb:
mount ActionCable.server => '/cable'
5) And finally I'm trying to broadcast data by calling the send_data function elsewhere in my application:
def send_current_question
App.quiz_data.send_data <--- this is apparently where the error gets thrown
end
What I would like to know is:
How do I solve this error?
Is the problem that I haven't established the socket connection correctly?
I have read 3 ActionCable guides and watched 2 guide videos - since most of them seem to be about chat applications (which in my mind is a 1 to 1 connection) I am now thoroughly confused as to how to get my app to work in the one-to-many broadcasting fashion I need.
I'm new to Rails, so any pointers would be appreciated! :)
WebSockets are designed such that it doesn't matter whether you are building a one-to-one, many-to-one, one-to-many or many-to-many feature. The reason is as a Publisher to a channel, you do not know who is Subscribed to it, so whether it is just 1 or 1000 users, they will all receive the message that is published.
As for why you are getting the error, well the App object is a JavaScript object (see how you are accessing it in your coffeescript files), so you cannot use it in Ruby code.
The App object is not made available in your Ruby code (backend) because it is supposed to be a method for the client (user) to communicate with the server (backend), which is why it is initialized in your coffeescript code.
So you will need to call the send_data method by attaching a click handler to your button. Let's say in your html your button looks like:
<button id="broadcast-button">Broadcast</button>
In your client side code (coffeescript), you would do the following:
$('#broadcast-button').on 'click', (e) ->
App.quiz_data.send_data()
So when the user clicks on that button, the send_data method will be called.
As for why you are getting the error, well the App object is a JavaScript object (see how you are accessing it in your coffeescript files), so you cannot use it in Ruby code.
The App object is not made available in your Ruby code (backend)
because it is supposed to be a method for the client (user) to
communicate with the server (backend), which is why it is initialized
in your coffeescript code.
As #Laith Azer pointed out, it is not possible to call a CoffeeScript method (since that represents the frontend) from a controller or model, but all my CoffeeScript method send_data really does, is call its counterpart on the backend:
#/app/assets/javascripts/channels/quiz_data.coffee:
App.quiz_data = App.cable.subscriptions.create "QuizDataChannel",
...
send_data: ->
#perform 'send_data'
So what is possible, is to call this backend method directly:
#In some controller or model:
QuizDataChannel.send_data(current_user)
For that to work we need to change the method in our ActionCable channel to a class method:
#/app/channels/quiz_data_channel.rb:
class QuizDataChannel < ApplicationCable::Channel
def self.send_data(current_user) #self. makes it a class method
new_quiz_html = Services::QuizDataCreation.new(user: current_user).create
#change "some_room" to the room name you want to send the page on
ActionCable.server.broadcast("some_room", html: new_quiz_html)
end
end
And all that is left is to receive this html page and display it:
#/app/assets/javascripts/channels/quiz_data.coffee:
App.quiz_data = App.cable.subscriptions.create "QuizDataChannel",
...
send_data: ->
#perform 'send_data'
received: (data) ->
# This will replace your body with the page html string:
$('body').html(data.html)

How can I call a controller action from ActiveAdmin?

I have this method in my reports_controller.rb, which allows an user to send a status.
def send_status
date = Date.today
reports = current_user.reports.for_date(date)
ReportMailer.status_email(current_user, reports, date).deliver
head :ok
rescue => e
head :bad_request
end
How can I call this action from ActiveAdmin, in order to check if a User sent this report or not? I want it like a status_tag on a column or something.
Should I do a member action?
Thanks!
I'll address the issue of checking if a report has been sent later, but first I'll cover the question of how to call the controller action from ActiveAdmin.
While you can call ReportsController#send_status by creating an ActionController::Base::ReportsController and then calling the desired method, e.g.
ActionController::Base::ReportsController.new.send_status
this isn't a good idea. You probably should refactor this to address a couple potential issues.
app/controllers/reports_controller.rb:
class ReportsController < ApplicationController
... # rest of controller methods
def send_status
if current_user # or whatever your conditional is
ReportMailer.status_email(current_user).deliver
response = :ok
else
response = :bad_request
end
head response
end
end
app/models/user.rb:
class User < ActiveRecord::Base
... # rest of user model
def reports_for_date(date)
reports.for_date(date)
end
end
app/mailers/reports_mailer.rb
class ReportsMailer < ActionMailer::Base
... # rest of mailer
def status_email(user)
#user = user
#date = Date.today
#reports = #user.reports_for_date(#date)
... # rest of method
end
end
This could obviously be refactored further, but provides a decent starting point.
An important thing to consider is that this controller action is not sending the email asynchronously, so in the interest of concurrency and user experience, you should strongly consider using a queuing system. DelayedJob would be an easy implementation with the example I've provided (look into the DelayedJob RailsCast).
As far as checking if the report has been sent, you could implement an ActionMailer Observer and register that observer:
This requires that the User model have a BOOLEAN column status_sent and that users have unique email address.
lib/status_sent_mail_observer.rb:
class StatusSentMailObserver
self.delivered_email(message)
user = User.find_by_email(message.to)
user.update_attribute(:status_sent, true)
end
end
config/intializer/setup_mail.rb:
... # rest of initializer
Mail.register_observer(StatusSentMailObserver)
If you are using DelayedJob (or almost any other queuing system) you could implement a callback method to be called on job completion (i.e. sending the status email) that updates a column on the user.
If you want to track the status message for every day, you should consider creating a Status model that belongs to the User. The status model could be created every time the user sends the email, allowing you to check if the email has been sent simply by checking if a status record exists. This strategy is one I would seriously consider adopting over just a simple status_sent column.
tl;dr ActionController::Base::ReportsController.new.send_status & implement an observer that updates a column on the user that tracks the status. But you really don't want to do that. Look into refactoring like I've mentioned above.

Resources