diff --git a/app/channels/awareness_channel.rb b/app/channels/awareness_channel.rb new file mode 100644 index 0000000000000000000000000000000000000000..554e057ca83d3bcb80121b046f9a499c5c7db47c --- /dev/null +++ b/app/channels/awareness_channel.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class AwarenessChannel < ApplicationCable::Channel # rubocop:disable Gitlab/NamespacedClass + REFRESH_INTERVAL = ENV.fetch("GITLAB_AWARENESS_REFRESH_INTERVAL_SEC", 60) + private_constant :REFRESH_INTERVAL + + # Produces a refresh interval value, based of the + # GITLAB_AWARENESS_REFRESH_INTERVAL_SEC environment variable or the given + # default. Makes sure, that the interval after a jitter is applied, is never + # less than half the predefined interval. + def self.refresh_interval(range: -10..10) + min = REFRESH_INTERVAL / 2.to_f + [min.to_i, REFRESH_INTERVAL.to_i + rand(range)].max.seconds + end + private_class_method :refresh_interval + + # keep clients updated about session membership + periodically every: self.refresh_interval do + transmit payload + end + + def subscribed + reject unless valid_subscription? + return if subscription_rejected? + + stream_for session, coder: ActiveSupport::JSON + + session.join(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + def unsubscribed + return if subscription_rejected? + + session.leave(current_user) + AwarenessChannel.broadcast_to(session, payload) + end + + # Allows a client to let the server know they are still around. This is not + # like a heartbeat mechanism. This can be triggered by any action that results + # in a meaningful "presence" update. Like scrolling the screen (debounce), + # window becoming active, user starting to type in a text field, etc. + def touch + session.touch!(current_user) + + transmit payload + end + + private + + def valid_subscription? + current_user.present? && path.present? + end + + def payload + { collaborators: collaborators } + end + + def collaborators + session.online_users_with_last_activity.map do |user, last_activity| + collaborator(user, last_activity) + end + end + + def collaborator(user, last_activity) + { + id: user.id, + name: user.name, + avatar_url: user.avatar_url(size: 36), + last_activity: last_activity, + last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( + Time.zone.now, last_activity + ) + } + end + + def session + @session ||= AwarenessSession.for(path) + end + + def path + params[:path] + end +end diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb index 602888182b18f0fe3fcbf44086b6e7d119c16769..a84a3454a275d344dc58a5a8c277a2e60fa011f2 100644 --- a/app/models/awareness_session.rb +++ b/app/models/awareness_session.rb @@ -143,17 +143,34 @@ def size end end + def to_param + id&.to_s + end + + def to_s + "awareness_session=#{id}" + end + + def online_users_with_last_activity(threshold: PRESENCE_LIFETIME) + users_with_last_activity.filter do |_user, last_activity| + user_online?(last_activity, threshold: threshold) + end + end + def users User.where(id: user_ids) end def users_with_last_activity - # where in (x, y, [...z]) is a set and does not maintain any order, we need to - # make sure to establish a stable order for both, the pairs returned from + # where in (x, y, [...z]) is a set and does not maintain any order, we need + # to make sure to establish a stable order for both, the pairs returned from # redis and the ActiveRecord query. Using IDs in ascending order. user_ids, last_activities = user_ids_with_last_activity .sort_by(&:first) .transpose + + return [] if user_ids.blank? + users = User.where(id: user_ids).order(id: :asc) users.zip(last_activities) end @@ -162,6 +179,10 @@ def users_with_last_activity attr_reader :id + def user_online?(last_activity, threshold:) + last_activity.to_i + threshold.to_i > Time.zone.now.to_i + end + # converts session id from hex to integer representation def id_i Integer(id, 16) if id.present? diff --git a/spec/channels/awareness_channel_spec.rb b/spec/channels/awareness_channel_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d6dc36f6bd7c0c9a156bb77b0dee19bbe2958f0 --- /dev/null +++ b/spec/channels/awareness_channel_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe AwarenessChannel, :clean_gitlab_redis_shared_state, type: :channel do + before do + stub_action_cable_connection(current_user: user) + end + + context "with user" do + let(:user) { create(:user) } + + describe "when no path parameter given" do + it "rejects subscription" do + subscribe path: nil + + expect(subscription).to be_rejected + end + end + + describe "with valid path parameter" do + it "successfully subscribes" do + subscribe path: "/test" + + session = AwarenessSession.for("/test") + + expect(subscription).to be_confirmed + # check if we can use session object instead + expect(subscription).to have_stream_from("awareness:#{session.to_param}") + end + + it "broadcasts set of collaborators when subscribing" do + session = AwarenessSession.for("/test") + + freeze_time do + collaborator = { + id: user.id, + name: user.name, + avatar_url: user.avatar_url(size: 36), + last_activity: Time.zone.now, + last_activity_humanized: ActionController::Base.helpers.distance_of_time_in_words( + Time.zone.now, Time.zone.now + ) + } + + expect do + subscribe path: "/test" + end.to have_broadcasted_to("awareness:#{session.to_param}") + .with(collaborators: [collaborator]) + end + end + + it "transmits payload when user is touched" do + subscribe path: "/test" + + perform :touch + + expect(transmissions.size).to be 1 + end + + it "unsubscribes from channel" do + subscribe path: "/test" + session = AwarenessSession.for("/test") + + expect { subscription.unsubscribe_from_channel } + .to change { session.size}.by(-1) + end + end + end + + context "with guest" do + let(:user) { nil } + + it "rejects subscription" do + subscribe path: "/test" + + expect(subscription).to be_rejected + end + end +end diff --git a/spec/models/awareness_session_spec.rb b/spec/models/awareness_session_spec.rb index 4dace7cb8de9c9384235c83a0653525f00f11efd..854ce5957f7c97c22ac48cb761d439f40af5814d 100644 --- a/spec/models/awareness_session_spec.rb +++ b/spec/models/awareness_session_spec.rb @@ -2,14 +2,24 @@ require 'spec_helper' -RSpec.describe AwarenessSession do +RSpec.describe AwarenessSession, :clean_gitlab_redis_shared_state do subject { AwarenessSession.for(session_id) } let!(:user) { create(:user) } let(:session_id) { 1 } - after do - redis_shared_state_cleanup! + describe "when initiating a session" do + it "provides a string representation of the model instance" do + expected = "awareness_session=6b86b273ff34fce" + + expect(subject.to_s).to eql(expected) + end + + it "provides a parameterized version of the session identifier" do + expected = "6b86b273ff34fce" + + expect(subject.to_param).to eql(expected) + end end describe "when a user joins a session" do @@ -103,6 +113,26 @@ expect(ttl_user).to be > 0 end end + + it "fetches user(s) from database" do + subject.join(user) + + expect(subject.users.first).to eql(user) + end + + it "fetches and filters online user(s) from database" do + subject.join(user) + + travel 2.hours do + subject.join(user2) + + online_users = subject.online_users_with_last_activity + online_user, _ = online_users.first + + expect(online_users.size).to be 1 + expect(online_user).to eql(user2) + end + end end describe "when a user leaves a session" do diff --git a/spec/models/concerns/awareness_spec.rb b/spec/models/concerns/awareness_spec.rb index 9119fe2c458eead0966eea3db176a6d03e8f2999..67acacc7bb1fafdf767a1b9779dfb2aa87ed5603 100644 --- a/spec/models/concerns/awareness_spec.rb +++ b/spec/models/concerns/awareness_spec.rb @@ -2,15 +2,11 @@ require 'spec_helper' -RSpec.describe Awareness do +RSpec.describe Awareness, :clean_gitlab_redis_shared_state do subject { create(:user) } let(:session) { AwarenessSession.for(1) } - after do - redis_shared_state_cleanup! - end - describe "when joining a session" do it "increases the number of sessions" do expect { subject.join(session) }