From e073220e21da7671462b1868c8180181931ff5b0 Mon Sep 17 00:00:00 2001 From: Arpith Siromoney Date: Thu, 13 Oct 2016 00:27:59 +0530 Subject: [PATCH] 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. --- .eslintrc.json | 1 + frontend_tests/node_tests/templates.js | 18 ++ static/js/server_events.js | 14 ++ static/js/typing.js | 184 ++++++++++++++++++ static/styles/media.css | 9 + static/styles/typing_notifications.css | 11 ++ .../templates/typing_notification.handlebars | 1 + .../templates/typing_notifications.handlebars | 6 + templates/zerver/home.html | 2 + zproject/settings.py | 3 + 10 files changed, 249 insertions(+) create mode 100644 static/js/typing.js create mode 100644 static/styles/typing_notifications.css create mode 100644 static/templates/typing_notification.handlebars create mode 100644 static/templates/typing_notifications.handlebars 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'