UX: only publish presence when typing a message

This commit is contained in:
Régis Hanol 2017-12-18 22:00:55 +01:00
parent 2d9d43ed1a
commit a7844de7ee
4 changed files with 109 additions and 208 deletions

View File

@ -1,10 +1,8 @@
import { ajax } from 'discourse/lib/ajax'; import { ajax } from 'discourse/lib/ajax';
import { observes, on } from 'ember-addons/ember-computed-decorators'; import { default as computed, observes, on } from 'ember-addons/ember-computed-decorators';
import computed from 'ember-addons/ember-computed-decorators';
import pageVisible from 'discourse/lib/page-visible';
export const keepAliveDuration = 10000; export const keepAliveDuration = 10000;
const bufferTime = 3000; export const bufferTime = 3000;
export default Ember.Component.extend({ export default Ember.Component.extend({
composer: Ember.inject.controller(), composer: Ember.inject.controller(),
@ -14,174 +12,90 @@ export default Ember.Component.extend({
post: null, post: null,
topic: null, topic: null,
reply: null, reply: null,
title: null,
// Internal variables // Internal variables
oldPresenceState: null, previousState: null,
presenceState: null, currentState: null,
keepAliveTimer: null,
messageBusChannel: null,
presenceUsers: null, presenceUsers: null,
channel: null,
@on('didInsertElement') @on('didInsertElement')
composerOpened() { composerOpened() {
this.updateStateObject(); this._lastPublish = new Date();
Ember.run.once(this, 'updateState');
},
@observes('action', 'post.id', 'topic.id')
composerStateChanged() {
Ember.run.once(this, 'updateState');
},
@observes('reply', 'title')
typing() {
if (new Date() - this._lastPublish > keepAliveDuration) {
this.publish({ current: this.get('currentState') });
}
}, },
@on('willDestroyElement') @on('willDestroyElement')
composerClosing() { composerClosing() {
this.updateStateObject({closing: true}); this.publish({ previous: this.get('currentState') });
Ember.run.cancel(this._pingTimer);
Ember.run.cancel(this._clearTimer);
}, },
@observes('reply', 'title') updateState() {
dataChanged() { let state = null;
if (!this._dataChanged && (new Date() - this._lastPublish) > keepAliveDuration) { const action = this.get('action');
this._dataChanged = true;
this.keepPresenceAlive(); if (action === 'reply' || action === 'edit') {
} else { state = { action };
this._dataChanged = true; if (action === 'reply') state.topic_id = this.get('topic.id');
if (action === 'edit') state.post_id = this.get('post.id');
} }
this.set('previousState', this.get('currentState'));
this.set('currentState', state);
}, },
@observes('action', 'post', 'topic') @observes('currentState')
composerStateChanged(){ currentStateChanged() {
Ember.run.once(this, 'updateStateObject'); if (this.get('channel')) {
}, this.messageBus.unsubscribe(this.get('channel'));
this.set('channel', null);
updateStateObject(opts){
const isClosing = opts && opts.closing;
var stateObject = null;
if(!isClosing && this.shouldSharePresence(this.get('action'))){
stateObject = {};
stateObject.action = this.get('action');
// Add some context if we're editing or replying
switch(stateObject.action){
case 'edit':
stateObject.post_id = this.get('post.id');
break;
case 'reply':
stateObject.topic_id = this.get('topic.id');
break;
default:
break; // createTopic or privateMessage
}
} }
this.set('oldPresenceState', this.get('presenceState')); this.clear();
this.set('presenceState', stateObject);
if (isClosing) {
Ember.run.cancel(this._timeoutTimer);
}
},
_ACTIONS: ['edit', 'reply'],
shouldSharePresence(action){
return this._ACTIONS.includes(action);
},
@observes('presenceState')
presenceStateChanged(){
if(this.get('messageBusChannel')){
this.messageBus.unsubscribe(this.get('messageBusChannel'));
this.set('messageBusChannel', null);
}
this.set('presenceUsers', []);
this.publish({ this.publish({
response_needed: true, response_needed: true,
previous: this.get('oldPresenceState'), previous: this.get('previousState'),
current: this.get('presenceState') current: this.get('currentState')
}).then((data) => { }).then(r => {
const messageBusChannel = data['messagebus_channel']; this.set('presenceUsers', r.users);
if(messageBusChannel){ this.set('channel', r.messagebus_channel);
const users = data['users']; this.messageBus.subscribe(r.messagebus_channel, message => {
const messageBusId = data['messagebus_id']; if (!this.get('isDestroyed')) this.set('presenceUsers', message.users);
this.set('presenceUsers', users); this._clearTimer = Ember.run.debounce(this, 'clear', keepAliveDuration + bufferTime);
this.set('messageBusChannel', messageBusChannel); }, r.messagebus_id);
this.messageBus.subscribe(messageBusChannel, message => {
this.set('presenceUsers', message['users']);
this.timeoutPresence();
}, messageBusId);
}
}).catch((error) => {
// This isn't a critical failure, so don't disturb the user
if (window.console && console.error) {
console.error("Error publishing composer status", error);
}
}); });
Ember.run.cancel(this.get('keepAliveTimer'));
if(this.shouldSharePresence(this.get('presenceState.action'))){
// Send presence data every 10 seconds
this.set('keepAliveTimer', Ember.run.later(this, 'keepPresenceAlive', keepAliveDuration));
}
}, },
timeoutPresence() { clear() {
Ember.run.cancel(this._timeoutTimer); if (!this.get('isDestroyed')) this.set('presenceUsers', []);
this._timeoutTimer = Ember.run.later(
this,
() => { this.set("presenceUsers", []); },
keepAliveDuration + bufferTime
);
}, },
publish(data) { publish(data) {
this._lastPublish = new Date(); this._lastPublish = new Date();
this._dataChanged = false; return ajax('/presence/publish', { type: 'POST', data });
return ajax('/presence/publish', {
type: 'POST',
data: data
});
},
keepPresenceAlive(){
// If we're not replying or editing,
// don't update anything, and don't schedule this task again
if(!this.shouldSharePresence(this.get('presenceState.action'))){
return;
}
if (this._dataChanged) {
this._dataChanged = false;
const browserInFocus = pageVisible();
// Only send the keepalive message if the browser has focus
if(browserInFocus){
this.publish({
current: this.get('presenceState')
}).catch((error) => {
// This isn't a critical failure, so don't disturb the user
if (window.console && console.error) {
console.error("Error publishing composer status", error);
}
});
}
}
// Schedule again in another 10 seconds
Ember.run.cancel(this.get('keepAliveTimer'));
this.set('keepAliveTimer', Ember.run.later(this, 'keepPresenceAlive', keepAliveDuration));
}, },
@computed('presenceUsers', 'currentUser.id') @computed('presenceUsers', 'currentUser.id')
users(presenceUsers, currentUser_id){ users(users, currentUserId) {
return (presenceUsers || []).filter(user => user.id !== currentUser_id); return (users || []).filter(user => user.id !== currentUserId);
}, },
@computed('presenceState.action') isReply: Ember.computed.equal('action', 'reply'),
isReply(action){ shouldDisplay: Ember.computed.gt('users.length', 0)
return action === 'reply';
},
@computed('users.length')
shouldDisplay(length){
return length > 0;
}
}); });

View File

@ -1,68 +1,42 @@
import { on } from 'ember-addons/ember-computed-decorators'; import { default as computed, on } from 'ember-addons/ember-computed-decorators';
import computed from 'ember-addons/ember-computed-decorators'; import { keepAliveDuration, bufferTime } from 'discourse/plugins/discourse-presence/discourse/components/composer-presence-display';
import { keepAliveDuration } from 'discourse/plugins/discourse-presence/discourse/components/composer-presence-display';
const bufferTime = 3000; const MB_GET_LAST_MESSAGE = -2;
export default Ember.Component.extend({ export default Ember.Component.extend({
topicId: null, topicId: null,
messageBusChannel: null,
presenceUsers: null, presenceUsers: null,
clear() {
if (!this.get('isDestroyed')) this.set('presenceUsers', []);
},
@on('didInsertElement') @on('didInsertElement')
_inserted() { _inserted() {
this.set("presenceUsers", []); this.clear();
const messageBusChannel = `/presence/topic/${this.get('topicId')}`;
this.set('messageBusChannel', messageBusChannel);
var firstMessage = true; this.messageBus.subscribe(this.get('channel'), message => {
if (!this.get('isDestroyed')) this.set('presenceUsers', message.users);
this.messageBus.subscribe(messageBusChannel, message => { this._clearTimer = Ember.run.debounce(this, 'clear', keepAliveDuration + bufferTime);
}, MB_GET_LAST_MESSAGE);
let users = message.users;
// account for old messages,
// we only do this once to allow for some bad clocks
if (firstMessage) {
const old = ((new Date()) / 1000) - ((keepAliveDuration / 1000) * 2);
if (message.time && (message.time < old)) {
users = [];
}
firstMessage = false;
}
Em.run.cancel(this._expireTimer);
this.set("presenceUsers", users);
this._expireTimer = Em.run.later(
this,
() => {
this.set("presenceUsers", []);
},
keepAliveDuration + bufferTime
);
}, -2); /* subscribe at position -2 so we get last message */
}, },
@on('willDestroyElement') @on('willDestroyElement')
_destroyed() { _destroyed() {
const channel = this.get("messageBusChannel"); Ember.run.cancel(this._clearTimer);
if (channel) { this.messageBus.unsubscribe(this.get('channel'));
Em.run.cancel(this._expireTimer); },
this.messageBus.unsubscribe(channel);
this.set("messageBusChannel", null); @computed('topicId')
} channel(topicId) {
return `/presence/topic/${topicId}`;
}, },
@computed('presenceUsers', 'currentUser.id') @computed('presenceUsers', 'currentUser.id')
users(presenceUsers, currentUser_id){ users(users, currentUserId) {
return (presenceUsers || []).filter(user => user.id !== currentUser_id); return (users || []).filter(user => user.id !== currentUserId);
}, },
@computed('users.length') shouldDisplay: Ember.computed.gt('users.length', 0)
shouldDisplay(length){
return length > 0;
}
}); });

View File

@ -3,4 +3,5 @@
post=model.post post=model.post
topic=model.topic topic=model.topic
reply=model.reply reply=model.reply
title=model.title
}} }}

