Add typing notifications front end.

Send typing notification events when user types in the compose box.
Listen for these events and display a notification.

Sending notifications: Notifications are throttled, so that start
notifications are sent every 10 seconds of active typing, and stop
notifications are sent 5 seconds after active typing stops or when the
compose box is closed.

Displaying notifications:
When a typing notification is received, if the current narrow is private
messages or is: pm-with and the user is not the sender,
"Othello is typing..." is displayed underneath the last message. This notification is
removed after 15 seconds. If another notification is received during this period, the
expiration is extended. When a stop notification is received the notification is removed.

Internally, a list of users currently typing is maintained for each
conversation (in a dict). When an event is received the list (for the appropriate
conversation) is updated and the notifications template is re-rendered
based on the narrow information. This template is also re-rendered when
the narrow changes.

Significantly modified by tabbott for clarity.

Fixes #150.
This commit is contained in:
Arpith Siromoney 2016-10-13 00:27:59 +05:30 committed by Tim Abbott
parent 25488b550f
commit e073220e21
10 changed files with 249 additions and 0 deletions

View File

@ -31,6 +31,7 @@
"settings": false,
"resize": false,
"loading": false,
"typing": false,
"compose": false,
"compose_fade": false,
"modals": false,

View File

@ -1023,6 +1023,24 @@ function render(template_name, args) {
});
}());
(function typing_notifications() {
var args = {
users: [{
full_name: 'Hamlet',
email: 'hamlet@zulip.com',
}],
};
var html = '';
html += '<ul>';
html += render('typing_notifications', args);
html += '</ul>';
global.write_handlebars_output('typing_notifications', html);
var li = $(html).find('li:first');
assert.equal(li.text(), 'Hamlet is typing...');
}());
(function user_presence_rows() {
var args = {
users: [

View File

@ -234,6 +234,20 @@ function dispatch_normal_event(event) {
}
break;
case 'typing':
if (event.sender.user_id === page_params.user_id) {
// typing notifications are sent to the user who is typing
// as well as recipients; we ignore such self-generated events.
return;
}
if (event.op === 'start') {
typing.display_notification(event);
} else if (event.op === 'stop') {
typing.hide_notification(event);
}
break;
case 'update_display_settings':
if (event.setting_name === 'twenty_four_hour_time') {
page_params.twenty_four_hour_time = event.setting;

184
static/js/typing.js Normal file
View File

@ -0,0 +1,184 @@
var typing = (function () {
var exports = {};
// How long before we assume a client has gone away
// and expire its typing status
var TYPING_STARTED_EXPIRY_PERIOD = 15000; // 15s
// How frequently 'still typing' notifications are sent
// to extend the expiry
var TYPING_STARTED_SEND_FREQUENCY = 10000; // 10s
// How long after someone stops editing in the compose box
// do we send a 'stopped typing' notification
var TYPING_STOPPED_WAIT_PERIOD = 5000; // 5s
var current_recipient;
var users_currently_typing = new Dict();
var stop_typing_timers = new Dict();
// Our logic is a bit too complex to encapsulate in
// _.throttle/_.debounce (since we need to cancel things), so we do it
// manually.
var stop_timer;
var last_start_time;
function send_typing_notification_ajax(recipients, operation) {
channel.post({
url: '/json/typing',
data: {
to: recipients,
op: operation,
},
success: function () {},
error: function (xhr) {
blueslip.warn("Failed to send typing event: " + xhr.responseText);
},
});
}
function check_and_send(operation) {
var compose_recipient = compose.recipient();
var compose_nonempty = compose.has_message_content();
// If we currently have an active typing notification out, and we
// want to send a stop notice, or the compose recipient changed
// (and implicitly we're sending a start notice), send a stop
// notice to the old recipient.
if (current_recipient !== undefined &&
(operation === 'stop' ||
current_recipient !== compose_recipient)) {
send_typing_notification_ajax(current_recipient, 'stop');
// clear the automatic stop notification timer and recipient.
clearTimeout(stop_timer);
stop_timer = undefined;
current_recipient = undefined;
}
if (operation === 'start') {
if (compose_recipient !== undefined && compose_recipient !== "" && compose_nonempty) {
current_recipient = compose_recipient;
send_typing_notification_ajax(compose_recipient, operation);
}
}
}
// Note: Because we don't make sure we send a final start notification
// at the last time a user typed something, we require that
// TYPING_STARTED_SEND_FREQUENCY + TYPING_STOPPED_WAIT_PERIOD <= TYPING_STARTED_EXPIRY_PERIOD
$(document).on('input', '#new_message_content', function () {
// If our previous state was no typing notification, send a
// start-typing notice immediately.
var current_time = new Date();
if (current_recipient === undefined ||
current_time - last_start_time > TYPING_STARTED_SEND_FREQUENCY) {
last_start_time = current_time;
check_and_send("start");
}
// Then, regardless of whether we changed state, reset the
// stop-notification timeout to TYPING_STOPPED_WAIT_PERIOD from
// now, so that we'll send a stop notice exactly that long after
// stopping typing.
if (stop_timer !== undefined) {
// Clear an existing stop_timer, if any.
clearTimeout(stop_timer);
}
stop_timer = setTimeout(function () {
check_and_send('stop');
}, TYPING_STOPPED_WAIT_PERIOD);
});
// We send a stop-typing notification immediately when compose is
// closed/cancelled
$(document).on('compose_canceled.zulip compose_finished.zulip', function () {
check_and_send('stop');
});
function get_users_typing_for_narrow() {
if (!narrow.narrowed_to_pms()) {
// Narrow is neither pm-with nor is: private
return [];
}
if (narrow.operators()[0].operator === 'pm-with') {
// Get list of users typing in this conversation
var narrow_emails_string = narrow.operators()[0].operand;
var narrow_user_ids_string = people.emails_strings_to_user_ids_string(narrow_emails_string);
var narrow_user_ids = narrow_user_ids_string.split(',').map(function (user_id_string) {
return parseInt(user_id_string, 10);
});
var group = narrow_user_ids.concat([page_params.user_id]);
group.sort();
return users_currently_typing.setdefault(group, []);
}
// Get all users typing (in all private conversations with current user)
var all_typing_users = [];
users_currently_typing.each(function (users_typing) {
all_typing_users = all_typing_users.concat(users_typing);
});
return all_typing_users;
}
function render_notifications_for_narrow() {
var user_ids = get_users_typing_for_narrow();
var users_typing = user_ids.map(people.get_person_from_user_id);
if (users_typing.length === 0) {
$('#typing_notifications').hide();
} else {
$('#typing_notifications').html(templates.render('typing_notifications', {users: users_typing}));
$('#typing_notifications').show();
}
}
$(document).on('narrow_activated.zulip', render_notifications_for_narrow);
$(document).on('narrow_deactivated.zulip', render_notifications_for_narrow);
exports.hide_notification = function (event) {
var recipients = event.recipients.map(function (user) {
return user.user_id;
});
recipients.sort();
// If there's an existing timer for this typing notifications
// thread, clear it.
if (stop_typing_timers[recipients] !== undefined) {
clearTimeout(stop_typing_timers[recipients]);
stop_typing_timers[recipients] = undefined;
}
var users_typing = users_currently_typing.get(recipients);
var i = users_typing.indexOf(event.sender.user_id);
if (i !== -1) {
users_typing.splice(i);
}
render_notifications_for_narrow();
};
exports.display_notification = function (event) {
var recipients = event.recipients.map(function (user) {
return user.user_id;
});
recipients.sort();
event.sender.name = people.get_person_from_user_id(event.sender.user_id).full_name;
var users_typing = users_currently_typing.setdefault(recipients, []);
var i = users_typing.indexOf(event.sender.user_id);
if (i === -1) {
users_typing.push(event.sender.user_id);
}
render_notifications_for_narrow();
// If there's an existing timeout for this typing notifications
// thread, clear it.
if (stop_typing_timers[recipients] !== undefined) {
clearTimeout(stop_typing_timers[recipients]);
}
// Set a time to expire the data if the sender stops transmitting
stop_typing_timers[recipients] = setTimeout(function () {
exports.hide_notification(event);
}, TYPING_STARTED_EXPIRY_PERIOD);
};
return exports;
}());
if (typeof module !== 'undefined') {
module.exports = typing;
}

View File

@ -68,6 +68,10 @@
margin-right: 7px;
}
#typing_notifications {
margin-right: 7px;
}
.nav .dropdown-menu {
min-width: 180px;
-webkit-box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
@ -162,6 +166,11 @@
margin-left: 7px;
}
#typing_notifications {
margin-right: 7px;
margin-left: 7px;
}
#searchbox {
margin-left: 42px;
}

View File

@ -0,0 +1,11 @@
#typing_notifications {
display: none;
margin-left: 10px;
font-style: italic;
color: #888;
}
#typing_notification_list {
list-style: none;
margin: 0;
}

