compose: Add compose typeahead for stream+topic mentions.

We implement 3 changes:

1. Partial Stream Typeahead

   In addition to regular stream completion, we do partial completion
   of stream typeahead on pressing '>'. We use our custom addition to
   typeahead.js: this.trigger_selection to start topic_list typeahead.

   Implements: `#stream na|` (press >) => `#**stream name>|`.

2. Topic Jump Typeahead

   'topic_jump' typeahead moves the cursor from just ahead of a
   completed stream-mention to just after the end of the mention
   text and is triggered by typing '>' after the stream mention.
   This typeahead merely uses the regex matching and event hooks of
   the typeahead library instead of displaying any text completions.

   Implements: `#**stream name** >|` => `#**stream name>|`.

3. Topic List Typeahead

   'topic_list' typeahead shows the list of recent topics of a stream
   and if your current text doesn't match one of them, also shows you
   the current query text, allowing you to create mentions for topics
   that do not exist yet.

   Implements: `#**stream name>someth|` => `#**stream name>something** |`.

At the end of this commit, we support the following mechanisms to
complete the stream-topic mention:

1. Type "#denmar|".
2. Press Enter to get "#**Denmark** |".
3. Press > to get "#**Denmark>|".
4. Type topic name and press enter.

OR

1. Type "#denmar|".
2. Type > to get "#**Denmark>|".
3. Type topic name and press enter.

Both result in the final inserted syntax: "#**Denmark>topic name**".

Documentation is still pending.

Fixes #4836.
This commit is contained in:
Rohitt Vashishtha 2019-06-25 18:21:47 +05:30 committed by Tim Abbott
parent 83cbd62ba1
commit 5fc37c5f9b
2 changed files with 137 additions and 2 deletions

View File