View File

@ -20,6 +20,8 @@ after_initialize do
end end
module ::Presence::PresenceManager module ::Presence::PresenceManager
MAX_BACKLOG_AGE ||= 60
def self.get_redis_key(type, id) def self.get_redis_key(type, id)
"presence:#{type}:#{id}" "presence:#{type}:#{id}"
end end
@ -28,24 +30,23 @@ after_initialize do
"/presence/#{type}/#{id}" "/presence/#{type}/#{id}"
end end
def self.add(type, id, user_id)
# return true if a key was added # return true if a key was added
def self.add(type, id, user_id)
key = get_redis_key(type, id) key = get_redis_key(type, id)
result = $redis.hset(key, user_id, Time.zone.now) result = $redis.hset(key, user_id, Time.zone.now)
$redis.expire(key, 60) $redis.expire(key, MAX_BACKLOG_AGE)
result result
end end
# return true if a key was deleted
def self.remove(type, id, user_id) def self.remove(type, id, user_id)
key = get_redis_key(type, id) key = get_redis_key(type, id)
$redis.expire(key, 60) $redis.expire(key, MAX_BACKLOG_AGE)
# return true if a key was deleted
$redis.hdel(key, user_id) > 0 $redis.hdel(key, user_id) > 0
end end
def self.get_users(type, id) def self.get_users(type, id)
user_ids = $redis.hkeys(get_redis_key(type, id)).map(&:to_i) user_ids = $redis.hkeys(get_redis_key(type, id)).map(&:to_i)
# TODO: limit the # of users returned
User.where(id: user_ids) User.where(id: user_ids)
end end
@ -59,9 +60,19 @@ after_initialize do
if topic.archetype == Archetype.private_message if topic.archetype == Archetype.private_message
user_ids = User.where('admin OR moderator').pluck(:id) + topic.allowed_users.pluck(:id) user_ids = User.where('admin OR moderator').pluck(:id) + topic.allowed_users.pluck(:id)
MessageBus.publish(messagebus_channel, message.as_json, user_ids: user_ids, max_backlog_age: 60) MessageBus.publish(
messagebus_channel,
message.as_json,
user_ids: user_ids,
max_backlog_age: MAX_BACKLOG_AGE
)
else else
MessageBus.publish(messagebus_channel, message.as_json, group_ids: topic.secure_group_ids, max_backlog_age: 60) MessageBus.publish(
messagebus_channel,
message.as_json,
group_ids: topic.secure_group_ids,
max_backlog_age: MAX_BACKLOG_AGE
)
end end
users users
@ -89,7 +100,8 @@ after_initialize do
requires_plugin PLUGIN_NAME requires_plugin PLUGIN_NAME
before_action :ensure_logged_in before_action :ensure_logged_in
ACTIONS = %w{edit reply}.each(&:freeze) ACTIONS ||= %w{edit reply}.each(&:freeze)
MAX_USERS ||= 20
def publish def publish
data = params.permit( data = params.permit(
@ -109,9 +121,9 @@ after_initialize do
if topic if topic
guardian.ensure_can_see!(topic) guardian.ensure_can_see!(topic)
_removed = Presence::PresenceManager.remove(type, id, current_user.id) Presence::PresenceManager.remove(type, id, current_user.id)
cleaned = Presence::PresenceManager.cleanup(type, id) Presence::PresenceManager.cleanup(type, id)
users = Presence::PresenceManager.publish(type, id) Presence::PresenceManager.publish(type, id)
end end
end end
@ -124,8 +136,8 @@ after_initialize do
if topic if topic
guardian.ensure_can_see!(topic) guardian.ensure_can_see!(topic)
_added = Presence::PresenceManager.add(type, id, current_user.id) Presence::PresenceManager.add(type, id, current_user.id)
cleaned = Presence::PresenceManager.cleanup(type, id) Presence::PresenceManager.cleanup(type, id)
users = Presence::PresenceManager.publish(type, id) users = Presence::PresenceManager.publish(type, id)
if data[:response_needed] if data[:response_needed]
@ -143,7 +155,7 @@ after_initialize do
{ {
messagebus_channel: channel, messagebus_channel: channel,
messagebus_id: MessageBus.last_id(channel), messagebus_id: MessageBus.last_id(channel),
users: users.map { |u| BasicUserSerializer.new(u, root: false) } users: users.limit(MAX_USERS).map { |u| BasicUserSerializer.new(u, root: false) }
} }
end end