Add discourse-presence as a core plugin (#5137)

* Add discourse-presence as a core plugin

* Default enabled
This commit is contained in:
David Taylor 2017-09-07 08:40:18 +01:00 committed by Régis Hanol
parent 4d840d10db
commit c9912fcc37
13 changed files with 538 additions and 0 deletions

1
.gitignore vendored
View File

@ -54,6 +54,7 @@ bootsnap-compile-cache/
!/plugins/discourse-details/ !/plugins/discourse-details/
!/plugins/discourse-nginx-performance-report !/plugins/discourse-nginx-performance-report
!/plugins/discourse-narrative-bot !/plugins/discourse-narrative-bot
!/plugins/discourse-presence
/plugins/*/auto_generated/ /plugins/*/auto_generated/
/spec/fixtures/plugins/my_plugin/auto_generated /spec/fixtures/plugins/my_plugin/auto_generated

View File

@ -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

View File

@ -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;
}
});

View File

@ -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);
}
};

View File

@ -0,0 +1,18 @@
{{#if shouldDisplay}}
<div class="presence-users">
{{#each users as |user|}}
{{avatar user avatarTemplatePath="avatar_template" usernamePath="username" imageSize="small"}}
{{/each}}
<span class="presence_text">
<span class="description">
{{#if isReply ~}}
{{i18n 'presence.is_replying' count=users.length}}
{{~else~}}
{{i18n 'presence.is_editing' count=users.length}}
{{~/if}}</span>{{!-- (using comment to stop whitespace)
--}}</span>{{!--
--}}<span class="wave"><span class="dot">.</span><span class="dot">.</span><span class="dot">.</span>
</span>
</div>
{{/if}}

View File

@ -0,0 +1,3 @@
{{#if siteSettings.presence_enabled}}
{{composer-presence-display}}
{{/if}}

View File

@ -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;
}
}

View File

@ -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"

View File

@ -0,0 +1,3 @@
en:
site_settings:
presence_enabled: 'Show users that are currently replying to the current topic, or editing the current post?'

View File

@ -0,0 +1,4 @@
plugins:
presence_enabled:
default: true
client: true

View File

@ -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

View File

@ -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

View File

@ -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