@ -1109,6 +1109,7 @@ run_test('begins_typeahead', () => {
slash: true, slash: true,
stream: true, stream: true,
syntax: true, syntax: true,
topic: true,
}}}; }}};
function get_values(input, rest) { function get_values(input, rest) {
@ -1282,6 +1283,18 @@ run_test('begins_typeahead', () => {
assert_typeahead_equals("test\n~~~ p", lang_list); assert_typeahead_equals("test\n~~~ p", lang_list);
assert_typeahead_equals("test\n~~~ p", lang_list); assert_typeahead_equals("test\n~~~ p", lang_list);
// topic_jump
assert_typeahead_equals("@**a person**>", false);
assert_typeahead_equals("@**a person** >", false);
assert_typeahead_equals("#**stream**>", ['']); // this is deliberately a blank choice.
assert_typeahead_equals("#**stream** >", ['']);
// topic_list
var sweden_topics_to_show = topic_data.get_recent_names(1); //includes "more ice"
assert_typeahead_equals("#**Sweden>more ice", sweden_topics_to_show);
sweden_topics_to_show.push('totally new topic');
assert_typeahead_equals("#**Sweden>totally new topic", sweden_topics_to_show);
// Following tests place the cursor before the second string // Following tests place the cursor before the second string
assert_typeahead_equals("#test", "ing", false); assert_typeahead_equals("#test", "ing", false);
assert_typeahead_equals("@test", "ing", false); assert_typeahead_equals("@test", "ing", false);

View File

@ -136,6 +136,14 @@ function query_matches_emoji(query, emoji) {
return query_matches_source_attrs(query, emoji, ["emoji_name"], "_"); return query_matches_source_attrs(query, emoji, ["emoji_name"], "_");
} }
function query_matches_topic(query, topic) {
var obj = {
topic: topic,
};
query = query.toLowerCase();
return query_matches_source_attrs(query, obj, ['topic'], ' ');
}
// nextFocus is set on a keydown event to indicate where we should focus on keyup. // nextFocus is set on a keydown event to indicate where we should focus on keyup.
// We can't focus at the time of keydown because we need to wait for typeahead. // We can't focus at the time of keydown because we need to wait for typeahead.
// And we can't compute where to focus at the time of keyup because only the keydown // And we can't compute where to focus at the time of keyup because only the keydown
@ -349,6 +357,20 @@ exports.tokenize_compose_str = function (s) {
} else if (/[\s(){}\[\]]/.test(s[i - 1])) { } else if (/[\s(){}\[\]]/.test(s[i - 1])) {
return s.slice(i); return s.slice(i);
} }
break;
case '>':
// topic_jump
//
// If you hit `>` immediately after completing the typeahead for mentioning a stream,
// this will reposition the user from. If | is the cursor, implements:
//
// `#**stream name** >|` => `#**stream name>|`.
if (s.substring(i - 2, i) === '**' || s.substring(i - 3, i) === '** ') {
// return any string as long as its not ''.
return '>topic_jump';
}
// maybe topic_list; let's let the stream_topic_regex decide later.
return '>topic_list';
} }
} }
@ -395,6 +417,21 @@ function filter_mention_name(current_token) {
return current_token; return current_token;
} }
function should_show_custom_query(query, items) {
// returns true if the custom query doesn't match one of the
// choices in the items list.
if (!query) {
return false;
}
var matched = _.reduce(items, function (matched, elem) {
if (elem.toLowerCase() === query.toLowerCase()) {
return true;
}
return matched;
}, false);
return !matched;
}
exports.slash_commands = [ exports.slash_commands = [
{ {
text: i18n.t("/me is excited (Display action text)"), text: i18n.t("/me is excited (Display action text)"),
@ -518,6 +555,36 @@ exports.compose_content_begins_typeahead = function (query) {
this.token = current_token; this.token = current_token;
return stream_data.get_unsorted_subs(); return stream_data.get_unsorted_subs();
} }
if (this.options.completions.topic) {
// Stream regex modified from marked.js
// Matches '#**stream name** >' at the end of a split.
var stream_regex = /#\*\*([^\*]+)\*\*\s?>$/;
var should_jump_inside_typeahead = stream_regex.test(split[0]);
if (should_jump_inside_typeahead) {
this.completing = 'topic_jump';
this.token = '>';
// We return something so that the typeahead is shown, but ultimately
return [''];
}
// Matches '#**stream name>some text' at the end of a split.
var stream_topic_regex = /#\*\*([^\*>]+)>([^\*]*)$/;
var should_begin_typeahead = stream_topic_regex.test(split[0]);
if (should_begin_typeahead) {
this.completing = 'topic_list';
var tokens = stream_topic_regex.exec(split[0]);
if (tokens[1]) {
var stream_name = tokens[1];
this.token = tokens[2] || '';
var topic_list = exports.topics_seen_for(stream_name);
if (should_show_custom_query(this.token, topic_list)) {
topic_list.push(this.token);
}
return topic_list;
}
}
}
return false; return false;
}; };
@ -534,10 +601,14 @@ exports.content_highlighter = function (item) {
return typeahead_helper.render_stream(item); return typeahead_helper.render_stream(item);
} else if (this.completing === 'syntax') { } else if (this.completing === 'syntax') {
return typeahead_helper.render_typeahead_item({ primary: item }); return typeahead_helper.render_typeahead_item({ primary: item });
} else if (this.completing === 'topic_jump') {
return typeahead_helper.render_typeahead_item({ primary: item });
} else if (this.completing === 'topic_list') {
return typeahead_helper.render_typeahead_item({ primary: item });
} }
}; };
exports.content_typeahead_selected = function (item) { exports.content_typeahead_selected = function (item, event) {
var pieces = exports.split_at_cursor(this.query, this.$element); var pieces = exports.split_at_cursor(this.query, this.$element);
var beginning = pieces[0]; var beginning = pieces[0];
var rest = pieces[1]; var rest = pieces[1];
@ -578,7 +649,15 @@ exports.content_typeahead_selected = function (item) {
if (beginning.endsWith('#*')) { if (beginning.endsWith('#*')) {
beginning = beginning.substring(0, beginning.length - 2); beginning = beginning.substring(0, beginning.length - 2);
} }
beginning += '#**' + item.name + '** '; beginning += '#**' + item.name;
if (event && event.key === '>') {
// Normally, one accepts typeahead with `tab` or `enter`, but when completing
// stream typeahead, we allow `>`, the delimiter for stream+topic mentions,
// as a completion that automatically sets up stream+topic typeahead for you.
beginning += '>';
} else {
beginning += '** ';
}
$(document).trigger('streamname_completed.zulip', {stream: item}); $(document).trigger('streamname_completed.zulip', {stream: item});
} else if (this.completing === 'syntax') { } else if (this.completing === 'syntax') {
// Isolate the end index of the triple backticks/tildes, including // Isolate the end index of the triple backticks/tildes, including
@ -595,6 +674,19 @@ exports.content_typeahead_selected = function (item) {
// "rest" (i.e. do not add a closing fence) // "rest" (i.e. do not add a closing fence)
beginning = beginning.substring(0, backticks) + item; beginning = beginning.substring(0, backticks) + item;
} }
} else if (this.completing === 'topic_jump') {
// Put the cursor at the end of immediately preceeding stream mention syntax,
// just before where the `**` at the end of the syntax. This will delete that
// final ** and set things up for the topic_list typeahead.
var index = beginning.lastIndexOf('**');
if (index !== -1) {
beginning = beginning.substring(0, index) + '>';
}
} else if (this.completing === 'topic_list') {
// Stream + topic mention typeahead; close the stream+topic mention syntax
// with the topic and the final **.
var start = beginning.length - this.token.length;
beginning = beginning.substring(0, start) + item + '** ';
} }
// Keep the cursor after the newly inserted text, as Bootstrap will call textbox.change() to // Keep the cursor after the newly inserted text, as Bootstrap will call textbox.change() to
@ -618,6 +710,11 @@ exports.compose_content_matcher = function (item) {
return query_matches_user_group_or_stream(this.token, item); return query_matches_user_group_or_stream(this.token, item);
} else if (this.completing === 'syntax') { } else if (this.completing === 'syntax') {
return query_matches_language(this.token, item); return query_matches_language(this.token, item);
} else if (this.completing === 'topic_jump') {
// topic_jump doesn't actually have a typeahead popover, so we return quickly here.
return true;
} else if (this.completing === 'topic_list') {
return query_matches_topic(this.token, item);
} }
}; };
@ -632,9 +729,31 @@ exports.compose_matches_sorter = function (matches) {
return typeahead_helper.sort_streams(matches, this.token); return typeahead_helper.sort_streams(matches, this.token);
} else if (this.completing === 'syntax') { } else if (this.completing === 'syntax') {
return typeahead_helper.sort_languages(matches, this.token); return typeahead_helper.sort_languages(matches, this.token);
} else if (this.completing === 'topic_jump') {
// topic_jump doesn't actually have a typeahead popover, so we return quickly here.
return matches;
} else if (this.completing === 'topic_list') {
return typeahead_helper.sorter(this.token, matches, function (x) {return x;});
} }
}; };
exports.compose_automated_selection = function () {
if (this.completing === 'topic_jump') {
// automatically jump inside stream mention on typing > just after
// a stream mention, to begin stream+topic mention typeahead (topic_list).
return true;
}
return false;
};
exports.compose_trigger_selection = function (event) {
if (this.completing === 'stream' && event.key === '>') {
// complete stream typeahead partially to immediately start the topic_list typeahead.
return true;
}
return false;
};
exports.initialize_compose_typeahead = function (selector) { exports.initialize_compose_typeahead = function (selector) {
var completions = { var completions = {
mention: true, mention: true,
@ -643,6 +762,7 @@ exports.initialize_compose_typeahead = function (selector) {
slash: true, slash: true,
stream: true, stream: true,
syntax: true, syntax: true,
topic: true,
}; };
$(selector).typeahead({ $(selector).typeahead({
@ -656,6 +776,8 @@ exports.initialize_compose_typeahead = function (selector) {
updater: exports.content_typeahead_selected, updater: exports.content_typeahead_selected,
stopAdvance: true, // Do not advance to the next field on a tab or enter stopAdvance: true, // Do not advance to the next field on a tab or enter
completions: completions, completions: completions,
automated: exports.compose_automated_selection,
trigger_selection: exports.compose_trigger_selection,
}); });
}; };