diff --git a/.eslintrc.json b/.eslintrc.json index 0ad7365c35..0a80b5785c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -31,6 +31,7 @@ "settings": false, "resize": false, "loading": false, + "typing": false, "compose": false, "compose_fade": false, "modals": false, diff --git a/frontend_tests/node_tests/templates.js b/frontend_tests/node_tests/templates.js index b3a72e951f..3af5ccf85b 100644 --- a/frontend_tests/node_tests/templates.js +++ b/frontend_tests/node_tests/templates.js @@ -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 += ''; + + 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: [ diff --git a/static/js/server_events.js b/static/js/server_events.js index e1a02f56b7..a516e84f84 100644 --- a/static/js/server_events.js +++ b/static/js/server_events.js @@ -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; diff --git a/static/js/typing.js b/static/js/typing.js new file mode 100644 index 0000000000..fcae1c0df9 --- /dev/null +++ b/static/js/typing.js @@ -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; +} diff --git a/static/styles/media.css b/static/styles/media.css index 39af227d21..de845f9e27 100644 --- a/static/styles/media.css +++ b/static/styles/media.css @@ -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; } diff --git a/static/styles/typing_notifications.css b/static/styles/typing_notifications.css new file mode 100644 index 0000000000..42a22f458f --- /dev/null +++ b/static/styles/typing_notifications.css @@ -0,0 +1,11 @@ +#typing_notifications { + display: none; + margin-left: 10px; + font-style: italic; + color: #888; +} + +#typing_notification_list { + list-style: none; + margin: 0; +} diff --git a/static/templates/typing_notification.handlebars b/static/templates/typing_notification.handlebars new file mode 100644 index 0000000000..682af9e0f5 --- /dev/null +++ b/static/templates/typing_notification.handlebars @@ -0,0 +1 @@ +
  • {{this.full_name}} is typing...
  • diff --git a/static/templates/typing_notifications.handlebars b/static/templates/typing_notifications.handlebars new file mode 100644 index 0000000000..516780310d --- /dev/null +++ b/static/templates/typing_notifications.handlebars @@ -0,0 +1,6 @@ +{{! Typing Notifications }} + diff --git a/templates/zerver/home.html b/templates/zerver/home.html index eeddb2b59e..72fc22bead 100644 --- a/templates/zerver/home.html +++ b/templates/zerver/home.html @@ -84,6 +84,8 @@
    +
    +
    diff --git a/zproject/settings.py b/zproject/settings.py index fe144a915b..c74d6988e7 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -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'