View File

@ -0,0 +1 @@
<li data-email="{{this.email}}" class="typing_notification">{{this.full_name}} is typing...</li>

View File

@ -0,0 +1,6 @@
{{! Typing Notifications }}
<ul id="typing_notification_list">
{{#each users}}
{{partial "typing_notification"}}
{{/each}}
</ul>

View File

@ -84,6 +84,8 @@
</div>
<div class="message_table" id="zfilt">
</div>
<div id="typing_notifications">
</div>
<div id="bottom_whitespace"></div>
</div>

View File

@ -705,6 +705,7 @@ PIPELINE = {
'styles/pygments.css',
'styles/thirdparty-fonts.css',
'styles/media.css',
'styles/typing_notifications.css',
# We don't want fonts.css on QtWebKit, so its omitted here
),
'output_filename': 'min/app-fontcompat.css'
@ -729,6 +730,7 @@ PIPELINE = {
'styles/thirdparty-fonts.css',
'styles/fonts.css',
'styles/media.css',
'styles/typing_notifications.css',
),
'output_filename': 'min/app.css'
},
@ -888,6 +890,7 @@ JS_SPECS = {
'js/custom_markdown.js',
'js/bot_data.js',
'js/reactions.js',
'js/typing.js',
# JS bundled by webpack is also included here if PIPELINE_ENABLED setting is true
],
'output_filename': 'min/app.js'