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