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 += '
';
+ html += render('typing_notifications', args);
+ 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 }}
+
+ {{#each users}}
+ {{partial "typing_notification"}}
+ {{/each}}
+
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'