From e51feb5d61bb08f260bc920db88bff534d132f7f Mon Sep 17 00:00:00 2001 From: sammo1235 Date: Fri, 11 Oct 2024 12:40:24 +0100 Subject: [PATCH 1/9] Create collaborator channels, stop editing unless editor, and switching forms behaviour Implements two main behaviours using ActionCable: 1. If a second user has the same section of a form open, they cannot edit it. However if the first user closes their tab or moves on to a different section then editing rights are released to the second user. 2. If user 1 opens a second tab of the same form, they cannot edit in the second tab. They are shown a message telling them to close the tab and edit in the first tab. --- app/assets/javascripts/application.js.coffee | 5 +- app/assets/javascripts/channels/index.coffee | 6 + .../access_manager.js.coffee | 159 +++++------------- .../connection_manager.js.coffee | 76 ++------- .../editor_bar.js.coffee | 7 +- .../general_room_tracking.js.coffee | 32 ++-- .../frontend/form-validation.js.coffee | 2 +- app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 4 + app/channels/collaborators_channel.rb | 41 +++++ app/channels/general_room_channel.rb | 29 ++++ app/controllers/application_controller.rb | 5 + app/views/qae_form/show.html.slim | 2 +- .../collaborators/broadcast_collab_worker.rb | 7 + config/cable.yml | 2 +- config/environments/development.rb | 3 +- docker-compose.yml | 2 +- package.json | 1 + spec/channels/collaborators_channel_spec.rb | 5 + yarn.lock | 5 + 20 files changed, 188 insertions(+), 209 deletions(-) create mode 100644 app/assets/javascripts/channels/index.coffee create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/collaborators_channel.rb create mode 100644 app/channels/general_room_channel.rb create mode 100644 app/workers/collaborators/broadcast_collab_worker.rb create mode 100644 spec/channels/collaborators_channel_spec.rb diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 1e8ce6962b..df5337419b 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -23,6 +23,7 @@ #= require_tree ./frontend #= require ./frontend/financial_summary_tables/fst_base.js #= require_tree ./frontend/financial_summary_tables +#= require channels # #= require offline @@ -740,7 +741,7 @@ jQuery -> CollaboratorsLog.log("[COLLABORATOR MODE] ------------ redirect_url ----------- " + redirect_url) - if ApplicationCollaboratorsAccessManager.does_im_current_editor() + if ApplicationCollaboratorsAccessManager.i_am_current_editor() CollaboratorsLog.log("[COLLABORATOR MODE] -------------I'm EDITOR---------- SAVE AND REDIRECT") # If I'm current editor # -> then save form data and once it saved redirect me to proper section in a callback @@ -755,7 +756,7 @@ jQuery -> window.location.href = redirect_url else - CollaboratorsLog.log("[STANDART MODE] ----------------------- ") + CollaboratorsLog.log("[STANDARD MODE] ----------------------- ") autosave() diff --git a/app/assets/javascripts/channels/index.coffee b/app/assets/javascripts/channels/index.coffee new file mode 100644 index 0000000000..c380d1d05a --- /dev/null +++ b/app/assets/javascripts/channels/index.coffee @@ -0,0 +1,6 @@ +#= require action_cable +#= require_self +#= require_tree . + +@App = {} +App.cable = ActionCable.createConsumer() \ No newline at end of file diff --git a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee index 926fca799e..e92cf7e058 100644 --- a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee @@ -1,133 +1,64 @@ window.ApplicationCollaboratorsAccessManager = - register_member: (member) -> - member_info = ApplicationCollaboratorsAccessManager.get_member_info(member) - - if member.id is String(window.pusher_current_channel.members.me.id) - CollaboratorsLog.log("[ME IS MEMBER] ------------------------- " + member_info) - else - CollaboratorsLog.log("[ANOTHER MEMBER] ------------------------- " + member_info) - set_access_mode: () -> - editor = ApplicationCollaboratorsAccessManager.current_editor() - CollaboratorsLog.log("[SET ACCESS MODE] ------------ CURRENT EDITOR IS -------- " + editor.id) + editor_id = ApplicationCollaboratorsAccessManager.current_editor_id() - ApplicationCollaboratorsAccessManager.track_current_editor(editor) + CollaboratorsLog.log("[SET ACCESS MODE] ------------ CURRENT EDITOR IS -------- " + editor_id) + previous_editor_id = window.last_editor_id + console.log("previous editor was: " + previous_editor_id) + ApplicationCollaboratorsAccessManager.track_current_editor(editor_id) - if ApplicationCollaboratorsAccessManager.does_im_current_editor() + if ApplicationCollaboratorsAccessManager.i_am_current_editor() CollaboratorsLog.log("[EDITOR MODE] ----------------------") - else - CollaboratorsLog.log("[READ ONLY MODE] ----------------------") - - ApplicationCollaboratorsFormLocker.lock_current_form_section() - ApplicationCollaboratorsEditorBar.render_collaborators_bar() + ApplicationCollaboratorsEditorBar.hide_collaborators_bar() - login_to_the_room: (current_step) -> - # Unsubscribe client from current section room - room = window.pusher_current_channel.name; - window.pusher.unsubscribe(window.pusher_current_channel.name); + if previous_editor_id != undefined && previous_editor_id != ApplicationCollaboratorsAccessManager.current_editor_id() + CollaboratorsLog.log("[NOW IM EDITOR] ---- REFRESHING PAGE") - # Clean up last editor id as we switched to another section - window.pusher_last_editor_id = null; + ApplicationCollaboratorsEditorBar.show_loading_bar() - # And hide collaborators info bar - ApplicationCollaboratorsEditorBar.hide_collaborators_bar() + # Redirect user to same page in order to get the new changes + redirect_url = $(".js-step-link.step-current a").attr('href') + redirect_url += "&form_refresh=true" - CollaboratorsLog.log("[TAB SWITCH] ------------- Log out from (" + room + ") to " + current_step + " --------------------") + # + # In case it was an attempt to submit and validation errors are present + # then we are passing validate_on_form_load option + # in order to show validation errors to user after redirection + # + if window.location.href.search("validate_on_form_load") > 0 + redirect_url += "&validate_on_form_load=true" - # Set new pusher section - window.pusher_section = current_step; - - # Set new login timestamp for user - timestamp = new Date().getTime(); - - # Init new room based on selected section - ApplicationCollaboratorsConnectionManager.init_pusher(timestamp) - ApplicationCollaboratorsConnectionManager.init_room() - - my_position_in_members_queue: () -> - i = 0 - my_index = 0 - - members = window.pusher_current_channel.members - me = members.me - - members.each (member) -> - if member.id is String(me.id) - my_index = i - - i += 1 + window.location.href = redirect_url + else + CollaboratorsLog.log("[READ ONLY MODE] ----------------------") - my_index + ApplicationCollaboratorsFormLocker.lock_current_form_section() + ApplicationCollaboratorsEditorBar.render_collaborators_bar() - does_im_current_editor: () -> - ApplicationCollaboratorsAccessManager.my_position_in_members_queue() == 0 + i_am_current_editor: () -> + ApplicationCollaboratorsAccessManager.current_editor_id() == window.user_id && + window.tab_ident == ApplicationCollaboratorsAccessManager.current_editor().tab_ident im_in_viewer_mode: () -> - !ApplicationCollaboratorsAccessManager.does_im_current_editor() - - normalized_members_array: () -> - list = [] + !ApplicationCollaboratorsAccessManager.i_am_current_editor() - members = window.pusher_current_channel.members - members.each (member) -> - list.push(member) - - return list - - get_member_info: (m) -> - return ("ID: " + m.id + ", NAME: " + m.info.name + ", JOINED AT: " + m.info.joined_at) + current_editor_id: () -> + editor_id = window.current_channel_members.split("/").find((el) => el.includes("EDITOR")).split(":")[0] + + return editor_id current_editor: () -> - editor = ApplicationCollaboratorsAccessManager.normalized_members_array()[0] - member_info = ApplicationCollaboratorsAccessManager.get_member_info(editor) - - CollaboratorsLog.log("[CURRENT EDITOR] ------------- " + member_info + " --------------------") - - return editor - - try_mark_as_editor: () -> - editor = ApplicationCollaboratorsAccessManager.current_editor() - - if ApplicationCollaboratorsAccessManager.can_be_marked_as_editor(editor) - CollaboratorsLog.log("[NOW IM EDITOR] ----------------------") - - ApplicationCollaboratorsEditorBar.show_loading_bar() - - # Redirect user to same page in order to login him - redirect_url = $(".js-step-link.step-current a").attr('href') - redirect_url += "&form_refresh=true" - - # - # In case if was attempt to submit and validation errors are present - # then we are passing validate_on_form_load option - # in order to show validation errors to user after redirection - # - if window.location.href.search("validate_on_form_load") > 0 - redirect_url += "&validate_on_form_load=true" - - window.location.href = redirect_url - else - # - # If I'm not next in queue to be marked as editor - # or I'm already editor - # - # Then do not need to refresh page - # - - if window.pusher_last_editor_id == editor.id - CollaboratorsLog.log("[ACCESS CALC] ---------------------- I'M ALREADY EDITOR OF CURRENT TAB!") - else - CollaboratorsLog.log("[ACCESS CALC] ---------------------- NOPE - I STILL HAVE TO WAIT!") - - track_current_editor: (editor) -> - window.pusher_last_editor_id = editor.id - - can_be_marked_as_editor: (editor) -> - # - # If I'm next in queue to join room as editor - # and I'm not previous editor - # - ApplicationCollaboratorsAccessManager.does_im_current_editor() && - window.pusher_last_editor_id != editor.id + editor = window.current_channel_members.split("/").find((el) => el.includes("EDITOR")).split(":") + editor_info = { + id: editor[0], + tab_ident: editor[1], + email: editor[2], + name: editor[3] + } + + editor_info + + track_current_editor: (editor_id) -> + window.last_editor_id = editor_id diff --git a/app/assets/javascripts/frontend/application_collaborators/connection_manager.js.coffee b/app/assets/javascripts/frontend/application_collaborators/connection_manager.js.coffee index c1a08c5a7e..9b9154549d 100644 --- a/app/assets/javascripts/frontend/application_collaborators/connection_manager.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/connection_manager.js.coffee @@ -1,6 +1,6 @@ window.ApplicationCollaboratorsConnectionManager = - init: (form_id, p_host, p_port, p_key, rails_env, timestamp) -> + init: (form_id, user_id, rails_env) -> # # Checking if browser supports WebSockets technology @@ -9,79 +9,27 @@ window.ApplicationCollaboratorsConnectionManager = window.form_id = form_id - window.pusher_host = p_host - window.pusher_port = p_port - - window.pusher_key = p_key window.rails_env = rails_env + window.user_id = user_id - if rails_env == "staging" || rails_env == "production" - window.pusher_encrypted = true - else - window.pusher_encrypted = false + window.tab_ident = ApplicationCollaboratorsConnectionManager.get_tab_ident() - window.pusher_section = $(".js-step-link.step-current").attr('data-step') + window.form_section = $(".js-step-link.step-current").attr('data-step') - ApplicationCollaboratorsConnectionManager.init_pusher(timestamp) ApplicationCollaboratorsConnectionManager.init_room() ApplicationCollaboratorsGeneralRoomTracking.login() - init_pusher: (timestamp) -> - CollaboratorsLog.log("[PUSHER INIT] form_id: " + window.form_id + ", host: " + window.pusher_host + ", port: " + window.pusher_port + ", key: " + window.pusher_key + ", enc: " + window.pusher_encrypted + ", section: " + window.pusher_section) - - # Init Pusher to use own Poxa server - pusher_ops = { - wsHost: window.pusher_host, - wsPort: window.pusher_port, - authTransport: 'jsonp', - authEndpoint: "/users/form_answers/" + window.form_id + "/collaborator_access/auth/" + window.pusher_section + "/" + timestamp - } - - # Use encryption on live and staging - # as they are using HTTPS - # - if window.pusher_encrypted == "true" - pusher_ops["encrypted"] = true - CollaboratorsLog.log("[PUSHER OPS] encryption turned on!") - else - CollaboratorsLog.log("[PUSHER OPS] encryption turned off!") - - window.pusher = new Pusher(window.pusher_key, pusher_ops) - - # Check connection status - connection_status = pusher.connection.state - CollaboratorsLog.log("PUSHER STATUS: " + connection_status) - - init_room: () -> # Introduce new channel - channel_name = 'presence-chat-' + window.rails_env + "-" + window.form_id + '-sep-' + window.pusher_section - - CollaboratorsLog.log("[PUSHER INIT ROOM] ------------------------ channel_name: " + channel_name) - - window.pusher_current_channel = window.pusher.subscribe(channel_name) - - # Check if subscription was successful - window.pusher_current_channel.bind 'pusher:subscription_succeeded', (members) -> - CollaboratorsLog.log('[subscription_succeeded] Count ' + members.count) - - ApplicationCollaboratorsAccessManager.set_access_mode() - members.each (member) => - ApplicationCollaboratorsAccessManager.register_member(member) - - return - - # Handle member removed - window.pusher_current_channel.bind 'pusher:member_removed', (member) -> - CollaboratorsLog.log('[member_removed] Count ' + window.pusher_current_channel.members.count) + channel_name = 'presence-chat-' + window.rails_env + "-" + window.form_id + '-sep-' + window.form_section - ApplicationCollaboratorsAccessManager.try_mark_as_editor() + CollaboratorsLog.log("[INIT ROOM] ------------------------ channel_name: " + channel_name) - return + window.App.collaborators = App.cable.subscriptions.create { channel: "CollaboratorsChannel", channel_name: channel_name, user_id: window.user_id, current_tab: window.tab_ident }, + received: (data) -> + window.current_channel_members = data.collaborators + ApplicationCollaboratorsAccessManager.set_access_mode() - # Handle member added - window.pusher_current_channel.bind 'pusher:member_added', (member) -> - CollaboratorsLog.log('[member_added] Count ' + window.pusher_current_channel.members.count) - ApplicationCollaboratorsAccessManager.register_member(member) + get_tab_ident: () -> + document.cookie.split('; ').find((c) => c.split("=")[0] == 'public_tab_ident').split('=')[1] - return diff --git a/app/assets/javascripts/frontend/application_collaborators/editor_bar.js.coffee b/app/assets/javascripts/frontend/application_collaborators/editor_bar.js.coffee index e278e053a5..9873146d4e 100644 --- a/app/assets/javascripts/frontend/application_collaborators/editor_bar.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/editor_bar.js.coffee @@ -2,12 +2,11 @@ window.ApplicationCollaboratorsEditorBar = render_collaborators_bar: () -> editor = ApplicationCollaboratorsAccessManager.current_editor() - currentEditorName = editor.info.name + " (" + editor.info.email + ")" + currentEditorName = editor.name + " (" + editor.email + ")" - members = window.pusher_current_channel.members - me = members.me + me = window.user_id - if me.info.email == editor.info.email + if me == editor.id header = "You cannot edit this section unless you close it elsewhere first." message = "It looks like you have already opened this section in another tab or window. To avoid data-saving issues, you can only have it open in one tab or window at a time. Please close the other tabs or windows to continue editing." else diff --git a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee index 93617baa12..dcec9f70e9 100644 --- a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee @@ -4,29 +4,21 @@ window.ApplicationCollaboratorsGeneralRoomTracking = # Introduce general channel channel_name = 'presence-chat-' + window.rails_env + "-" + window.form_id + '-general' - CollaboratorsLog.log("[PUSHER INIT GENERAL ROOM] ------------------------ channel_name: " + channel_name) + CollaboratorsLog.log("[INIT GENERAL ROOM] ------------------------ channel_name: " + channel_name) - general_channel = window.pusher.subscribe(channel_name) + window.App.generalRoom = App.cable.subscriptions.create { channel: "GeneralRoomChannel", channel_name: channel_name, user_id: window.user_id }, + connected: -> + console.log("connected to GENERAL ROOM") - # Check if subscription was successful - # and track number of max members in room - # - general_channel.bind 'pusher:subscription_succeeded', (members) -> - CollaboratorsLog.log('[GENERAL_ROOM subscription_succeeded] Count: ' + members.count) - window.pusher_max_members_count = members.count + received: (data) -> + console.log("receieved data", data.collaborators) - return + window.general_room_members = data.collaborators + ApplicationCollaboratorsAccessManager.set_access_mode() - # Handle member added - general_channel.bind 'pusher:member_added', (member) -> - members_count = general_channel.members.count - CollaboratorsLog.log('[GENERAL_ROOM member_added] Count: ' + members_count) - - if window.pusher_max_members_count < members_count - window.pusher_max_members_count = members_count - - return + disconnected: -> + console.log("disconnected") there_are_other_collaborators_here: () -> - window.pusher_max_members_count && - window.pusher_max_members_count > 1 + window.general_room_members && + window.general_room_members.split('/').length > 1 diff --git a/app/assets/javascripts/frontend/form-validation.js.coffee b/app/assets/javascripts/frontend/form-validation.js.coffee index 4d2c5e2e29..5f136ad612 100644 --- a/app/assets/javascripts/frontend/form-validation.js.coffee +++ b/app/assets/javascripts/frontend/form-validation.js.coffee @@ -890,7 +890,7 @@ window.FormValidation = qRef = question.attr("data-question_ref") qTitle = $.trim(question.find("h2").first().text()) - if typeof console != "undefined" + if typeof console != "undefined" && false console.log "-----------------------------" console.log("[STEP]: " + stepTitle) console.log(" [QUESTION] " + qRef + ": "+ qTitle) diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000000..d672697283 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000000..0ff5442f47 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + end +end diff --git a/app/channels/collaborators_channel.rb b/app/channels/collaborators_channel.rb new file mode 100644 index 0000000000..dc2c60c0bf --- /dev/null +++ b/app/channels/collaborators_channel.rb @@ -0,0 +1,41 @@ +class CollaboratorsChannel < ApplicationCable::Channel + def subscribed + stream_from params["channel_name"] + collaborators = Rails.cache.read(params["channel_name"]) + user = User.find(params["user_id"]) + + if collaborators.blank? + collaborators = "#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}:EDITOR" + else + collaborators += "/#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}" + end + + Rails.cache.write(params["channel_name"], collaborators) + + Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], collaborators) + end + + def unsubscribed + user_id = params["user_id"] + new_collaborators = [] + collaborators = Rails.cache.read(params["channel_name"]) + + collaborators.split("/").each do |collaborator| + # remove the unsubscribing user + new_collaborators << collaborator unless collaborator.split(":")[0] == user_id && collaborator.split(":")[1] == params["current_tab"] + end + + if !new_collaborators.empty? && new_collaborators.join.exclude?("EDITOR") + # editor has left the channel, so we update the next in line to be editor + temp_collabs = new_collaborators + temp_collabs[0] += ":EDITOR" + new_collaborators = temp_collabs.join("/") + else + new_collaborators = new_collaborators.join("/") + end + + Rails.cache.write(params["channel_name"], new_collaborators) + + Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], new_collaborators) + end +end diff --git a/app/channels/general_room_channel.rb b/app/channels/general_room_channel.rb new file mode 100644 index 0000000000..ae5b00f1ab --- /dev/null +++ b/app/channels/general_room_channel.rb @@ -0,0 +1,29 @@ +class GeneralRoomChannel < ApplicationCable::Channel + def subscribed + room_members = Rails.cache.read(params["channel_name"]) + + if room_members.blank? + room_members = params["user_id"] + elsif room_members.split("/").exclude?(params["user_id"]) + room_members += "/#{params["user_id"]}" + end + + stream_from params["channel_name"] + + Rails.cache.write(params["channel_name"], room_members) + + Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], room_members) + end + + def unsubscribed + room_members = Rails.cache.read(params["channel_name"]) + + tmp_room_members = room_members.split("/") + tmp_room_members.delete(params["user_id"]) + room_members = tmp_room_members.join("/") + + Rails.cache.write(params["channel_name"], room_members) + + Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], room_members) + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 84a1737eb1..e3a0b3aa28 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base before_action :set_current_attributes before_action :set_paper_trail_whodunnit before_action :disable_browser_caching! + before_action :set_session_identifier self.responder = AppResponder respond_to :html @@ -93,6 +94,10 @@ def should_enable_js? helper_method "#{award}_submission_started_deadline" end + def set_session_identifier + cookies["public_tab_ident"] = cookies["_qae_session#{"_development" if Rails.env.development?}"]&.first(8) + end + protected def settings diff --git a/app/views/qae_form/show.html.slim b/app/views/qae_form/show.html.slim index 9158de7f0d..efec3ddd07 100644 --- a/app/views/qae_form/show.html.slim +++ b/app/views/qae_form/show.html.slim @@ -35,4 +35,4 @@ form.qae-form.award-form data-autosave-url=save_form_url(@form_answer) action=sa - if application_collaborator_group_mode?(@form_answer) = content_for(:javascript_code) do - | ApplicationCollaboratorsConnectionManager.init('#{@form_answer.id}', '#{Pusher.host}', #{Pusher.port}, '#{Pusher.key}', '#{Rails.env}', #{Time.now.utc.to_i}); + | ApplicationCollaboratorsConnectionManager.init('#{@form_answer.id}', '#{current_user.id}', '#{Rails.env}'); diff --git a/app/workers/collaborators/broadcast_collab_worker.rb b/app/workers/collaborators/broadcast_collab_worker.rb new file mode 100644 index 0000000000..5fb402cdbb --- /dev/null +++ b/app/workers/collaborators/broadcast_collab_worker.rb @@ -0,0 +1,7 @@ +class Collaborators::BroadcastCollabWorker + include Sidekiq::Worker + + def perform(channel_name, collaborators) + ActionCable.server.broadcast(channel_name, { collaborators: collaborators }) unless collaborators.nil? + end +end diff --git a/config/cable.yml b/config/cable.yml index 48d02bebc8..bbcbb3db58 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,5 +1,5 @@ development: - adapter: async + adapter: redis test: adapter: test diff --git a/config/environments/development.rb b/config/environments/development.rb index e3ff2e6029..c2a7de105a 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -24,7 +24,8 @@ config.action_controller.perform_caching = true config.action_controller.enable_fragment_cache_logging = true - config.cache_store = :memory_store + config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] } + config.public_file_server.headers = { "Cache-Control" => "public, max-age=#{2.days.to_i}", } diff --git a/docker-compose.yml b/docker-compose.yml index aa0c5a3220..3af7024131 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: condition: service_healthy redis-db: condition: service_started - command: /rails/bin/rails server + command: /rails/bin/rails server -b 0.0.0.0 entrypoint: ["/rails/bin/docker-entrypoint"] volumes: - .:/rails:cached diff --git a/package.json b/package.json index f7c6fcf08e..ec471341d4 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "@hotwired/stimulus": "^3.2.1", "@hotwired/stimulus-webpack-helpers": "^1.0.1", "@rails/webpacker": "^6.0.0-rc.6", + "@rails/actioncable": "7.2.100", "css-loader": "^5.2.6", "css-minimizer-webpack-plugin": "^3.0.2", "govuk-frontend": "^5.7.1", diff --git a/spec/channels/collaborators_channel_spec.rb b/spec/channels/collaborators_channel_spec.rb new file mode 100644 index 0000000000..745809e024 --- /dev/null +++ b/spec/channels/collaborators_channel_spec.rb @@ -0,0 +1,5 @@ +require "rails_helper" + +RSpec.describe CollaboratorsChannel, type: :channel do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/yarn.lock b/yarn.lock index d3385b50ce..65c3e83018 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1056,6 +1056,11 @@ mkdirp "^1.0.4" rimraf "^3.0.2" +"@rails/actioncable@7.2.100": + version "7.2.100" + resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.2.100.tgz#86ec1c2e00c357cef1e421fd63863d5c34339ce8" + integrity sha512-7xtIENf0Yw59AFDM3+xqxPCZxev3QVAqjPmUzmgsB9eL8S/zTpB0IU9srNc7XknzJI4e09XKNnCaJRx3gfYzXA== + "@rails/webpacker@^6.0.0-rc.6": version "6.0.0-rc.6" resolved "https://registry.yarnpkg.com/@rails/webpacker/-/webpacker-6.0.0-rc.6.tgz#04af15dc33697e09aa492da54d2093cdd15573ff" From 33e7a304e16fec2a6c5f39d6e4f08e2d67dc50cb Mon Sep 17 00:00:00 2001 From: sammo1235 Date: Tue, 10 Dec 2024 09:38:57 +0000 Subject: [PATCH 2/9] Remove all pusher references and dependencies --- .env.example | 5 --- Gemfile | 3 -- Gemfile.lock | 6 ---- app/assets/javascripts/application.js.coffee | 1 - app/assets/javascripts/libs/pusher.min.js | 9 ------ .../users/collaborator_access_controller.rb | 31 ------------------- config/initializers/pusher_init.rb | 15 --------- config/routes.rb | 6 ---- 8 files changed, 76 deletions(-) delete mode 100644 app/assets/javascripts/libs/pusher.min.js delete mode 100644 app/controllers/users/collaborator_access_controller.rb delete mode 100644 config/initializers/pusher_init.rb diff --git a/.env.example b/.env.example index 6509a090bc..6852dfa6dc 100644 --- a/.env.example +++ b/.env.example @@ -15,11 +15,6 @@ AWS_SECRET_ACCESS_KEY=xxx AWS_REGION=xxx AWS_S3_BUCKET_NAME=xxx DISPLAY_SOCIAL_MOBILITY_AWARD=true -PUSHER_SOCKET_HOST=localhost -PUSHER_WS_PORT=8080 -PUSHER_APP_ID=app_id -PUSHER_APP_KEY=app_key -PUSHER_SECRET=secret GOV_UK_NOTIFY_API_KEY=key GOV_UK_NOTIFY_API_TEMPLATE_ID=id SESSION_TIMEOUT=1 diff --git a/Gemfile b/Gemfile index 5fff468a5c..922fcdd39e 100644 --- a/Gemfile +++ b/Gemfile @@ -112,9 +112,6 @@ gem "redis-store", "~> 1.4" # We use it for communicating with api.debounce.io gem "rest-client" -# We are using Pusher with Poxa server for realtime collaborator editing -gem "pusher", "0.15.2" - # Text Search gem "pg_search", "~> 2.3.3" diff --git a/Gemfile.lock b/Gemfile.lock index 588d1845fb..9664bc64a1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -429,11 +429,6 @@ GEM nio4r (~> 2.0) pundit (0.3.0) activesupport (>= 3.0.0) - pusher (0.15.2) - httpclient (~> 2.5) - multi_json (~> 1.0) - pusher-signature (~> 0.1.8) - pusher-signature (0.1.8) raabro (1.4.0) racc (1.8.1) rack (2.2.9) @@ -788,7 +783,6 @@ DEPENDENCIES pry-byebug puma (~> 6.4.3) pundit (~> 0.3) - pusher (= 0.15.2) rack-cors (~> 1.0) rack-mini-profiler (>= 0.10.1) rack-protection (= 3.0.5) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index df5337419b..c00986a81c 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -12,7 +12,6 @@ #= require moment.min #= require core #= require libs/suchi/isOld.js -#= require libs/pusher.min.js #= require mobile #= require browser-check #= require vendor/zxcvbn diff --git a/app/assets/javascripts/libs/pusher.min.js b/app/assets/javascripts/libs/pusher.min.js deleted file mode 100644 index d634d5d687..0000000000 --- a/app/assets/javascripts/libs/pusher.min.js +++ /dev/null @@ -1,9 +0,0 @@ -/*! - * Pusher JavaScript Library v3.1.0 - * http://pusher.com/ - * - * Copyright 2016, Pusher - * Released under the MIT licence. - */ -!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(this,function(){return function(t){function e(i){if(n[i])return n[i].exports;var o=n[i]={exports:{},id:i,loaded:!1};return t[i].call(o.exports,o,o.exports,e),o.loaded=!0,o.exports}var n={};return e.m=t,e.c=n,e.p="",e(0)}([function(t,e,n){"use strict";var i=n(1);t.exports=i["default"]},function(t,e,n){"use strict";function i(t){if(null===t||void 0===t)throw"You must pass your app key when you instantiate Pusher."}var o=n(2),r=n(9),s=n(23),a=n(38),c=n(39),u=n(40),l=n(12),h=n(5),f=n(62),p=n(8),d=n(42),y=function(){function t(e,n){var l=this;i(e),n=n||{},this.key=e,this.config=r.extend(f.getGlobalConfig(),n.cluster?f.getClusterConfig(n.cluster):{},n),this.channels=d["default"].createChannels(),this.global_emitter=new s["default"],this.sessionID=Math.floor(1e9*Math.random()),this.timeline=new a["default"](this.key,this.sessionID,{cluster:this.config.cluster,features:t.getClientFeatures(),params:this.config.timelineParams||{},limit:50,level:c["default"].INFO,version:h["default"].VERSION}),this.config.disableStats||(this.timelineSender=d["default"].createTimelineSender(this.timeline,{host:this.config.statsHost,path:"/timeline/v2/"+o["default"].TimelineTransport.name}));var y=function(t){var e=r.extend({},l.config,t);return u.build(o["default"].getDefaultStrategy(e),e)};this.connection=d["default"].createConnectionManager(this.key,r.extend({getStrategy:y,timeline:this.timeline,activityTimeout:this.config.activity_timeout,pongTimeout:this.config.pong_timeout,unavailableTimeout:this.config.unavailable_timeout},this.config,{encrypted:this.isEncrypted()})),this.connection.bind("connected",function(){l.subscribeAll(),l.timelineSender&&l.timelineSender.send(l.connection.isEncrypted())}),this.connection.bind("message",function(t){var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=l.channel(t.channel);n&&n.handleEvent(t.event,t.data)}e||l.global_emitter.emit(t.event,t.data)}),this.connection.bind("disconnected",function(){l.channels.disconnect()}),this.connection.bind("error",function(t){p["default"].warn("Error",t)}),t.instances.push(this),this.timeline.info({instances:t.instances.length}),t.isReady&&this.connect()}return t.ready=function(){t.isReady=!0;for(var e=0,n=t.instances.length;n>e;e++)t.instances[e].connect()},t.log=function(e){var n=Function("return this")();t.logToConsole&&n.console&&n.console.log&&n.console.log(e)},t.getClientFeatures=function(){return r.keys(r.filterObject({ws:o["default"].Transports.ws},function(t){return t.isSupported({})}))},t.prototype.channel=function(t){return this.channels.find(t)},t.prototype.allChannels=function(){return this.channels.all()},t.prototype.connect=function(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isEncrypted(),e=this.timelineSender;this.timelineSenderTimer=new l.PeriodicTimer(6e4,function(){e.send(t)})}},t.prototype.disconnect=function(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)},t.prototype.bind=function(t,e){return this.global_emitter.bind(t,e),this},t.prototype.bind_all=function(t){return this.global_emitter.bind_all(t),this},t.prototype.subscribeAll=function(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)},t.prototype.subscribe=function(t){var e=this.channels.add(t,this);return"connected"===this.connection.state&&e.subscribe(),e},t.prototype.unsubscribe=function(t){var e=this.channels.remove(t);e&&"connected"===this.connection.state&&e.unsubscribe()},t.prototype.send_event=function(t,e,n){return this.connection.send_event(t,e,n)},t.prototype.isEncrypted=function(){return"https:"===o["default"].getProtocol()?!0:Boolean(this.config.encrypted)},t.instances=[],t.isReady=!1,t.logToConsole=!1,t.Runtime=o["default"],t.ScriptReceivers=o["default"].ScriptReceivers,t.DependenciesReceivers=o["default"].DependenciesReceivers,t.auth_callbacks=o["default"].auth_callbacks,t}();e.__esModule=!0,e["default"]=y,o["default"].setup(y)},function(t,e,n){"use strict";var i=n(3),o=n(7),r=n(14),s=n(15),a=n(16),c=n(4),u=n(17),l=n(18),h=n(25),f=n(26),p=n(27),d=n(28),y={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:c.ScriptReceivers,DependenciesReceivers:i.DependenciesReceivers,getDefaultStrategy:f["default"],Transports:l["default"],transportConnectionInitializer:p["default"],HTTPFactory:d["default"],TimelineTransport:u["default"],getXHRAPI:function(){return window.XMLHttpRequest},getWebSocketAPI:function(){return window.WebSocket||window.MozWebSocket},setup:function(t){var e=this;window.Pusher=t;var n=function(){e.onDocumentBody(t.ready)};window.JSON?n():i.Dependencies.load("json2",{},n)},getDocument:function(){return document},getProtocol:function(){return this.getDocument().location.protocol},getGlobal:function(){return window},getAuthorizers:function(){return{ajax:o["default"],jsonp:r["default"]}},onDocumentBody:function(t){var e=this;document.body?t():setTimeout(function(){e.onDocumentBody(t)},0)},createJSONPRequest:function(t,e){return new a["default"](t,e)},createScriptRequest:function(t){return new s["default"](t)},getLocalStorage:function(){try{return window.localStorage}catch(t){return}},createXHR:function(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest:function(){var t=this.getXHRAPI();return new t},createMicrosoftXHR:function(){return new ActiveXObject("Microsoft.XMLHTTP")},getNetwork:function(){return h.Network},createWebSocket:function(t){var e=this.getWebSocketAPI();return new e(t)},createSocketRequest:function(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported:function(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported:function(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener:function(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener:function(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)}};e.__esModule=!0,e["default"]=y},function(t,e,n){"use strict";var i=n(4),o=n(5),r=n(6);e.DependenciesReceivers=new i.ScriptReceiverFactory("_pusher_dependencies","Pusher.DependenciesReceivers"),e.Dependencies=new r["default"]({cdn_http:o["default"].cdn_http,cdn_https:o["default"].cdn_https,version:o["default"].VERSION,suffix:o["default"].dependency_suffix,receivers:e.DependenciesReceivers})},function(t,e){"use strict";var n=function(){function t(t,e){this.lastId=0,this.prefix=t,this.name=e}return t.prototype.create=function(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",o=!1,r=function(){o||(t.apply(null,arguments),o=!0)};return this[e]=r,{number:e,id:n,name:i,callback:r}},t.prototype.remove=function(t){delete this[t.number]},t}();e.ScriptReceiverFactory=n,e.ScriptReceivers=new n("_pusher_script_","Pusher.ScriptReceivers")},function(t,e){"use strict";var n={VERSION:"3.1.0",PROTOCOL:7,host:"ws.pusherapp.com",ws_port:80,wss_port:443,sockjs_host:"sockjs.pusher.com",sockjs_http_port:80,sockjs_https_port:443,sockjs_path:"/pusher",stats_host:"stats.pusher.com",channel_auth_endpoint:"/pusher/auth",channel_auth_transport:"ajax",activity_timeout:12e4,pong_timeout:3e4,unavailable_timeout:1e4,cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:".min"};e.__esModule=!0,e["default"]=n},function(t,e,n){"use strict";var i=n(4),o=n(2),r=function(){function t(t){this.options=t,this.receivers=t.receivers||i.ScriptReceivers,this.loading={}}return t.prototype.load=function(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=o["default"].createScriptRequest(i.getPath(t,e)),s=i.receivers.create(function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;ai;i++)if(t[i]===e)return i;return-1}function s(t,e){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&e(t[n],n,t)}function a(t){var e=[];return s(t,function(t,n){e.push(n)}),e}function c(t){var e=[];return s(t,function(t){e.push(t)}),e}function u(t,e,n){for(var i=0;ia;a++)s[r.charAt(a)]=a;var u=function(t){var e=t.charCodeAt(0);return 128>e?t:2048>e?o(192|e>>>6)+o(128|63&e):o(224|e>>>12&15)+o(128|e>>>6&63)+o(128|63&e)},l=function(t){return t.replace(/[^\x00-\x7F]/g,u)},h=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0),i=[r.charAt(n>>>18),r.charAt(n>>>12&63),e>=2?"=":r.charAt(n>>>6&63),e>=1?"=":r.charAt(63&n)];return i.join("")},f=i.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,h)}},function(t,e,n){"use strict";var i=n(12),o={getGlobal:function(){return Function("return this")()},now:function(){return Date.now?Date.now():(new Date).valueOf()},defer:function(t){return new i.OneOffTimer(0,t)},method:function(t){for(var e=[],n=1;n0)for(n=0;n0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};e.__esModule=!0,e["default"]=o},function(t,e){"use strict";var n=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},i=function(t){function e(){t.apply(this,arguments)}return n(e,t),e}(Error);e.BadEventName=i;var o=function(t){function e(){t.apply(this,arguments)}return n(e,t),e}(Error);e.RequestTimedOut=o;var r=function(t){function e(){t.apply(this,arguments)}return n(e,t),e}(Error);e.TransportPriorityTooLow=r;var s=function(t){function e(){t.apply(this,arguments)}return n(e,t),e}(Error);e.TransportClosed=s;var a=function(t){function e(){t.apply(this,arguments)}return n(e,t),e}(Error);e.UnsupportedTransport=a;var c=function(t){function e(){t.apply(this,arguments)}return n(e,t),e}(Error);e.UnsupportedStrategy=c},function(t,e,n){"use strict";var i=n(32),o=n(33),r=n(35),s=n(36),a=n(37),c={createStreamingSocket:function(t){return this.createSocket(r["default"],t)},createPollingSocket:function(t){return this.createSocket(s["default"],t)},createSocket:function(t,e){return new o["default"](t,e)},createXHR:function(t,e){return this.createRequest(a["default"],t,e)},createRequest:function(t,e,n){return new i["default"](t,e,n)}};e.__esModule=!0,e["default"]=c},function(t,e,n){"use strict";var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(2),r=n(23),s=262144,a=function(t){function e(e,n,i){t.call(this),this.hooks=e,this.method=n,this.url=i}return i(e,t),e.prototype.start=function(t){var e=this;this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=function(){e.close()},o["default"].addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)},e.prototype.close=function(){this.unloader&&(o["default"].removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)},e.prototype.onChunk=function(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")},e.prototype.advanceBuffer=function(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null},e.prototype.isBufferTooLong=function(t){return this.position===t.length&&t.length>s},e}(r["default"]);e.__esModule=!0,e["default"]=a},function(t,e,n){"use strict";function i(t){var e=/([^\?]*)\/*(\??.*)/.exec(t);return{base:e[1],queryString:e[2]}}function o(t,e){return t.base+"/"+e+"/xhr_send"}function r(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+f++}function s(t,e){var n=/(https?:\/\/)([^\/:]+)((\/|:)?.*)/.exec(t);return n[1]+e+n[3]}function a(t){return Math.floor(Math.random()*t)}function c(t){for(var e=[],n=0;t>n;n++)e.push(a(32).toString(32));return e.join("")}var u=n(34),l=n(11),h=n(2),f=1,p=function(){function t(t,e){this.hooks=t,this.session=a(1e3)+"/"+c(8),this.location=i(e),this.readyState=u["default"].CONNECTING,this.openStream()}return t.prototype.send=function(t){return this.sendRaw(JSON.stringify([t]))},t.prototype.ping=function(){this.hooks.sendHeartbeat(this)},t.prototype.close=function(t,e){this.onClose(t,e,!0)},t.prototype.sendRaw=function(t){if(this.readyState!==u["default"].OPEN)return!1;try{return h["default"].createSocketRequest("POST",r(o(this.location,this.session))).start(t),!0}catch(e){return!1}},t.prototype.reconnect=function(){this.closeStream(),this.openStream()},t.prototype.onClose=function(t,e,n){this.closeStream(),this.readyState=u["default"].CLOSED,this.onclose&&this.onclose({code:t,reason:e,wasClean:n})},t.prototype.onChunk=function(t){if(200===t.status){this.readyState===u["default"].OPEN&&this.onActivity();var e,n=t.data.slice(0,1);switch(n){case"o":e=JSON.parse(t.data.slice(1)||"{}"), -this.onOpen(e);break;case"a":e=JSON.parse(t.data.slice(1)||"[]");for(var i=0;i0&&t.onChunk(n.status,n.responseText);break;case 4:n.responseText&&n.responseText.length>0&&t.onChunk(n.status,n.responseText),t.emit("finished",n.status),t.close()}},n},abortRequest:function(t){t.onreadystatechange=null,t.abort()}};e.__esModule=!0,e["default"]=o},function(t,e,n){"use strict";var i=n(9),o=n(11),r=n(39),s=function(){function t(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}return t.prototype.log=function(t,e){t<=this.options.level&&(this.events.push(i.extend({},e,{timestamp:o["default"].now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())},t.prototype.error=function(t){this.log(r["default"].ERROR,t)},t.prototype.info=function(t){this.log(r["default"].INFO,t)},t.prototype.debug=function(t){this.log(r["default"].DEBUG,t)},t.prototype.isEmpty=function(){return 0===this.events.length},t.prototype.send=function(t,e){var n=this,o=i.extend({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(o,function(t,i){t||n.sent++,e&&e(t,i)}),!0},t.prototype.generateUniqueID=function(){return this.uniqueID++,this.uniqueID},t}();e.__esModule=!0,e["default"]=s},function(t,e){"use strict";var n;!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(n||(n={})),e.__esModule=!0,e["default"]=n},function(t,e,n){"use strict";function i(t){return function(e){return[t.apply(this,arguments),e]}}function o(t){return"string"==typeof t&&":"===t.charAt(0)}function r(t,e){return e[t.slice(1)]}function s(t,e){if(0===t.length)return[[],e];var n=u(t[0],e),i=s(t.slice(1),n[1]);return[[n[0]].concat(i[0]),i[1]]}function a(t,e){if(!o(t))return[t,e];var n=r(t,e);if(void 0===n)throw"Undefined symbol "+t;return[n,e]}function c(t,e){if(o(t[0])){var n=r(t[0],e);if(t.length>1){if("function"!=typeof n)throw"Calling non-function "+t[0];var i=[l.extend({},e)].concat(l.map(t.slice(1),function(t){return u(t,l.extend({},e))[0]}));return n.apply(this,i)}return[n,e]}return s(t,e)}function u(t,e){return"string"==typeof t?a(t,e):"object"==typeof t&&t instanceof Array&&t.length>0?c(t,e):[t,e]}var l=n(9),h=n(11),f=n(41),p=n(30),d=n(55),y=n(56),m=n(57),v=n(58),g=n(59),_=n(60),b=n(61),k=n(2),w=k["default"].Transports;e.build=function(t,e){var n=l.extend({},C,e);return u(t,n)[1].strategy};var S={isSupported:function(){return!1},connect:function(t,e){var n=h["default"].defer(function(){e(new p.UnsupportedStrategy)});return{abort:function(){n.ensureAborted()},forceMinPriority:function(){}}}},C={extend:function(t,e,n){return[l.extend({},e,n),t]},def:function(t,e,n){if(void 0!==t[e])throw"Redefining symbol "+e;return t[e]=n,[void 0,t]},def_transport:function(t,e,n,i,o,r){var s=w[n];if(!s)throw new p.UnsupportedTransport(n);var a,c=!(t.enabledTransports&&-1===l.arrayIndexOf(t.enabledTransports,e)||t.disabledTransports&&-1!==l.arrayIndexOf(t.disabledTransports,e));a=c?new d["default"](e,i,r?r.getAssistant(s):s,l.extend({key:t.key,encrypted:t.encrypted,timeline:t.timeline,ignoreNullOrigin:t.ignoreNullOrigin},o)):S;var u=t.def(t,e,a)[1];return u.Transports=t.Transports||{},u.Transports[e]=a,[void 0,u]},transport_manager:i(function(t,e){return new f["default"](e)}),sequential:i(function(t,e){var n=Array.prototype.slice.call(arguments,2);return new y["default"](n,e)}),cached:i(function(t,e,n){return new v["default"](n,t.Transports,{ttl:e,timeline:t.timeline,encrypted:t.encrypted})}),first_connected:i(function(t,e){return new b["default"](e)}),best_connected_ever:i(function(){var t=Array.prototype.slice.call(arguments,1);return new m["default"](t)}),delayed:i(function(t,e,n){return new g["default"](n,{delay:e})}),"if":i(function(t,e,n,i){return new _["default"](e,n,i)}),is_supported:i(function(t,e){return function(){return e.isSupported()}})}},function(t,e,n){"use strict";var i=n(42),o=function(){function t(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}return t.prototype.getAssistant=function(t){return i["default"].createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})},t.prototype.isAlive=function(){return this.livesLeft>0},t.prototype.reportDeath=function(){this.livesLeft-=1},t}();e.__esModule=!0,e["default"]=o},function(t,e,n){"use strict";var i=n(43),o=n(44),r=n(47),s=n(48),a=n(49),c=n(50),u=n(51),l=n(53),h=n(54),f={createChannels:function(){return new h["default"]},createConnectionManager:function(t,e){return new l["default"](t,e)},createChannel:function(t,e){return new u["default"](t,e)},createPrivateChannel:function(t,e){return new c["default"](t,e)},createPresenceChannel:function(t,e){return new a["default"](t,e)},createTimelineSender:function(t,e){return new s["default"](t,e)},createAuthorizer:function(t,e){return new r["default"](t,e)},createHandshake:function(t,e){return new o["default"](t,e)},createAssistantToTheTransportManager:function(t,e,n){return new i["default"](t,e,n)}};e.__esModule=!0,e["default"]=f},function(t,e,n){"use strict";var i=n(11),o=n(9),r=function(){function t(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}return t.prototype.createConnection=function(t,e,n,r){var s=this;r=o.extend({},r,{activityTimeout:this.pingDelay});var a=this.transport.createConnection(t,e,n,r),c=null,u=function(){a.unbind("open",u),a.bind("closed",l),c=i["default"].now()},l=function(t){if(a.unbind("closed",l),1002===t.code||1003===t.code)s.manager.reportDeath();else if(!t.wasClean&&c){var e=i["default"].now()-c;e<2*s.maxPingDelay&&(s.manager.reportDeath(),s.pingDelay=Math.max(e/2,s.minPingDelay))}};return a.bind("open",u),a},t.prototype.isSupported=function(t){return this.manager.isAlive()&&this.transport.isSupported(t)},t}();e.__esModule=!0,e["default"]=r},function(t,e,n){"use strict";var i=n(9),o=n(45),r=n(46),s=function(){function t(t,e){this.transport=t,this.callback=e,this.bindListeners()}return t.prototype.close=function(){this.unbindListeners(),this.transport.close()},t.prototype.bindListeners=function(){var t=this;this.onMessage=function(e){t.unbindListeners();var n;try{n=o.processHandshake(e)}catch(i){return t.finish("error",{error:i}),void t.transport.close()}"connected"===n.action?t.finish("connected",{connection:new r["default"](n.id,t.transport),activityTimeout:n.activityTimeout}):(t.finish(n.action,{error:n.error}),t.transport.close())},this.onClosed=function(e){t.unbindListeners();var n=o.getCloseAction(e)||"backoff",i=o.getCloseError(e);t.finish(n,{error:i})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)},t.prototype.unbindListeners=function(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)},t.prototype.finish=function(t,e){this.callback(i.extend({transport:this.transport,action:t},e))},t}();e.__esModule=!0,e["default"]=s},function(t,e){"use strict";e.decodeMessage=function(t){try{var e=JSON.parse(t.data);if("string"==typeof e.data)try{e.data=JSON.parse(e.data)}catch(n){if(!(n instanceof SyntaxError))throw n}return e}catch(n){throw{type:"MessageParseError",error:n,data:t.data}}},e.encodeMessage=function(t){return JSON.stringify(t)},e.processHandshake=function(t){if(t=e.decodeMessage(t),"pusher:connection_established"===t.event){if(!t.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:t.data.socket_id,activityTimeout:1e3*t.data.activity_timeout}}if("pusher:error"===t.event)return{action:this.getCloseAction(t.data),error:this.getCloseError(t.data)};throw"Invalid handshake"},e.getCloseAction=function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"ssl_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},e.getCloseError=function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}},function(t,e,n){"use strict";var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(9),r=n(23),s=n(45),a=n(8),c=function(t){function e(e,n){t.call(this),this.id=e,this.transport=n,this.activityTimeout=n.activityTimeout,this.bindListeners()}return i(e,t),e.prototype.handlesActivityChecks=function(){return this.transport.handlesActivityChecks()},e.prototype.send=function(t){return this.transport.send(t)},e.prototype.send_event=function(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),a["default"].debug("Event sent",i),this.send(s.encodeMessage(i))},e.prototype.ping=function(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})},e.prototype.close=function(){this.transport.close()},e.prototype.bindListeners=function(){var t=this,e={message:function(e){var n;try{n=s.decodeMessage(e)}catch(i){t.emit("error",{type:"MessageParseError",error:i,data:e.data})}if(void 0!==n){switch(a["default"].debug("Event recd",n),n.event){case"pusher:error":t.emit("error",{type:"PusherError",data:n.data});break;case"pusher:ping":t.emit("ping");break;case"pusher:pong":t.emit("pong")}t.emit("message",n)}},activity:function(){t.emit("activity")},error:function(e){t.emit("error",{type:"WebSocketError",error:e})},closed:function(e){n(),e&&e.code&&t.handleCloseEvent(e),t.transport=null,t.emit("closed")}},n=function(){o.objectApply(e,function(e,n){t.transport.unbind(n,e)})};o.objectApply(e,function(e,n){t.transport.bind(n,e)})},e.prototype.handleCloseEvent=function(t){var e=s.getCloseAction(t),n=s.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e)},e}(r["default"]);e.__esModule=!0,e["default"]=c},function(t,e,n){"use strict";var i=n(2),o=function(){function t(t,e){this.channel=t;var n=e.authTransport;if("undefined"==typeof i["default"].getAuthorizers()[n])throw"'"+n+"' is not a recognized auth transport";this.type=n,this.options=e,this.authOptions=(e||{}).auth||{}}return t.prototype.composeQuery=function(t){var e="socket_id="+encodeURIComponent(t)+"&channel_name="+encodeURIComponent(this.channel.name);for(var n in this.authOptions.params)e+="&"+encodeURIComponent(n)+"="+encodeURIComponent(this.authOptions.params[n]);return e},t.prototype.authorize=function(e,n){return t.authorizers=t.authorizers||i["default"].getAuthorizers(),t.authorizers[this.type].call(this,i["default"],e,n)},t}();e.__esModule=!0,e["default"]=o},function(t,e,n){"use strict";var i=n(2),o=function(){function t(t,e){this.timeline=t,this.options=e||{}}return t.prototype.send=function(t,e){this.timeline.isEmpty()||this.timeline.send(i["default"].TimelineTransport.getAgent(this,t),e)},t}();e.__esModule=!0,e["default"]=o},function(t,e,n){"use strict";var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(50),r=n(8),s=n(52),a=function(t){function e(e,n){t.call(this,e,n),this.members=new s["default"]}return i(e,t),e.prototype.authorize=function(e,n){var i=this;t.prototype.authorize.call(this,e,function(t,e){if(!t){if(void 0===e.channel_data)return r["default"].warn("Invalid auth response for channel '"+i.name+"', expected 'channel_data' field"),void n("Invalid auth response");var o=JSON.parse(e.channel_data);i.members.setMyID(o.user_id)}n(t,e)})},e.prototype.handleEvent=function(t,e){switch(t){case"pusher_internal:subscription_succeeded":this.members.onSubscription(e),this.subscribed=!0,this.emit("pusher:subscription_succeeded",this.members);break;case"pusher_internal:member_added":var n=this.members.addMember(e);this.emit("pusher:member_added",n);break;case"pusher_internal:member_removed":var i=this.members.removeMember(e);i&&this.emit("pusher:member_removed",i);break;default:o["default"].prototype.handleEvent.call(this,t,e)}},e.prototype.disconnect=function(){this.members.reset(),t.prototype.disconnect.call(this)},e}(o["default"]);e.__esModule=!0,e["default"]=a},function(t,e,n){"use strict";var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(42),r=n(51),s=function(t){function e(){t.apply(this,arguments)}return i(e,t),e.prototype.authorize=function(t,e){var n=o["default"].createAuthorizer(this,this.pusher.config);return n.authorize(t,e)},e}(r["default"]);e.__esModule=!0,e["default"]=s},function(t,e,n){"use strict";var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(23),r=n(30),s=n(8),a=function(t){function e(e,n){t.call(this,function(t,n){s["default"].debug("No callbacks on "+e+" for "+t)}),this.name=e,this.pusher=n,this.subscribed=!1}return i(e,t),e.prototype.authorize=function(t,e){return e(!1,{})},e.prototype.trigger=function(t,e){if(0!==t.indexOf("client-"))throw new r.BadEventName("Event '"+t+"' does not start with 'client-'");return this.pusher.send_event(t,e,this.name)},e.prototype.disconnect=function(){this.subscribed=!1},e.prototype.handleEvent=function(t,e){0===t.indexOf("pusher_internal:")?"pusher_internal:subscription_succeeded"===t&&(this.subscribed=!0,this.emit("pusher:subscription_succeeded",e)):this.emit(t,e)},e.prototype.subscribe=function(){var t=this;this.authorize(this.pusher.connection.socket_id,function(e,n){e?t.handleEvent("pusher:subscription_error",n):t.pusher.send_event("pusher:subscribe",{auth:n.auth,channel_data:n.channel_data,channel:t.name})})},e.prototype.unsubscribe=function(){this.pusher.send_event("pusher:unsubscribe",{channel:this.name})},e}(o["default"]);e.__esModule=!0,e["default"]=a},function(t,e,n){"use strict";var i=n(9),o=function(){function t(){this.reset()}return t.prototype.get=function(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null},t.prototype.each=function(t){var e=this;i.objectApply(this.members,function(n,i){t(e.get(i))})},t.prototype.setMyID=function(t){this.myID=t},t.prototype.onSubscription=function(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)},t.prototype.addMember=function(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)},t.prototype.removeMember=function(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e},t.prototype.reset=function(){this.members={},this.count=0,this.myID=null,this.me=null},t}();e.__esModule=!0,e["default"]=o},function(t,e,n){"use strict";var i=this&&this.__extends||function(t,e){function n(){this.constructor=t}for(var i in e)e.hasOwnProperty(i)&&(t[i]=e[i]);t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)},o=n(23),r=n(12),s=n(8),a=n(9),c=n(2),u=function(t){function e(e,n){var i=this;t.call(this),this.key=e,this.options=n||{},this.state="initialized",this.connection=null,this.encrypted=!!n.encrypted,this.timeline=this.options.timeline,this.connectionCallbacks=this.buildConnectionCallbacks(),this.errorCallbacks=this.buildErrorCallbacks(),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var o=c["default"].getNetwork();o.bind("online",function(){i.timeline.info({netinfo:"online"}),("connecting"===i.state||"unavailable"===i.state)&&i.retryIn(0)}),o.bind("offline",function(){i.timeline.info({netinfo:"offline"}),i.connection&&i.sendActivityCheck()}),this.updateStrategy()}return i(e,t),e.prototype.connect=function(){if(!this.connection&&!this.runner){if(!this.strategy.isSupported())return void this.updateState("failed");this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()}},e.prototype.send=function(t){return this.connection?this.connection.send(t):!1},e.prototype.send_event=function(t,e,n){return this.connection?this.connection.send_event(t,e,n):!1},e.prototype.disconnect=function(){this.disconnectInternally(),this.updateState("disconnected")},e.prototype.isEncrypted=function(){return this.encrypted},e.prototype.startConnecting=function(){var t=this,e=function(n,i){n?t.runner=t.strategy.connect(0,e):"error"===i.action?(t.emit("error",{type:"HandshakeError",error:i.error}),t.timeline.error({handshakeError:i.error})):(t.abortConnecting(),t.handshakeCallbacks[i.action](i))};this.runner=this.strategy.connect(0,e)},e.prototype.abortConnecting=function(){this.runner&&(this.runner.abort(),this.runner=null)},e.prototype.disconnectInternally=function(){if(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection){var t=this.abandonConnection();t.close()}},e.prototype.updateStrategy=function(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,encrypted:this.encrypted})},e.prototype.retryIn=function(t){var e=this;this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new r.OneOffTimer(t||0,function(){e.disconnectInternally(),e.connect()})},e.prototype.clearRetryTimer=function(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)},e.prototype.setUnavailableTimer=function(){var t=this;this.unavailableTimer=new r.OneOffTimer(this.options.unavailableTimeout,function(){t.updateState("unavailable")})},e.prototype.clearUnavailableTimer=function(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()},e.prototype.sendActivityCheck=function(){var t=this;this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new r.OneOffTimer(this.options.pongTimeout,function(){t.timeline.error({pong_timed_out:t.options.pongTimeout}),t.retryIn(0)})},e.prototype.resetActivityCheck=function(){var t=this;this.stopActivityCheck(),this.connection.handlesActivityChecks()||(this.activityTimer=new r.OneOffTimer(this.activityTimeout,function(){t.sendActivityCheck()}))},e.prototype.stopActivityCheck=function(){this.activityTimer&&this.activityTimer.ensureAborted()},e.prototype.buildConnectionCallbacks=function(){var t=this;return{message:function(e){t.resetActivityCheck(),t.emit("message",e)},ping:function(){t.send_event("pusher:pong",{})},activity:function(){t.resetActivityCheck()},error:function(e){t.emit("error",{type:"WebSocketError",error:e})},closed:function(){t.abandonConnection(),t.shouldRetry()&&t.retryIn(1e3)}}},e.prototype.buildHandshakeCallbacks=function(t){var e=this;return a.extend({},t,{connected:function(t){e.activityTimeout=Math.min(e.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),e.clearUnavailableTimer(),e.setConnection(t.connection),e.socket_id=e.connection.id,e.updateState("connected",{socket_id:e.socket_id})}})},e.prototype.buildErrorCallbacks=function(){var t=this,e=function(e){return function(n){n.error&&t.emit("error",{type:"WebSocketError",error:n.error}),e(n)}};return{ssl_only:e(function(){t.encrypted=!0,t.updateStrategy(),t.retryIn(0)}),refused:e(function(){t.disconnect()}),backoff:e(function(){t.retryIn(1e3)}),retry:e(function(){t.retryIn(0)})}},e.prototype.setConnection=function(t){this.connection=t;for(var e in this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()},e.prototype.abandonConnection=function(){if(this.connection){this.stopActivityCheck();for(var t in this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}},e.prototype.updateState=function(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),s["default"].debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}},e.prototype.shouldRetry=function(){return"connecting"===this.state||"connected"===this.state},e}(o["default"]);e.__esModule=!0,e["default"]=u},function(t,e,n){"use strict";function i(t,e){return 0===t.indexOf("private-")?r["default"].createPrivateChannel(t,e):0===t.indexOf("presence-")?r["default"].createPresenceChannel(t,e):r["default"].createChannel(t,e)}var o=n(9),r=n(42),s=function(){function t(){this.channels={}}return t.prototype.add=function(t,e){return this.channels[t]||(this.channels[t]=i(t,e)),this.channels[t]},t.prototype.all=function(){return o.values(this.channels)},t.prototype.find=function(t){return this.channels[t]},t.prototype.remove=function(t){var e=this.channels[t];return delete this.channels[t],e},t.prototype.disconnect=function(){o.objectApply(this.channels,function(t){t.disconnect()})},t}();e.__esModule=!0,e["default"]=s},function(t,e,n){"use strict";function i(t,e){return r["default"].defer(function(){e(t)}),{abort:function(){},forceMinPriority:function(){}}}var o=n(42),r=n(11),s=n(30),a=n(9),c=function(){function t(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}return t.prototype.isSupported=function(){return this.transport.isSupported({encrypted:this.options.encrypted})},t.prototype.connect=function(t,e){var n=this;if(!this.isSupported())return i(new s.UnsupportedStrategy,e);if(this.priority0&&(o=new r.OneOffTimer(n.timeout,function(){s.abort(),i(!0)})),s=t.connect(e,function(t,e){t&&o&&o.isRunning()&&!n.failFast||(o&&o.ensureAborted(),i(t,e))}),{abort:function(){o&&o.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}},t}();e.__esModule=!0,e["default"]=s},function(t,e,n){"use strict";function i(t,e,n){var i=s.map(t,function(t,i,o,r){return t.connect(e,n(i,r))});return{abort:function(){s.apply(i,r)},forceMinPriority:function(t){s.apply(i,function(e){e.forceMinPriority(t)})}}}function o(t){return s.all(t,function(t){return Boolean(t.error)})}function r(t){t.error||t.aborted||(t.abort(),t.aborted=!0)}var s=n(9),a=n(11),c=function(){function t(t){this.strategies=t}return t.prototype.isSupported=function(){return s.any(this.strategies,a["default"].method("isSupported"))},t.prototype.connect=function(t,e){return i(this.strategies,t,function(t,n){return function(i,r){return n[t].error=i,i?void(o(n)&&e(!0)):(s.apply(n,function(t){t.forceMinPriority(r.transport.priority)}),void e(null,r))}})},t}();e.__esModule=!0,e["default"]=c},function(t,e,n){"use strict";function i(t){return"pusherTransport"+(t?"Encrypted":"Unencrypted")}function o(t){var e=c["default"].getLocalStorage();if(e)try{var n=e[i(t)];if(n)return JSON.parse(n)}catch(o){s(t)}return null}function r(t,e,n){var o=c["default"].getLocalStorage();if(o)try{o[i(t)]=JSON.stringify({timestamp:a["default"].now(),transport:e,latency:n})}catch(r){}}function s(t){var e=c["default"].getLocalStorage();if(e)try{delete e[i(t)]}catch(n){}}var a=n(11),c=n(2),u=n(56),l=function(){function t(t,e,n){this.strategy=t,this.transports=e,this.ttl=n.ttl||18e5,this.encrypted=n.encrypted,this.timeline=n.timeline}return t.prototype.isSupported=function(){return this.strategy.isSupported()},t.prototype.connect=function(t,e){var n=this.encrypted,i=o(n),c=[this.strategy];if(i&&i.timestamp+this.ttl>=a["default"].now()){var l=this.transports[i.transport];l&&(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),c.push(new u["default"]([l],{timeout:2*i.latency+1e3,failFast:!0})))}var h=a["default"].now(),f=c.pop().connect(t,function p(i,o){i?(s(n),c.length>0?(h=a["default"].now(),f=c.pop().connect(t,p)):e(i)):(r(n,o.transport.name,a["default"].now()-h),e(null,o))});return{abort:function(){f.abort()},forceMinPriority:function(e){t=e,f&&f.forceMinPriority(e)}}},t}();e.__esModule=!0,e["default"]=l},function(t,e,n){"use strict";var i=n(12),o=function(){function t(t,e){var n=e.delay;this.strategy=t,this.options={delay:n}}return t.prototype.isSupported=function(){return this.strategy.isSupported()},t.prototype.connect=function(t,e){var n,o=this.strategy,r=new i.OneOffTimer(this.options.delay,function(){n=o.connect(t,e)});return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}},t}();e.__esModule=!0,e["default"]=o},function(t,e){"use strict";var n=function(){function t(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}return t.prototype.isSupported=function(){var t=this.test()?this.trueBranch:this.falseBranch;return t.isSupported()},t.prototype.connect=function(t,e){var n=this.test()?this.trueBranch:this.falseBranch;return n.connect(t,e)},t}();e.__esModule=!0,e["default"]=n},function(t,e){"use strict";var n=function(){function t(t){this.strategy=t}return t.prototype.isSupported=function(){return this.strategy.isSupported()},t.prototype.connect=function(t,e){var n=this.strategy.connect(t,function(t,i){i&&n.abort(),e(t,i)});return n},t}();e.__esModule=!0,e["default"]=n},function(t,e,n){"use strict";var i=n(5);e.getGlobalConfig=function(){return{wsHost:i["default"].host,wsPort:i["default"].ws_port,wssPort:i["default"].wss_port,httpHost:i["default"].sockjs_host,httpPort:i["default"].sockjs_http_port,httpsPort:i["default"].sockjs_https_port,httpPath:i["default"].sockjs_path,statsHost:i["default"].stats_host,authEndpoint:i["default"].channel_auth_endpoint,authTransport:i["default"].channel_auth_transport,activity_timeout:i["default"].activity_timeout,pong_timeout:i["default"].pong_timeout,unavailable_timeout:i["default"].unavailable_timeout}},e.getClusterConfig=function(t){return{wsHost:"ws-"+t+".pusher.com",httpHost:"sockjs-"+t+".pusher.com"}}}])}); \ No newline at end of file diff --git a/app/controllers/users/collaborator_access_controller.rb b/app/controllers/users/collaborator_access_controller.rb deleted file mode 100644 index 52e78e4ce8..0000000000 --- a/app/controllers/users/collaborator_access_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -class Users::CollaboratorAccessController < Users::BaseController - # stop rails CSRF protection for pusher authentication - protect_from_forgery except: :auth - - expose(:user_id) do - "#{current_user.id}-time-#{params[:timestamp]}" - end - - expose(:pusher_callback) do - params[:callback] - end - - def auth - response = Pusher[params[:channel_name]].authenticate( - params[:socket_id], { - user_id: user_id, - user_info: { - name: current_user.full_name, - email: current_user.email, - section: params[:section], - joined_at: params[:timestamp], - }, - } - ) - - render( - plain: "#{pusher_callback}(#{response.to_json})", - content_type: "application/javascript", - ) - end -end diff --git a/config/initializers/pusher_init.rb b/config/initializers/pusher_init.rb deleted file mode 100644 index cd99605f6a..0000000000 --- a/config/initializers/pusher_init.rb +++ /dev/null @@ -1,15 +0,0 @@ -# We are using Pusher with Poxa server -# for collaborators application edit stuff -# - -Pusher.host = ENV["PUSHER_SOCKET_HOST"] -Pusher.port = ENV["PUSHER_WS_PORT"].to_i -Pusher.app_id = ENV["PUSHER_APP_ID"] -Pusher.key = ENV["PUSHER_APP_KEY"] -Pusher.secret = ENV["PUSHER_SECRET"] - -if Rails.env.production? || Rails.env.staging? - # Set encrypted on staging and live as they are using HTTPS - - Pusher.encrypted = true -end diff --git a/config/routes.rb b/config/routes.rb index dee13d7daf..e9da0954c6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -106,12 +106,6 @@ namespace :users do resources :form_answers, only: [:show] do - resources :collaborator_access, only: [] do - collection do - get "auth/:section/:timestamp" => "collaborator_access#auth" - end - end - # shortlisted docs block resource :audit_certificate, only: [:show, :create, :destroy] do get "Guide-to-Editing-External-Accountants-Report-Using-Adobe-PDF-Editor", as: :guide, to: "audit_certificates#guide" From 4235e9dd3ac208c0336c231d017c0cedd48f92f0 Mon Sep 17 00:00:00 2001 From: sammo1235 Date: Tue, 10 Dec 2024 12:01:50 +0000 Subject: [PATCH 3/9] Add specs for collaborators and general room channels --- Gemfile | 1 + Gemfile.lock | 3 + spec/channels/collaborators_channel_spec.rb | 138 +++++++++++++++++++- spec/channels/general_room_channel_spec.rb | 81 ++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 spec/channels/general_room_channel_spec.rb diff --git a/Gemfile b/Gemfile index 922fcdd39e..eaa9263a0b 100644 --- a/Gemfile +++ b/Gemfile @@ -174,6 +174,7 @@ group :development, :test do end group :test do + gem "action-cable-testing" gem "factory_bot_rails" gem "capybara", "~> 3.39.0" gem "poltergeist" diff --git a/Gemfile.lock b/Gemfile.lock index 9664bc64a1..dfac496b60 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,8 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (1.1.0) + action-cable-testing (0.6.1) + actioncable (>= 5.0) actioncable (7.0.8.4) actionpack (= 7.0.8.4) activesupport (= 7.0.8.4) @@ -727,6 +729,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + action-cable-testing active_hash amoeba (= 3.0.0) binding_of_caller diff --git a/spec/channels/collaborators_channel_spec.rb b/spec/channels/collaborators_channel_spec.rb index 745809e024..879da9c3cc 100644 --- a/spec/channels/collaborators_channel_spec.rb +++ b/spec/channels/collaborators_channel_spec.rb @@ -1,5 +1,141 @@ require "rails_helper" RSpec.describe CollaboratorsChannel, type: :channel do - pending "add some examples to (or delete) #{__FILE__}" + let(:channel_name) { "presence-chat-development-1-sep-step-consent-due-diligence" } + let(:user) { create(:user) } + let(:user_two) { create(:user) } + let(:tab_one) { "EiIKeLF" } + let(:tab_two) { "isdSJa2" } + let(:current_editor) { "#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR" } + + before do + stub_connection + allow(Collaborators::BroadcastCollabWorker).to receive(:perform_async) + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + end + + describe "#subscribed" do + context "when there are no subscribed users" do + before do + Rails.cache.write(channel_name, "") + end + + it "makes the first joining user an editor" do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + + expect(Rails.cache.read(channel_name)).to eq("#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR") + end + + it "broadcasts the collaborators for the room" do + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) + .with(channel_name, "#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR") + + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + end + end + + context "when there is already an editor for the channel" do + let(:second_user_tab) { "2392j123" } + before do + Rails.cache.write(channel_name, current_editor) + end + + it "adds the second user as a non-editor" do + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => second_user_tab }) + + expect(Rails.cache.read(channel_name)).to eq("#{current_editor}/#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}") + end + + it "broadcasts the collaborators for the room" do + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) + .with(channel_name, "#{current_editor}/#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}") + + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id, "current_tab" => second_user_tab }) + end + end + + context "when the same user opens two tabs" do + it "adds the same user to the cache but as a non-editor" do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_two }) + + expect(Rails.cache.read(channel_name)).to eq("#{current_editor}/#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}") + end + end + end + + describe "#unsubscribed" do + context "when the editor leaves the channel" do + before do + Rails.cache.write(channel_name, "") + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + end + + it "removes the user from the redis cache" do + expect(subscription).to be_confirmed + subscription.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq "" + end + + it "broadcasts the new collaborator list" do + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) + .with(channel_name, "") + + subscription.unsubscribe_from_channel + end + end + + context "when the editor leaves the channel and another user is present" do + before do + Rails.cache.write(channel_name, "") + end + + it "makes the second user the new editor" do + sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => tab_two }) + + expect(Rails.cache.read(channel_name)).to eq("#{current_editor}/#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}") + + sub_one.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq("#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}:EDITOR") + end + end + + context "when a non-editor leaves the channel" do + it "keeps the editor the same as before" do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + sub_two = subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => tab_two }) + + sub_two.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq(current_editor) + end + end + + context "when one user has two tabs open" do + context "and they close the editor tab" do + it "makes the non-editing tab the editor" do + sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_two }) + + sub_one.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq("#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}:EDITOR") + end + end + + context "and they close the non-editor tab" do + it "keeps the editing tab as the editor" do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) + sub_two = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_two }) + + sub_two.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq("#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR") + end + end + end + end end diff --git a/spec/channels/general_room_channel_spec.rb b/spec/channels/general_room_channel_spec.rb new file mode 100644 index 0000000000..5cc4ef4cd9 --- /dev/null +++ b/spec/channels/general_room_channel_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +RSpec.describe GeneralRoomChannel, type: :channel do + let(:channel_name) { "presence-chat-development-1-general" } + let(:user) { create(:user) } + let(:user_two) { create(:user) } + + before do + stub_connection + allow(Collaborators::BroadcastCollabWorker).to receive(:perform_async) + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + end + + describe "#subscribed" do + context "when the room is empty" do + it "adds the subscribing user ID to the redis cache" do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + + expect(Rails.cache.read(channel_name)).to eq(user.id.to_s) + end + + it "broadcasts the new room member" do + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, user.id.to_s) + + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + end + end + + context "when the room already has a member" do + before do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + end + + it "adds the subscribing user ID to the redis cache" do + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) + + expect(Rails.cache.read(channel_name)).to eq("#{user.id}/#{user_two.id}") + end + + it "broadcasts the new room member" do + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, "#{user.id}/#{user_two.id}") + + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) + end + end + end + + describe "#unsubscribed" do + context "when there is one room member" do + it "clears the room" do + sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + + sub_one.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq "" + end + + it "broadcasts the new (empty) room members" do + sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, "") + sub_one.unsubscribe_from_channel + end + end + + context "when there are multiple room members" do + it "removes only the unsubscribing room member" do + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + sub_two = subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) + + expect(Rails.cache.read(channel_name)).to eq("#{user.id}/#{user_two.id}") + + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, user.id.to_s) + + sub_two.unsubscribe_from_channel + + expect(Rails.cache.read(channel_name)).to eq(user.id.to_s) + end + end + end +end From 0adafcafee261fd4b5e2ead49463f12866dea839 Mon Sep 17 00:00:00 2001 From: sammo1235 Date: Wed, 11 Dec 2024 11:00:25 +0000 Subject: [PATCH 4/9] Cleanup --- .../access_manager.js.coffee | 13 ++++--------- .../general_room_tracking.js.coffee | 8 -------- .../javascripts/frontend/form-validation.js.coffee | 2 +- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee index e92cf7e058..b949366d48 100644 --- a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee @@ -1,11 +1,11 @@ window.ApplicationCollaboratorsAccessManager = set_access_mode: () -> - editor_id = ApplicationCollaboratorsAccessManager.current_editor_id() + editor_id = ApplicationCollaboratorsAccessManager.current_editor().id CollaboratorsLog.log("[SET ACCESS MODE] ------------ CURRENT EDITOR IS -------- " + editor_id) previous_editor_id = window.last_editor_id - console.log("previous editor was: " + previous_editor_id) + ApplicationCollaboratorsAccessManager.track_current_editor(editor_id) if ApplicationCollaboratorsAccessManager.i_am_current_editor() @@ -13,7 +13,7 @@ window.ApplicationCollaboratorsAccessManager = ApplicationCollaboratorsEditorBar.hide_collaborators_bar() - if previous_editor_id != undefined && previous_editor_id != ApplicationCollaboratorsAccessManager.current_editor_id() + if previous_editor_id != undefined && previous_editor_id != ApplicationCollaboratorsAccessManager.current_editor().id CollaboratorsLog.log("[NOW IM EDITOR] ---- REFRESHING PAGE") ApplicationCollaboratorsEditorBar.show_loading_bar() @@ -38,17 +38,12 @@ window.ApplicationCollaboratorsAccessManager = ApplicationCollaboratorsEditorBar.render_collaborators_bar() i_am_current_editor: () -> - ApplicationCollaboratorsAccessManager.current_editor_id() == window.user_id && + ApplicationCollaboratorsAccessManager.current_editor().id == window.user_id && window.tab_ident == ApplicationCollaboratorsAccessManager.current_editor().tab_ident im_in_viewer_mode: () -> !ApplicationCollaboratorsAccessManager.i_am_current_editor() - current_editor_id: () -> - editor_id = window.current_channel_members.split("/").find((el) => el.includes("EDITOR")).split(":")[0] - - return editor_id - current_editor: () -> editor = window.current_channel_members.split("/").find((el) => el.includes("EDITOR")).split(":") editor_info = { diff --git a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee index dcec9f70e9..0f9a7411f3 100644 --- a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee @@ -7,18 +7,10 @@ window.ApplicationCollaboratorsGeneralRoomTracking = CollaboratorsLog.log("[INIT GENERAL ROOM] ------------------------ channel_name: " + channel_name) window.App.generalRoom = App.cable.subscriptions.create { channel: "GeneralRoomChannel", channel_name: channel_name, user_id: window.user_id }, - connected: -> - console.log("connected to GENERAL ROOM") - received: (data) -> - console.log("receieved data", data.collaborators) - window.general_room_members = data.collaborators ApplicationCollaboratorsAccessManager.set_access_mode() - disconnected: -> - console.log("disconnected") - there_are_other_collaborators_here: () -> window.general_room_members && window.general_room_members.split('/').length > 1 diff --git a/app/assets/javascripts/frontend/form-validation.js.coffee b/app/assets/javascripts/frontend/form-validation.js.coffee index 5f136ad612..4d2c5e2e29 100644 --- a/app/assets/javascripts/frontend/form-validation.js.coffee +++ b/app/assets/javascripts/frontend/form-validation.js.coffee @@ -890,7 +890,7 @@ window.FormValidation = qRef = question.attr("data-question_ref") qTitle = $.trim(question.find("h2").first().text()) - if typeof console != "undefined" && false + if typeof console != "undefined" console.log "-----------------------------" console.log("[STEP]: " + stepTitle) console.log(" [QUESTION] " + qRef + ": "+ qTitle) From cdd7b0bf9fa032b29562557b86f487948ee313d1 Mon Sep 17 00:00:00 2001 From: sammo1235 Date: Thu, 12 Dec 2024 10:24:19 +0000 Subject: [PATCH 5/9] Store room members in cache as Array --- .../access_manager.js.coffee | 2 +- .../general_room_tracking.js.coffee | 2 +- app/channels/collaborators_channel.rb | 16 +++++----- app/channels/general_room_channel.rb | 14 ++++----- spec/channels/collaborators_channel_spec.rb | 30 +++++++++---------- spec/channels/general_room_channel_spec.rb | 18 +++++------ 6 files changed, 39 insertions(+), 43 deletions(-) diff --git a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee index b949366d48..0763f97ea1 100644 --- a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee @@ -45,7 +45,7 @@ window.ApplicationCollaboratorsAccessManager = !ApplicationCollaboratorsAccessManager.i_am_current_editor() current_editor: () -> - editor = window.current_channel_members.split("/").find((el) => el.includes("EDITOR")).split(":") + editor = window.current_channel_members.find((el) => el.includes("EDITOR")).split(":") editor_info = { id: editor[0], tab_ident: editor[1], diff --git a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee index 0f9a7411f3..5a48a1c21c 100644 --- a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee @@ -13,4 +13,4 @@ window.ApplicationCollaboratorsGeneralRoomTracking = there_are_other_collaborators_here: () -> window.general_room_members && - window.general_room_members.split('/').length > 1 + window.general_room_members.length > 1 diff --git a/app/channels/collaborators_channel.rb b/app/channels/collaborators_channel.rb index dc2c60c0bf..0cce50a53f 100644 --- a/app/channels/collaborators_channel.rb +++ b/app/channels/collaborators_channel.rb @@ -5,12 +5,12 @@ def subscribed user = User.find(params["user_id"]) if collaborators.blank? - collaborators = "#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}:EDITOR" + collaborators = ["#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}:EDITOR"] else - collaborators += "/#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}" + collaborators << "#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}" end - Rails.cache.write(params["channel_name"], collaborators) + Rails.cache.write(params["channel_name"], collaborators, expires_in: 60.minutes) Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], collaborators) end @@ -20,21 +20,19 @@ def unsubscribed new_collaborators = [] collaborators = Rails.cache.read(params["channel_name"]) - collaborators.split("/").each do |collaborator| + collaborators.each do |collaborator| # remove the unsubscribing user new_collaborators << collaborator unless collaborator.split(":")[0] == user_id && collaborator.split(":")[1] == params["current_tab"] end - if !new_collaborators.empty? && new_collaborators.join.exclude?("EDITOR") + if new_collaborators.any? && new_collaborators.join.exclude?("EDITOR") # editor has left the channel, so we update the next in line to be editor temp_collabs = new_collaborators temp_collabs[0] += ":EDITOR" - new_collaborators = temp_collabs.join("/") - else - new_collaborators = new_collaborators.join("/") + new_collaborators = temp_collabs end - Rails.cache.write(params["channel_name"], new_collaborators) + Rails.cache.write(params["channel_name"], new_collaborators, expires_in: 60.minutes) Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], new_collaborators) end diff --git a/app/channels/general_room_channel.rb b/app/channels/general_room_channel.rb index ae5b00f1ab..20784ee9a4 100644 --- a/app/channels/general_room_channel.rb +++ b/app/channels/general_room_channel.rb @@ -3,14 +3,14 @@ def subscribed room_members = Rails.cache.read(params["channel_name"]) if room_members.blank? - room_members = params["user_id"] - elsif room_members.split("/").exclude?(params["user_id"]) - room_members += "/#{params["user_id"]}" + room_members = [params["user_id"]] + elsif room_members.exclude?(params["user_id"]) + room_members << params["user_id"] end stream_from params["channel_name"] - Rails.cache.write(params["channel_name"], room_members) + Rails.cache.write(params["channel_name"], room_members, expires_in: 60.minutes) Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], room_members) end @@ -18,11 +18,9 @@ def subscribed def unsubscribed room_members = Rails.cache.read(params["channel_name"]) - tmp_room_members = room_members.split("/") - tmp_room_members.delete(params["user_id"]) - room_members = tmp_room_members.join("/") + room_members.delete(params["user_id"]) - Rails.cache.write(params["channel_name"], room_members) + Rails.cache.write(params["channel_name"], room_members, expires_in: 60.minutes) Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], room_members) end diff --git a/spec/channels/collaborators_channel_spec.rb b/spec/channels/collaborators_channel_spec.rb index 879da9c3cc..92d515a2db 100644 --- a/spec/channels/collaborators_channel_spec.rb +++ b/spec/channels/collaborators_channel_spec.rb @@ -23,12 +23,12 @@ it "makes the first joining user an editor" do subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) - expect(Rails.cache.read(channel_name)).to eq("#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR") + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR"]) end it "broadcasts the collaborators for the room" do expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) - .with(channel_name, "#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR") + .with(channel_name, ["#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR"]) subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) end @@ -37,18 +37,18 @@ context "when there is already an editor for the channel" do let(:second_user_tab) { "2392j123" } before do - Rails.cache.write(channel_name, current_editor) + Rails.cache.write(channel_name, [current_editor]) end it "adds the second user as a non-editor" do subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => second_user_tab }) - expect(Rails.cache.read(channel_name)).to eq("#{current_editor}/#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}") + expect(Rails.cache.read(channel_name)).to eq([current_editor, "#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}"]) end it "broadcasts the collaborators for the room" do expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) - .with(channel_name, "#{current_editor}/#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}") + .with(channel_name, [current_editor, "#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}"]) subscribe({ "channel_name" => channel_name, "user_id" => user_two.id, "current_tab" => second_user_tab }) end @@ -59,7 +59,7 @@ subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_two }) - expect(Rails.cache.read(channel_name)).to eq("#{current_editor}/#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}") + expect(Rails.cache.read(channel_name)).to eq([current_editor, "#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}"]) end end end @@ -67,7 +67,7 @@ describe "#unsubscribed" do context "when the editor leaves the channel" do before do - Rails.cache.write(channel_name, "") + Rails.cache.write(channel_name, []) subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) end @@ -75,12 +75,12 @@ expect(subscription).to be_confirmed subscription.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq "" + expect(Rails.cache.read(channel_name)).to eq [] end it "broadcasts the new collaborator list" do expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) - .with(channel_name, "") + .with(channel_name, []) subscription.unsubscribe_from_channel end @@ -88,18 +88,18 @@ context "when the editor leaves the channel and another user is present" do before do - Rails.cache.write(channel_name, "") + Rails.cache.write(channel_name, []) end it "makes the second user the new editor" do sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => tab_two }) - expect(Rails.cache.read(channel_name)).to eq("#{current_editor}/#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}") + expect(Rails.cache.read(channel_name)).to eq([current_editor, "#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}"]) sub_one.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq("#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}:EDITOR") + expect(Rails.cache.read(channel_name)).to eq(["#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}:EDITOR"]) end end @@ -110,7 +110,7 @@ sub_two.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq(current_editor) + expect(Rails.cache.read(channel_name)).to eq([current_editor]) end end @@ -122,7 +122,7 @@ sub_one.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq("#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}:EDITOR") + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}:EDITOR"]) end end @@ -133,7 +133,7 @@ sub_two.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq("#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR") + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR"]) end end end diff --git a/spec/channels/general_room_channel_spec.rb b/spec/channels/general_room_channel_spec.rb index 5cc4ef4cd9..fd250ec208 100644 --- a/spec/channels/general_room_channel_spec.rb +++ b/spec/channels/general_room_channel_spec.rb @@ -16,11 +16,11 @@ it "adds the subscribing user ID to the redis cache" do subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) - expect(Rails.cache.read(channel_name)).to eq(user.id.to_s) + expect(Rails.cache.read(channel_name)).to eq([user.id.to_s]) end it "broadcasts the new room member" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, user.id.to_s) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [user.id.to_s]) subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) end @@ -34,11 +34,11 @@ it "adds the subscribing user ID to the redis cache" do subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) - expect(Rails.cache.read(channel_name)).to eq("#{user.id}/#{user_two.id}") + expect(Rails.cache.read(channel_name)).to eq([user.id.to_s, user_two.id.to_s]) end it "broadcasts the new room member" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, "#{user.id}/#{user_two.id}") + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [user.id.to_s, user_two.id.to_s]) subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) end @@ -52,13 +52,13 @@ sub_one.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq "" + expect(Rails.cache.read(channel_name)).to eq [] end it "broadcasts the new (empty) room members" do sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, "") + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, []) sub_one.unsubscribe_from_channel end end @@ -68,13 +68,13 @@ subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) sub_two = subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) - expect(Rails.cache.read(channel_name)).to eq("#{user.id}/#{user_two.id}") + expect(Rails.cache.read(channel_name)).to eq([user.id.to_s, user_two.id.to_s]) - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, user.id.to_s) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [user.id.to_s]) sub_two.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq(user.id.to_s) + expect(Rails.cache.read(channel_name)).to eq([user.id.to_s]) end end end From 96d5a54c569c4180ac15f32030c26d357fcd8ce0 Mon Sep 17 00:00:00 2001 From: phil-l-brockwell Date: Tue, 17 Dec 2024 14:32:46 +0000 Subject: [PATCH 6/9] Prevent deprecation warning --- app/assets/javascripts/channels/index.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/channels/index.coffee b/app/assets/javascripts/channels/index.coffee index c380d1d05a..7647037115 100644 --- a/app/assets/javascripts/channels/index.coffee +++ b/app/assets/javascripts/channels/index.coffee @@ -1,6 +1,6 @@ -#= require action_cable +#= require actioncable #= require_self #= require_tree . @App = {} -App.cable = ActionCable.createConsumer() \ No newline at end of file +App.cable = ActionCable.createConsumer() From 8296f8593e64f3757fde562d1248e10d102e0efe Mon Sep 17 00:00:00 2001 From: phil-l-brockwell Date: Tue, 17 Dec 2024 14:33:47 +0000 Subject: [PATCH 7/9] Hash tab identifiers --- app/controllers/application_controller.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e3a0b3aa28..81c028d7d3 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -95,7 +95,13 @@ def should_enable_js? end def set_session_identifier - cookies["public_tab_ident"] = cookies["_qae_session#{"_development" if Rails.env.development?}"]&.first(8) + return unless session_cookie + + cookies["public_tab_ident"] = Digest::SHA256.digest(session_cookie) + end + + def session_cookie + cookies["_qae_session#{"_development" if Rails.env.development?}"] end protected From a684b44a176ecc1e385bfc36097241b19d1a12dc Mon Sep 17 00:00:00 2001 From: phil-l-brockwell Date: Tue, 17 Dec 2024 14:50:06 +0000 Subject: [PATCH 8/9] Store collaborators as hashes in CollaboratorsChannel --- .../access_manager.js.coffee | 12 ++--- app/channels/collaborators_channel.rb | 42 ++++++---------- spec/channels/collaborators_channel_spec.rb | 49 +++++++++++++------ 3 files changed, 52 insertions(+), 51 deletions(-) diff --git a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee index 0763f97ea1..e830a4c57a 100644 --- a/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/access_manager.js.coffee @@ -1,6 +1,8 @@ window.ApplicationCollaboratorsAccessManager = set_access_mode: () -> + return if !window.current_channel_members + editor_id = ApplicationCollaboratorsAccessManager.current_editor().id CollaboratorsLog.log("[SET ACCESS MODE] ------------ CURRENT EDITOR IS -------- " + editor_id) @@ -45,15 +47,7 @@ window.ApplicationCollaboratorsAccessManager = !ApplicationCollaboratorsAccessManager.i_am_current_editor() current_editor: () -> - editor = window.current_channel_members.find((el) => el.includes("EDITOR")).split(":") - editor_info = { - id: editor[0], - tab_ident: editor[1], - email: editor[2], - name: editor[3] - } - - editor_info + window.current_channel_members[0] track_current_editor: (editor_id) -> window.last_editor_id = editor_id diff --git a/app/channels/collaborators_channel.rb b/app/channels/collaborators_channel.rb index 0cce50a53f..b24e7bc683 100644 --- a/app/channels/collaborators_channel.rb +++ b/app/channels/collaborators_channel.rb @@ -1,39 +1,29 @@ class CollaboratorsChannel < ApplicationCable::Channel def subscribed stream_from params["channel_name"] - collaborators = Rails.cache.read(params["channel_name"]) - user = User.find(params["user_id"]) - - if collaborators.blank? - collaborators = ["#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}:EDITOR"] - else - collaborators << "#{user.id}:#{params["current_tab"]}:#{user.email}:#{user.full_name}" - end - + collaborators = Rails.cache.read(params["channel_name"]) || [] + collaborators << user_hash(params["user_id"]) Rails.cache.write(params["channel_name"], collaborators, expires_in: 60.minutes) - Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], collaborators) end def unsubscribed - user_id = params["user_id"] - new_collaborators = [] - collaborators = Rails.cache.read(params["channel_name"]) - - collaborators.each do |collaborator| - # remove the unsubscribing user - new_collaborators << collaborator unless collaborator.split(":")[0] == user_id && collaborator.split(":")[1] == params["current_tab"] - end + collaborators = Rails.cache.read(params["channel_name"]) || [] + collaborators.reject! { |c| c["id"] == params["user_id"] && c["tab_ident"] == params["current_tab"] } + Rails.cache.write(params["channel_name"], collaborators, expires_in: 60.minutes) + Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], collaborators) + end - if new_collaborators.any? && new_collaborators.join.exclude?("EDITOR") - # editor has left the channel, so we update the next in line to be editor - temp_collabs = new_collaborators - temp_collabs[0] += ":EDITOR" - new_collaborators = temp_collabs - end + private - Rails.cache.write(params["channel_name"], new_collaborators, expires_in: 60.minutes) + def user_hash(user_id) + user = User.find(params["user_id"]) - Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], new_collaborators) + { + "id" => user.id.to_s, + "email" => user.email, + "name" => user.full_name, + "tab_ident" => params["current_tab"], + } end end diff --git a/spec/channels/collaborators_channel_spec.rb b/spec/channels/collaborators_channel_spec.rb index 92d515a2db..247f2c2e54 100644 --- a/spec/channels/collaborators_channel_spec.rb +++ b/spec/channels/collaborators_channel_spec.rb @@ -1,12 +1,12 @@ require "rails_helper" -RSpec.describe CollaboratorsChannel, type: :channel do +describe CollaboratorsChannel, type: :channel do let(:channel_name) { "presence-chat-development-1-sep-step-consent-due-diligence" } let(:user) { create(:user) } let(:user_two) { create(:user) } let(:tab_one) { "EiIKeLF" } let(:tab_two) { "isdSJa2" } - let(:current_editor) { "#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR" } + let(:current_editor) { { email: user.email, id: user.id.to_s, name: user.full_name, tab_ident: tab_one }.stringify_keys } before do stub_connection @@ -17,18 +17,17 @@ describe "#subscribed" do context "when there are no subscribed users" do before do - Rails.cache.write(channel_name, "") + Rails.cache.write(channel_name, []) end it "makes the first joining user an editor" do subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) - expect(Rails.cache.read(channel_name)).to eq(["#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR"]) + expect(Rails.cache.read(channel_name)).to eq([current_editor]) end it "broadcasts the collaborators for the room" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) - .with(channel_name, ["#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR"]) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [current_editor]) subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) end @@ -43,12 +42,19 @@ it "adds the second user as a non-editor" do subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => second_user_tab }) - expect(Rails.cache.read(channel_name)).to eq([current_editor, "#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}"]) + expect(Rails.cache.read(channel_name)).to eq( + [ + current_editor, + { email: user_two.email, id: user_two.id.to_s, name: user_two.full_name, tab_ident: second_user_tab }.stringify_keys, + ], + ) end it "broadcasts the collaborators for the room" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) - .with(channel_name, [current_editor, "#{user_two.id}:#{second_user_tab}:#{user_two.email}:#{user_two.full_name}"]) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with( + channel_name, + [current_editor, { email: user_two.email, id: user_two.id.to_s, name: user_two.full_name, tab_ident: second_user_tab }.stringify_keys], + ) subscribe({ "channel_name" => channel_name, "user_id" => user_two.id, "current_tab" => second_user_tab }) end @@ -59,7 +65,10 @@ subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_two }) - expect(Rails.cache.read(channel_name)).to eq([current_editor, "#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}"]) + expect(Rails.cache.read(channel_name)).to eq([ + current_editor, + { email: user.email, id: user.id.to_s, name: user.full_name, tab_ident: tab_two }.stringify_keys, + ]) end end end @@ -79,8 +88,7 @@ end it "broadcasts the new collaborator list" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async) - .with(channel_name, []) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, []) subscription.unsubscribe_from_channel end @@ -95,11 +103,16 @@ sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => tab_one }) subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => tab_two }) - expect(Rails.cache.read(channel_name)).to eq([current_editor, "#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}"]) + expect(Rails.cache.read(channel_name)).to eq([ + current_editor, + { email: user_two.email, id: user_two.id.to_s, name: user_two.full_name, tab_ident: tab_two }.stringify_keys, + ]) sub_one.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq(["#{user_two.id}:#{tab_two}:#{user_two.email}:#{user_two.full_name}:EDITOR"]) + expect(Rails.cache.read(channel_name)).to eq([ + { email: user_two.email, id: user_two.id.to_s, name: user_two.full_name, tab_ident: tab_two }.stringify_keys, + ]) end end @@ -122,7 +135,9 @@ sub_one.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq(["#{user.id}:#{tab_two}:#{user.email}:#{user.full_name}:EDITOR"]) + expect(Rails.cache.read(channel_name)).to eq([ + { email: user.email, id: user.id.to_s, name: user.full_name, tab_ident: tab_two }.stringify_keys, + ]) end end @@ -133,7 +148,9 @@ sub_two.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq(["#{user.id}:#{tab_one}:#{user.email}:#{user.full_name}:EDITOR"]) + expect(Rails.cache.read(channel_name)).to eq([ + { email: user.email, id: user.id.to_s, name: user.full_name, tab_ident: tab_one }.stringify_keys, + ]) end end end From 391a2ab9eaaf73a2f260a82f1410bf644b687047 Mon Sep 17 00:00:00 2001 From: phil-l-brockwell Date: Tue, 17 Dec 2024 14:52:10 +0000 Subject: [PATCH 9/9] Use tab_ident in GeneralRoomChannel --- .../general_room_tracking.js.coffee | 2 +- app/channels/general_room_channel.rb | 25 +++++------- spec/channels/general_room_channel_spec.rb | 39 +++++++++++-------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee index 5a48a1c21c..0b220942b2 100644 --- a/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee +++ b/app/assets/javascripts/frontend/application_collaborators/general_room_tracking.js.coffee @@ -6,7 +6,7 @@ window.ApplicationCollaboratorsGeneralRoomTracking = CollaboratorsLog.log("[INIT GENERAL ROOM] ------------------------ channel_name: " + channel_name) - window.App.generalRoom = App.cable.subscriptions.create { channel: "GeneralRoomChannel", channel_name: channel_name, user_id: window.user_id }, + window.App.generalRoom = App.cable.subscriptions.create { channel: "GeneralRoomChannel", channel_name: channel_name, user_id: window.user_id, current_tab: window.tab_ident }, received: (data) -> window.general_room_members = data.collaborators ApplicationCollaboratorsAccessManager.set_access_mode() diff --git a/app/channels/general_room_channel.rb b/app/channels/general_room_channel.rb index 20784ee9a4..e3a2bf87bd 100644 --- a/app/channels/general_room_channel.rb +++ b/app/channels/general_room_channel.rb @@ -1,27 +1,22 @@ class GeneralRoomChannel < ApplicationCable::Channel def subscribed - room_members = Rails.cache.read(params["channel_name"]) - - if room_members.blank? - room_members = [params["user_id"]] - elsif room_members.exclude?(params["user_id"]) - room_members << params["user_id"] - end - + room_members = Rails.cache.read(params["channel_name"]) || [] + room_members << user_identifier if room_members.exclude?(user_identifier) stream_from params["channel_name"] - Rails.cache.write(params["channel_name"], room_members, expires_in: 60.minutes) - Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], room_members) end def unsubscribed - room_members = Rails.cache.read(params["channel_name"]) - - room_members.delete(params["user_id"]) - + room_members = Rails.cache.read(params["channel_name"]) || [] + room_members.delete(user_identifier) Rails.cache.write(params["channel_name"], room_members, expires_in: 60.minutes) - Collaborators::BroadcastCollabWorker.perform_async(params["channel_name"], room_members) end + + private + + def user_identifier + "#{params["user_id"]}-#{params["current_tab"]}" + end end diff --git a/spec/channels/general_room_channel_spec.rb b/spec/channels/general_room_channel_spec.rb index fd250ec208..27e6d0bf20 100644 --- a/spec/channels/general_room_channel_spec.rb +++ b/spec/channels/general_room_channel_spec.rb @@ -1,9 +1,10 @@ require "rails_helper" -RSpec.describe GeneralRoomChannel, type: :channel do +describe GeneralRoomChannel, type: :channel do let(:channel_name) { "presence-chat-development-1-general" } let(:user) { create(:user) } let(:user_two) { create(:user) } + let(:current_tab) { "anything" } before do stub_connection @@ -14,33 +15,35 @@ describe "#subscribed" do context "when the room is empty" do it "adds the subscribing user ID to the redis cache" do - subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => current_tab }) - expect(Rails.cache.read(channel_name)).to eq([user.id.to_s]) + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}-#{current_tab}"]) end it "broadcasts the new room member" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [user.id.to_s]) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, ["#{user.id}-#{current_tab}"]) - subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => current_tab }) end end context "when the room already has a member" do before do - subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => current_tab }) end + let(:user_two_tab) { "anything-else" } + it "adds the subscribing user ID to the redis cache" do - subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => user_two_tab }) - expect(Rails.cache.read(channel_name)).to eq([user.id.to_s, user_two.id.to_s]) + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}-#{current_tab}", "#{user_two.id}-#{user_two_tab}"]) end it "broadcasts the new room member" do - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [user.id.to_s, user_two.id.to_s]) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, ["#{user.id}-#{current_tab}", "#{user_two.id}-#{user_two_tab}"]) - subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) + subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => user_two_tab }) end end end @@ -48,7 +51,7 @@ describe "#unsubscribed" do context "when there is one room member" do it "clears the room" do - sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => current_tab }) sub_one.unsubscribe_from_channel @@ -56,7 +59,7 @@ end it "broadcasts the new (empty) room members" do - sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) + sub_one = subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => current_tab }) expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, []) sub_one.unsubscribe_from_channel @@ -64,17 +67,19 @@ end context "when there are multiple room members" do + let(:user_two_tab) { "anything-else" } + it "removes only the unsubscribing room member" do - subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s }) - sub_two = subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s }) + subscribe({ "channel_name" => channel_name, "user_id" => user.id.to_s, "current_tab" => current_tab }) + sub_two = subscribe({ "channel_name" => channel_name, "user_id" => user_two.id.to_s, "current_tab" => user_two_tab }) - expect(Rails.cache.read(channel_name)).to eq([user.id.to_s, user_two.id.to_s]) + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}-#{current_tab}", "#{user_two.id}-#{user_two_tab}"]) - expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, [user.id.to_s]) + expect(Collaborators::BroadcastCollabWorker).to receive(:perform_async).with(channel_name, ["#{user.id}-#{current_tab}"]) sub_two.unsubscribe_from_channel - expect(Rails.cache.read(channel_name)).to eq([user.id.to_s]) + expect(Rails.cache.read(channel_name)).to eq(["#{user.id}-#{current_tab}"]) end end end