mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 07:12:48 +08:00
Add discourse-presence as a core plugin (#5137)
* Add discourse-presence as a core plugin * Default enabled
This commit is contained in:
parent
4d840d10db
commit
c9912fcc37
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||
|
|
14
plugins/discourse-presence/README.md
Normal file
14
plugins/discourse-presence/README.md
Normal 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
|
|
@ -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;
|
||||
}
|
||||
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -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}}
|
|
@ -0,0 +1,3 @@
|
|||
{{#if siteSettings.presence_enabled}}
|
||||
{{composer-presence-display}}
|
||||
{{/if}}
|
45
plugins/discourse-presence/assets/stylesheets/presence.scss
Normal file
45
plugins/discourse-presence/assets/stylesheets/presence.scss
Normal 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;
|
||||
}
|
||||
}
|
9
plugins/discourse-presence/config/locales/client.en.yml
Normal file
9
plugins/discourse-presence/config/locales/client.en.yml
Normal 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"
|
3
plugins/discourse-presence/config/locales/server.en.yml
Normal file
3
plugins/discourse-presence/config/locales/server.en.yml
Normal 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?'
|
4
plugins/discourse-presence/config/settings.yml
Normal file
4
plugins/discourse-presence/config/settings.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
plugins:
|
||||
presence_enabled:
|
||||
default: true
|
||||
client: true
|
148
plugins/discourse-presence/plugin.rb
Normal file
148
plugins/discourse-presence/plugin.rb
Normal 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
|
80
plugins/discourse-presence/spec/presence_controller_spec.rb
Normal file
80
plugins/discourse-presence/spec/presence_controller_spec.rb
Normal 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
|
64
plugins/discourse-presence/spec/presence_manager_spec.rb
Normal file
64
plugins/discourse-presence/spec/presence_manager_spec.rb
Normal 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
|
Loading…
Reference in New Issue
Block a user