diff --git a/.gitignore b/.gitignore index 67dcad40e52..8a1f8652b9a 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ bootsnap-compile-cache/ !/plugins/discourse-details/ !/plugins/discourse-nginx-performance-report !/plugins/discourse-narrative-bot +!/plugins/discourse-presence /plugins/*/auto_generated/ /spec/fixtures/plugins/my_plugin/auto_generated diff --git a/plugins/discourse-presence/README.md b/plugins/discourse-presence/README.md new file mode 100644 index 00000000000..4e41c6c62ec --- /dev/null +++ b/plugins/discourse-presence/README.md @@ -0,0 +1,14 @@ +# Discourse Presence plugin +This plugin shows which users are currently writing a reply at the same time as you. + +## Installation + +Follow the directions at [Install a Plugin](https://meta.discourse.org/t/install-a-plugin/19157) using https://github.com/discourse/discourse-presence.git as the repository URL. + +## Authors + +André Pereira, David Taylor + +## License + +GNU GPL v2 diff --git a/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 new file mode 100644 index 00000000000..44184dd2c8e --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/components/composer-presence-display.js.es6 @@ -0,0 +1,21 @@ +import computed from 'ember-addons/ember-computed-decorators'; + +export default Ember.Component.extend({ + composer: Ember.inject.controller(), + + @computed('composer.presenceUsers', 'currentUser.id') + users(presenceUsers, currentUser_id){ + return presenceUsers.filter(user => user.id !== currentUser_id); + }, + + @computed('composer.presenceState.action') + isReply(action){ + return action === 'reply'; + }, + + @computed('users.length') + shouldDisplay(length){ + return length > 0; + } + +}); \ No newline at end of file diff --git a/plugins/discourse-presence/assets/javascripts/discourse/initializers/composer-controller-presence.js.es6 b/plugins/discourse-presence/assets/javascripts/discourse/initializers/composer-controller-presence.js.es6 new file mode 100644 index 00000000000..0fcdbbb55bd --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/initializers/composer-controller-presence.js.es6 @@ -0,0 +1,128 @@ +import { ajax } from 'discourse/lib/ajax'; +import { observes} from 'ember-addons/ember-computed-decorators'; +import { withPluginApi } from 'discourse/lib/plugin-api'; +import pageVisible from 'discourse/lib/page-visible'; + +function initialize(api) { + api.modifyClass('controller:composer', { + + oldPresenceState: { compose_state: 'closed' }, + presenceState: { compose_state: 'closed' }, + keepAliveTimer: null, + messageBusChannel: null, + + @observes('model.composeState', 'model.action', 'model.post', 'model.topic') + openStatusChanged(){ + Ember.run.once(this, 'updateStateObject'); + }, + + updateStateObject(){ + const composeState = this.get('model.composeState'); + + const stateObject = { + compose_state: composeState ? composeState : 'closed' + }; + + if(stateObject.compose_state === 'open'){ + stateObject.action = this.get('model.action'); + + // Add some context if we're editing or replying + switch(stateObject.action){ + case 'edit': + stateObject.post_id = this.get('model.post.id'); + break; + case 'reply': + stateObject.topic_id = this.get('model.topic.id'); + break; + default: + break; // createTopic or privateMessage + } + } + + this.set('oldPresenceState', this.get('presenceState')); + this.set('presenceState', stateObject); + }, + + shouldSharePresence(){ + const isOpen = this.get('presenceState.compose_state') !== 'open'; + const isEditing = ['edit','reply'].includes(this.get('presenceState.action')); + return isOpen && isEditing; + }, + + @observes('presenceState') + presenceStateChanged(){ + if(this.get('messageBusChannel')){ + this.messageBus.unsubscribe(this.get('messageBusChannel')); + this.set('messageBusChannel', null); + } + + this.set('presenceUsers', []); + + ajax('/presence/publish/', { + type: 'POST', + data: { + response_needed: true, + previous: this.get('oldPresenceState'), + current: this.get('presenceState') + } + }).then((data) => { + const messageBusChannel = data['messagebus_channel']; + if(messageBusChannel){ + const users = data['users']; + const messageBusId = data['messagebus_id']; + this.set('presenceUsers', users); + this.set('messageBusChannel', messageBusChannel); + this.messageBus.subscribe(messageBusChannel, message => { + this.set('presenceUsers', message['users']); + }, messageBusId); + } + }).catch((error) => { + // This isn't a critical failure, so don't disturb the user + console.error("Error publishing composer status", error); + }); + + + Ember.run.cancel(this.get('keepAliveTimer')); + if(this.shouldSharePresence()){ + // Send presence data every 10 seconds + this.set('keepAliveTimer', Ember.run.later(this, 'keepPresenceAlive', 10000)); + } + }, + + + + keepPresenceAlive(){ + // If the composer isn't open, or we're not editing, + // don't update anything, and don't schedule this task again + if(!this.shouldSharePresence()){ + return; + } + + // Only send the keepalive message if the browser has focus + if(pageVisible()){ + ajax('/presence/publish/', { + type: 'POST', + data: { current: this.get('presenceState') } + }).catch((error) => { + // This isn't a critical failure, so don't disturb the user + console.error("Error publishing composer status", error); + }); + } + + // Schedule again in another 30 seconds + Ember.run.cancel(this.get('keepAliveTimer')); + this.set('keepAliveTimer', Ember.run.later(this, 'keepPresenceAlive', 10000)); + } + + }); +} + +export default { + name: "composer-controller-presence", + after: "message-bus", + + initialize(container) { + const siteSettings = container.lookup('site-settings:main'); + if (siteSettings.presence_enabled) withPluginApi('0.8.9', initialize); + } +}; diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs b/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs new file mode 100644 index 00000000000..1b105bcd817 --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/templates/components/composer-presence-display.hbs @@ -0,0 +1,18 @@ +{{#if shouldDisplay}} +
+ {{#each users as |user|}} + {{avatar user avatarTemplatePath="avatar_template" usernamePath="username" imageSize="small"}} + {{/each}} + + + + {{#if isReply ~}} + {{i18n 'presence.is_replying' count=users.length}} + {{~else~}} + {{i18n 'presence.is_editing' count=users.length}} + {{~/if}}{{!-- (using comment to stop whitespace) + --}}{{!-- + --}}... + +
+{{/if}} \ No newline at end of file diff --git a/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs new file mode 100644 index 00000000000..0f3c2e1b95b --- /dev/null +++ b/plugins/discourse-presence/assets/javascripts/discourse/templates/connectors/composer-fields/presence.hbs @@ -0,0 +1,3 @@ +{{#if siteSettings.presence_enabled}} + {{composer-presence-display}} +{{/if}} diff --git a/plugins/discourse-presence/assets/stylesheets/presence.scss b/plugins/discourse-presence/assets/stylesheets/presence.scss new file mode 100644 index 00000000000..7b2c04c466c --- /dev/null +++ b/plugins/discourse-presence/assets/stylesheets/presence.scss @@ -0,0 +1,45 @@ +.presence-users{ + + background-color: $primary-low; + + color: $primary-medium; + padding: 0px 5px; + position: absolute; + top: 8px; + right: 30px; + + + .wave { + + .dot { + display: inline-block; + animation: wave 1.8s linear infinite; + + &:nth-child(2) { + animation-delay: -1.6s; + } + + &:nth-child(3) { + animation-delay: -1.4s; + } + } + } + + @keyframes wave { + 0%, 60%, 100% { + transform: initial; + } + + 30% { + transform: translateY(-0.2em); + } + } +} + +.mobile-view .presence-users{ + top: 5px; + right: 60px; + .description{ + display:none; + } +} \ No newline at end of file diff --git a/plugins/discourse-presence/config/locales/client.en.yml b/plugins/discourse-presence/config/locales/client.en.yml new file mode 100644 index 00000000000..9de83306d75 --- /dev/null +++ b/plugins/discourse-presence/config/locales/client.en.yml @@ -0,0 +1,9 @@ +en: + js: + presence: + is_replying: + one: "is also replying" + other: "are also replying" + is_editing: + one: "is also editing" + other: "are also editing" \ No newline at end of file diff --git a/plugins/discourse-presence/config/locales/server.en.yml b/plugins/discourse-presence/config/locales/server.en.yml new file mode 100644 index 00000000000..72768d3f25f --- /dev/null +++ b/plugins/discourse-presence/config/locales/server.en.yml @@ -0,0 +1,3 @@ +en: + site_settings: + presence_enabled: 'Show users that are currently replying to the current topic, or editing the current post?' diff --git a/plugins/discourse-presence/config/settings.yml b/plugins/discourse-presence/config/settings.yml new file mode 100644 index 00000000000..0bc0899aa8a --- /dev/null +++ b/plugins/discourse-presence/config/settings.yml @@ -0,0 +1,4 @@ +plugins: + presence_enabled: + default: true + client: true \ No newline at end of file diff --git a/plugins/discourse-presence/plugin.rb b/plugins/discourse-presence/plugin.rb new file mode 100644 index 00000000000..9ee7a498470 --- /dev/null +++ b/plugins/discourse-presence/plugin.rb @@ -0,0 +1,148 @@ +# name: discourse-presence +# about: Show which users are writing a reply to a topic +# version: 1.0 +# authors: André Pereira, David Taylor +# url: https://github.com/discourse/discourse-presence.git + +enabled_site_setting :presence_enabled + +register_asset 'stylesheets/presence.scss' + +PLUGIN_NAME ||= "discourse-presence".freeze + +after_initialize do + + module ::Presence + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace Presence + end + end + + module ::Presence::PresenceManager + def self.get_redis_key(type, id) + "presence:#{type}:#{id}" + end + + def self.get_messagebus_channel(type, id) + "/presence/#{type}/#{id}" + end + + def self.add(type, id, user_id) + redis_key = get_redis_key(type, id) + response = $redis.hset(redis_key, user_id, Time.zone.now) + + response # Will be true if a new key + end + + def self.remove(type, id, user_id) + redis_key = get_redis_key(type, id) + response = $redis.hdel(redis_key, user_id) + + response > 0 # Return true if key was actually deleted + end + + def self.get_users(type, id) + redis_key = get_redis_key(type, id) + user_ids = $redis.hkeys(redis_key).map(&:to_i) + + User.where(id: user_ids) + end + + def self.publish(type, id) + users = get_users(type, id) + serialized_users = users.map { |u| BasicUserSerializer.new(u, root: false) } + message = { + users: serialized_users + } + MessageBus.publish(get_messagebus_channel(type, id), message.as_json) + + users + end + + def self.cleanup(type, id) + hash = $redis.hgetall(get_redis_key(type, id)) + original_hash_size = hash.length + + any_changes = false + + # Delete entries older than 20 seconds + hash.each do |user_id, time| + if Time.zone.now - Time.parse(time) >= 20 + any_changes ||= remove(type, id, user_id) + end + end + + any_changes + end + + end + + require_dependency "application_controller" + + class Presence::PresencesController < ::ApplicationController + before_filter :ensure_logged_in + + def publish + data = params.permit(:response_needed, + current: [:compose_state, :action, :topic_id, :post_id], + previous: [:compose_state, :action, :topic_id, :post_id] + ) + + if data[:previous] && + data[:previous][:compose_state] == 'open' && + data[:previous][:action].in?(['edit', 'reply']) + + type = data[:previous][:post_id] ? 'post' : 'topic' + id = data[:previous][:post_id] ? data[:previous][:post_id] : data[:previous][:topic_id] + + any_changes = false + any_changes ||= Presence::PresenceManager.remove(type, id, current_user.id) + any_changes ||= Presence::PresenceManager.cleanup(type, id) + + users = Presence::PresenceManager.publish(type, id) if any_changes + end + + if data[:current] && + data[:current][:compose_state] == 'open' && + data[:current][:action].in?(['edit', 'reply']) + + type = data[:current][:post_id] ? 'post' : 'topic' + id = data[:current][:post_id] ? data[:current][:post_id] : data[:current][:topic_id] + + any_changes = false + any_changes ||= Presence::PresenceManager.add(type, id, current_user.id) + any_changes ||= Presence::PresenceManager.cleanup(type, id) + + users = Presence::PresenceManager.publish(type, id) if any_changes + + if data[:response_needed] + users ||= Presence::PresenceManager.get_users(type, id) + + serialized_users = users.map { |u| BasicUserSerializer.new(u, root: false) } + + messagebus_channel = Presence::PresenceManager.get_messagebus_channel(type, id) + + render json: { + messagebus_channel: messagebus_channel, + messagebus_id: MessageBus.last_id(messagebus_channel), + users: serialized_users + } + return + end + end + + render json: {} + end + + end + + Presence::Engine.routes.draw do + post '/publish' => 'presences#publish' + end + + Discourse::Application.routes.append do + mount ::Presence::Engine, at: '/presence' + end + +end diff --git a/plugins/discourse-presence/spec/presence_controller_spec.rb b/plugins/discourse-presence/spec/presence_controller_spec.rb new file mode 100644 index 00000000000..c15263714e2 --- /dev/null +++ b/plugins/discourse-presence/spec/presence_controller_spec.rb @@ -0,0 +1,80 @@ +require 'rails_helper' + +describe ::Presence::PresencesController, type: :request do + + before do + SiteSetting.presence_enabled = true + end + + let(:user1) { Fabricate(:user) } + let(:user2) { Fabricate(:user) } + let(:user3) { Fabricate(:user) } + + after(:each) do + $redis.del('presence:post:22') + $redis.del('presence:post:11') + end + + context 'when not logged in' do + it 'should raise the right error' do + expect { post '/presence/publish.json' }.to raise_error(Discourse::NotLoggedIn) + end + end + + context 'when logged in' do + before do + sign_in(user1) + end + + it "doesn't produce an error" do + expect { post '/presence/publish.json' }.not_to raise_error + end + + it "returns a response when requested" do + messages = MessageBus.track_publish do + post '/presence/publish.json', current: { compose_state: 'open', action: 'edit', post_id: 22 }, response_needed: true + end + + expect(messages.count).to eq (1) + + data = JSON.parse(response.body) + + expect(data['messagebus_channel']).to eq('/presence/post/22') + expect(data['messagebus_id']).to eq(MessageBus.last_id('/presence/post/22')) + expect(data['users'][0]["id"]).to eq(user1.id) + end + + it "doesn't return a response when not requested" do + messages = MessageBus.track_publish do + post '/presence/publish.json', current: { compose_state: 'open', action: 'edit', post_id: 22 } + end + + expect(messages.count).to eq (1) + + data = JSON.parse(response.body) + expect(data).to eq({}) + end + + it "doesn't send duplicate messagebus messages" do + messages = MessageBus.track_publish do + post '/presence/publish.json', current: { compose_state: 'open', action: 'edit', post_id: 22 } + end + expect(messages.count).to eq (1) + + messages = MessageBus.track_publish do + post '/presence/publish.json', current: { compose_state: 'open', action: 'edit', post_id: 22 } + end + expect(messages.count).to eq (0) + end + + it "clears 'previous' state when supplied" do + messages = MessageBus.track_publish do + post '/presence/publish.json', current: { compose_state: 'open', action: 'edit', post_id: 22 } + post '/presence/publish.json', current: { compose_state: 'open', action: 'edit', post_id: 11 }, previous: { compose_state: 'open', action: 'edit', post_id: 22 } + end + expect(messages.count).to eq (3) + end + + end + +end diff --git a/plugins/discourse-presence/spec/presence_manager_spec.rb b/plugins/discourse-presence/spec/presence_manager_spec.rb new file mode 100644 index 00000000000..0c98197a474 --- /dev/null +++ b/plugins/discourse-presence/spec/presence_manager_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +describe ::Presence::PresenceManager do + + let(:user1) { Fabricate(:user) } + let(:user2) { Fabricate(:user) } + let(:user3) { Fabricate(:user) } + let(:manager) { ::Presence::PresenceManager } + + after(:each) do + $redis.del('presence:post:22') + $redis.del('presence:post:11') + end + + it 'adds, removes and lists users correctly' do + expect(manager.get_users('post', 22).count).to eq(0) + + expect(manager.add('post', 22, user1.id)).to be true + expect(manager.add('post', 22, user2.id)).to be true + expect(manager.add('post', 11, user3.id)).to be true + + expect(manager.get_users('post', 22).count).to eq(2) + expect(manager.get_users('post', 11).count).to eq(1) + + expect(manager.get_users('post', 22)).to contain_exactly(user1, user2) + expect(manager.get_users('post', 11)).to contain_exactly(user3) + + expect(manager.remove('post', 22, user1.id)).to be true + expect(manager.get_users('post', 22).count).to eq(1) + expect(manager.get_users('post', 22)).to contain_exactly(user2) + end + + it 'publishes correctly' do + expect(manager.get_users('post', 22).count).to eq(0) + + manager.add('post', 22, user1.id) + manager.add('post', 22, user2.id) + + messages = MessageBus.track_publish do + manager.publish('post', 22) + end + + expect(messages.count).to eq (1) + message = messages.first + + expect(message.channel).to eq('/presence/post/22') + + expect(message.data["users"].map { |u| u[:id] }).to contain_exactly(user1.id, user2.id) + end + + it 'cleans up correctly' do + freeze_time Time.zone.now do + expect(manager.add('post', 22, user1.id)).to be true + expect(manager.cleanup('post', 22)).to be false # Nothing to cleanup + expect(manager.get_users('post', 22).count).to eq(1) + end + + # Anything older than 20 seconds should be cleaned up + freeze_time 30.seconds.from_now do + expect(manager.cleanup('post', 22)).to be true + expect(manager.get_users('post', 22).count).to eq(0) + end + end +end