mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 22:13:26 +00:00
We made this change because users often unnecessarily click "Home" first in their use of Zulip, because it seems appealing. While "All messages" isn't quite precise (it doesn't include muted streams), it does describe relatively simply the interleaved view that this represents. This commit leaves everything as "home" in the code, and only changes user-visible strings and docs. Changing the code will be a big project; there are hundreds of relevant occurrences in variable names, etc. Further, we'll probably want to convert those various variable names in different ways. Tweaked by tabbott to extend the commit message and update a few comments.
623 lines
20 KiB
JavaScript
623 lines
20 KiB
JavaScript
var search_suggestion = (function () {
|
|
|
|
var exports = {};
|
|
|
|
function phrase_match(phrase, q) {
|
|
// match "tes" to "test" and "stream test" but not "hostess"
|
|
var i;
|
|
q = q.toLowerCase();
|
|
|
|
phrase = phrase.toLowerCase();
|
|
if (phrase.indexOf(q) === 0) {
|
|
return true;
|
|
}
|
|
|
|
var parts = phrase.split(' ');
|
|
for (i = 0; i < parts.length; i += 1) {
|
|
if (parts[i].indexOf(q) === 0) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stream_matches_query(stream_name, q) {
|
|
return phrase_match(stream_name, q);
|
|
}
|
|
|
|
function highlight_person(query, person) {
|
|
var hilite = typeahead_helper.highlight_query_in_phrase;
|
|
return hilite(query, person.full_name) + " <" + hilite(query, person.email) + ">";
|
|
}
|
|
|
|
function match_criteria(operators, criteria) {
|
|
var filter = new Filter(operators);
|
|
return _.any(criteria, function (cr) {
|
|
if (_.has(cr, 'operand')) {
|
|
return filter.has_operand(cr.operator, cr.operand);
|
|
}
|
|
return filter.has_operator(cr.operator);
|
|
});
|
|
}
|
|
|
|
function compare_by_huddle(huddle) {
|
|
huddle = _.map(huddle.slice(0, -1), function (person) {
|
|
person = people.get_by_email(person);
|
|
if (person) {
|
|
return person.user_id;
|
|
}
|
|
});
|
|
|
|
// Construct dict for all huddles, so we can lookup each's recency
|
|
var huddles = activity.get_huddles();
|
|
var huddle_dict = {};
|
|
for (var i = 0; i < huddles.length; i += 1) {
|
|
huddle_dict[huddles[i]] = i + 1;
|
|
}
|
|
|
|
return function (person1, person2) {
|
|
var huddle1 = huddle.concat(person1.user_id).sort().join(',');
|
|
var huddle2 = huddle.concat(person2.user_id).sort().join(',');
|
|
|
|
// If not in the dict, assign an arbitrarily high index
|
|
var score1 = huddle_dict[huddle1] || 100;
|
|
var score2 = huddle_dict[huddle2] || 100;
|
|
var diff = score1 - score2;
|
|
|
|
if (diff !== 0) {
|
|
return diff;
|
|
}
|
|
return typeahead_helper.compare_by_pms(person1, person2);
|
|
};
|
|
}
|
|
|
|
function get_stream_suggestions(last, operators) {
|
|
if (!(last.operator === 'stream' || last.operator === 'search'
|
|
|| last.operator === '')) {
|
|
return [];
|
|
}
|
|
|
|
var invalid = [
|
|
{operator: 'stream'},
|
|
{operator: 'is', operand: 'private'},
|
|
{operator: 'pm-with'},
|
|
];
|
|
if (match_criteria(operators, invalid)) {
|
|
return [];
|
|
}
|
|
|
|
var query = last.operand;
|
|
var streams = stream_data.subscribed_streams();
|
|
|
|
streams = _.filter(streams, function (stream) {
|
|
return stream_matches_query(stream, query);
|
|
});
|
|
|
|
streams = typeahead_helper.sorter(query, streams);
|
|
|
|
var objs = _.map(streams, function (stream) {
|
|
var prefix = 'stream';
|
|
var highlighted_stream = typeahead_helper.highlight_with_escaping(query, stream);
|
|
var description = prefix + ' ' + highlighted_stream;
|
|
var term = {
|
|
operator: 'stream',
|
|
operand: stream,
|
|
};
|
|
var search_string = Filter.unparse([term]);
|
|
return {description: description, search_string: search_string};
|
|
});
|
|
|
|
return objs;
|
|
}
|
|
|
|
function get_group_suggestions(all_persons, last, operators) {
|
|
if (last.operator !== 'pm-with') {
|
|
return [];
|
|
}
|
|
|
|
var invalid = [
|
|
{operator: 'stream'},
|
|
];
|
|
if (match_criteria(operators, invalid)) {
|
|
return [];
|
|
}
|
|
|
|
var operand = last.operand;
|
|
var negated = last.negated;
|
|
|
|
// The operand has the form "part1,part2,pa", where all but the last part
|
|
// are emails, and the last part is an arbitrary query.
|
|
//
|
|
// We only generate group suggestions when there's more than one part, and
|
|
// we only use the last part to generate suggestions.
|
|
var all_but_last_part;
|
|
var last_part;
|
|
|
|
var last_comma_index = operand.lastIndexOf(',');
|
|
if (last_comma_index < 0) {
|
|
return [];
|
|
}
|
|
|
|
// Neither all_but_last_part nor last_part include the final comma.
|
|
all_but_last_part = operand.slice(0, last_comma_index);
|
|
last_part = operand.slice(last_comma_index + 1);
|
|
|
|
// We don't suggest a person if their email is already present in the
|
|
// operand (not including the last part).
|
|
var parts = all_but_last_part.split(',').concat(people.my_current_email());
|
|
var persons = _.filter(all_persons, function (person) {
|
|
if (_.contains(parts, person.email)) {
|
|
return false;
|
|
}
|
|
return (last_part === '') || people.person_matches_query(person, last_part);
|
|
});
|
|
|
|
persons.sort(compare_by_huddle(parts));
|
|
|
|
// Take top 15 persons, since they're ordered by pm_recipient_count.
|
|
persons = persons.slice(0, 15);
|
|
|
|
var prefix = Filter.operator_to_prefix('pm-with', negated);
|
|
|
|
var suggestions = _.map(persons, function (person) {
|
|
var term = {
|
|
operator: 'pm-with',
|
|
operand: all_but_last_part + ',' + person.email,
|
|
negated: negated,
|
|
};
|
|
var name = highlight_person(last_part, person);
|
|
var description = prefix + ' ' + all_but_last_part + ',' + name;
|
|
var terms = [term];
|
|
if (negated) {
|
|
terms = [{operator: 'is', operand: 'private'}, term];
|
|
}
|
|
var search_string = Filter.unparse(terms);
|
|
return {description: description, search_string: search_string};
|
|
});
|
|
|
|
return suggestions;
|
|
}
|
|
|
|
// Possible args for autocomplete_operator: pm-with, sender, from
|
|
function get_person_suggestions(all_persons, last, operators, autocomplete_operator) {
|
|
if (last.operator === "is" && last.operand === "private") {
|
|
// Interpret 'is:private' as equivalent to 'pm-with:'
|
|
last = {operator: "pm-with", operand: "", negated: false};
|
|
}
|
|
|
|
var query = last.operand;
|
|
|
|
// Only accept queries who match the specified operator, or no operator (search)
|
|
if (!(last.operator === 'search' || last.operator === autocomplete_operator)) {
|
|
return [];
|
|
}
|
|
|
|
// Be especially strict about the less common "from" operator.
|
|
if (autocomplete_operator === 'from' && last.operator !== 'from') {
|
|
return [];
|
|
}
|
|
|
|
var invalid;
|
|
if (autocomplete_operator === 'pm-with') {
|
|
invalid = [{operator: 'pm-with'}, {operator: 'stream'}];
|
|
} else {
|
|
// If not pm-with, then this must either be 'sender' or 'from'
|
|
invalid = [{operator: 'sender'}, {operator: 'from'}];
|
|
}
|
|
|
|
if (match_criteria(operators, invalid)) {
|
|
return [];
|
|
}
|
|
|
|
var persons = _.filter(all_persons, function (person) {
|
|
return people.person_matches_query(person, query);
|
|
});
|
|
|
|
persons.sort(typeahead_helper.compare_by_pms);
|
|
|
|
var prefix = Filter.operator_to_prefix(autocomplete_operator, last.negated);
|
|
|
|
var objs = _.map(persons, function (person) {
|
|
var name = highlight_person(query, person);
|
|
var description = prefix + ' ' + name;
|
|
var terms = [{
|
|
operator: autocomplete_operator,
|
|
operand: person.email,
|
|
negated: last.negated,
|
|
}];
|
|
if (autocomplete_operator === 'pm-with' && last.negated) {
|
|
// In the special case of '-pm-with', add 'is:private' before it
|
|
// because we assume the user still wants to narrow to PMs
|
|
terms.unshift({operator: 'is', operand: 'private'});
|
|
}
|
|
var search_string = Filter.unparse(terms);
|
|
return {description: description, search_string: search_string};
|
|
});
|
|
|
|
return objs;
|
|
}
|
|
|
|
function get_default_suggestion(operators) {
|
|
// Here we return the canonical suggestion for the full query that the
|
|
// user typed. (The caller passes us the parsed query as "operators".)
|
|
if (operators.length === 0) {
|
|
return {description: '', search_string: ''};
|
|
}
|
|
var search_string = Filter.unparse(operators);
|
|
var description = Filter.describe(operators);
|
|
description = Handlebars.Utils.escapeExpression(description);
|
|
return {description: description, search_string: search_string};
|
|
}
|
|
|
|
function get_topic_suggestions(last, operators) {
|
|
if (last.operator === '') {
|
|
return [];
|
|
}
|
|
|
|
var invalid = [
|
|
{operator: 'pm-with'},
|
|
{operator: 'is', operand: 'private'},
|
|
{operator: 'topic'},
|
|
];
|
|
|
|
if (match_criteria(operators, invalid)) {
|
|
return [];
|
|
}
|
|
|
|
var operator = Filter.canonicalize_operator(last.operator);
|
|
var operand = last.operand;
|
|
var negated = (operator === 'topic') && (last.negated);
|
|
var stream;
|
|
var guess;
|
|
var filter = new Filter(operators);
|
|
var suggest_operators = [];
|
|
|
|
// stream:Rome -> show all Rome topics
|
|
// stream:Rome topic: -> show all Rome topics
|
|
// stream:Rome f -> show all Rome topics with a word starting in f
|
|
// stream:Rome topic:f -> show all Rome topics with a word starting in f
|
|
// stream:Rome topic:f -> show all Rome topics with a word starting in f
|
|
|
|
// When narrowed to a stream:
|
|
// topic: -> show all topics in current stream
|
|
// foo -> show all topics in current stream with words starting with foo
|
|
|
|
// If somebody explicitly types search:, then we might
|
|
// not want to suggest topics, but I feel this is a very
|
|
// minor issue, and Filter.parse() is currently lossy
|
|
// in terms of telling us whether they provided the operator,
|
|
// i.e. "foo" and "search:foo" both become [{operator: 'search', operand: 'foo'}].
|
|
switch (operator) {
|
|
case 'stream':
|
|
guess = '';
|
|
stream = operand;
|
|
suggest_operators.push(last);
|
|
break;
|
|
case 'topic':
|
|
case 'search':
|
|
guess = operand;
|
|
if (filter.has_operator('stream')) {
|
|
stream = filter.operands('stream')[0];
|
|
} else {
|
|
stream = narrow_state.stream();
|
|
suggest_operators.push({operator: 'stream', operand: stream});
|
|
}
|
|
break;
|
|
default:
|
|
return [];
|
|
}
|
|
|
|
if (!stream) {
|
|
return [];
|
|
}
|
|
|
|
|
|
var stream_id = stream_data.get_stream_id(stream);
|
|
if (!stream_id) {
|
|
return [];
|
|
}
|
|
|
|
var topics = topic_data.get_recent_names(stream_id);
|
|
|
|
if (!topics || !topics.length) {
|
|
return [];
|
|
}
|
|
|
|
// Be defensive here in case stream_data.get_recent_topics gets
|
|
// super huge, but still slice off enough topics to find matches.
|
|
topics = topics.slice(0, 300);
|
|
|
|
if (guess !== '') {
|
|
topics = _.filter(topics, function (topic) {
|
|
return phrase_match(topic, guess);
|
|
});
|
|
}
|
|
|
|
topics = topics.slice(0, 10);
|
|
|
|
// Just use alphabetical order. While recency and read/unreadness of
|
|
// topics do matter in some contexts, you can get that from the left sidebar,
|
|
// and I'm leaning toward high scannability for autocompletion. I also don't
|
|
// care about case.
|
|
topics.sort();
|
|
|
|
return _.map(topics, function (topic) {
|
|
var topic_term = {operator: 'topic', operand: topic, negated: negated};
|
|
var operators = suggest_operators.concat([topic_term]);
|
|
var search_string = Filter.unparse(operators);
|
|
var description = Filter.describe(operators);
|
|
return {description: description, search_string: search_string};
|
|
});
|
|
}
|
|
|
|
function get_operator_subset_suggestions(operators) {
|
|
// For stream:a topic:b search:c, suggest:
|
|
// stream:a topic:b
|
|
// stream:a
|
|
if (operators.length < 1) {
|
|
return [];
|
|
}
|
|
|
|
var i;
|
|
var suggestions = [];
|
|
|
|
for (i = operators.length - 1; i >= 1; i -= 1) {
|
|
var subset = operators.slice(0, i);
|
|
var search_string = Filter.unparse(subset);
|
|
var description = Filter.describe(subset);
|
|
var suggestion = {description: description, search_string: search_string};
|
|
suggestions.push(suggestion);
|
|
}
|
|
|
|
return suggestions;
|
|
}
|
|
|
|
|
|
function get_special_filter_suggestions(last, operators) {
|
|
var suggestions = [
|
|
{
|
|
search_string: 'is:private',
|
|
description: 'private messages',
|
|
invalid: [
|
|
{operator: 'is', operand: 'private'},
|
|
{operator: 'stream'},
|
|
{operator: 'pm-with'},
|
|
{operator: 'in'},
|
|
],
|
|
|
|
},
|
|
{
|
|
search_string: 'is:starred',
|
|
description: 'starred messages',
|
|
invalid: [
|
|
{operator: 'is', operand: 'starred'},
|
|
],
|
|
},
|
|
{
|
|
search_string: 'is:mentioned',
|
|
description: '@-mentions',
|
|
invalid: [
|
|
{operator: 'is', operand: 'mentioned'},
|
|
],
|
|
},
|
|
{
|
|
search_string: 'is:alerted',
|
|
description: 'alerted messages',
|
|
invalid: [
|
|
{operator: 'is', operand: 'alerted'},
|
|
],
|
|
},
|
|
{
|
|
search_string: 'is:unread',
|
|
description: 'unread messages',
|
|
invalid: [
|
|
{operator: 'is', operand: 'unread'},
|
|
],
|
|
},
|
|
];
|
|
|
|
var last_string = Filter.unparse([last]).toLowerCase();
|
|
suggestions = _.filter(suggestions, function (s) {
|
|
if (match_criteria(operators, s.invalid)) {
|
|
return false;
|
|
}
|
|
if (last_string === '') {
|
|
return true;
|
|
}
|
|
return (s.search_string.toLowerCase().indexOf(last_string) === 0) ||
|
|
(s.description.toLowerCase().indexOf(last_string) === 0);
|
|
});
|
|
|
|
// Only show home if there's an empty bar
|
|
if (operators.length === 0 && last_string === '') {
|
|
suggestions.unshift({search_string: '', description: 'All messages'});
|
|
}
|
|
return suggestions;
|
|
}
|
|
|
|
function get_sent_by_me_suggestions(last, operators) {
|
|
var last_string = Filter.unparse([last]).toLowerCase();
|
|
var sender_query = 'sender:' + people.my_current_email();
|
|
var from_query = 'from:' + people.my_current_email();
|
|
var description = 'sent by me';
|
|
var invalid = [
|
|
{operator: 'sender'},
|
|
{operator: 'from'},
|
|
];
|
|
|
|
if (match_criteria(operators, invalid)) {
|
|
return [];
|
|
}
|
|
|
|
if (last.operator === '' || sender_query.indexOf(last_string) === 0 ||
|
|
'sender:me'.indexOf(last_string) === 0 || last_string === 'sent') {
|
|
return [
|
|
{
|
|
search_string: sender_query,
|
|
description: description,
|
|
},
|
|
];
|
|
} else if (from_query.indexOf(last_string) === 0 || 'sender:me'.indexOf(last_string) === 0) {
|
|
return [
|
|
{
|
|
search_string: from_query,
|
|
description: description,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function get_containing_suggestions(last) {
|
|
if (!(last.operator === 'search' || last.operator === 'has')) {
|
|
return [];
|
|
}
|
|
|
|
var choices = ['link', 'image', 'attachment'];
|
|
choices = _.filter(choices, function (choice) {
|
|
return phrase_match(choice, last.operand);
|
|
});
|
|
|
|
return _.map(choices, function (choice) {
|
|
var op = [{operator: 'has', operand: choice, negated: last.negated}];
|
|
var search_string = Filter.unparse(op);
|
|
var description = Filter.describe(op);
|
|
return {description: description, search_string: search_string};
|
|
});
|
|
}
|
|
|
|
function get_operator_suggestions(last) {
|
|
if (!(last.operator === 'search')) {
|
|
return [];
|
|
}
|
|
|
|
var negated = false;
|
|
if (last.operand.indexOf("-") === 0) {
|
|
negated = true;
|
|
last.operand = last.operand.slice(1);
|
|
}
|
|
|
|
var choices = ['stream', 'topic', 'pm-with', 'sender', 'near', 'has', 'from', 'group-pm-with'];
|
|
choices = _.filter(choices, function (choice) {
|
|
return phrase_match(choice, last.operand);
|
|
});
|
|
|
|
return _.map(choices, function (choice) {
|
|
var op = [{operator: choice, operand: '', negated: negated}];
|
|
var search_string = Filter.unparse(op);
|
|
var description = Filter.describe(op);
|
|
return {description: description, search_string: search_string};
|
|
});
|
|
}
|
|
|
|
function attach_suggestions(result, base, suggestions) {
|
|
_.each(suggestions, function (suggestion) {
|
|
if (base.description.length > 0) {
|
|
suggestion.search_string = base.search_string + " " + suggestion.search_string;
|
|
suggestion.description = base.description + ", " + suggestion.description;
|
|
}
|
|
result.push(suggestion);
|
|
});
|
|
}
|
|
|
|
exports.get_suggestions = function (query) {
|
|
// This method works in tandem with the typeahead library to generate
|
|
// search suggestions. If you want to change its behavior, be sure to update
|
|
// the tests. Its API is partly shaped by the typeahead library, which wants
|
|
// us to give it strings only, but we also need to return our caller a hash
|
|
// with information for subsequent callbacks.
|
|
var result = [];
|
|
var suggestion;
|
|
var base; //base, default suggestion
|
|
var suggestions;
|
|
|
|
// Add an entry for narrow by operators.
|
|
var operators = Filter.parse(query);
|
|
var last = {operator: '', operand: '', negated: false};
|
|
if (operators.length > 0) {
|
|
last = operators.slice(-1)[0];
|
|
}
|
|
|
|
// Display the default first
|
|
if (last.operator !== '') {
|
|
suggestion = get_default_suggestion(operators);
|
|
result = [suggestion];
|
|
}
|
|
|
|
var base_operators = [];
|
|
if (operators.length > 1) {
|
|
base_operators = operators.slice(0, -1);
|
|
}
|
|
base = get_default_suggestion(base_operators);
|
|
|
|
// Get all individual suggestions, and then attach_suggestions
|
|
// mutates the list 'result' to add a properly-formatted suggestion
|
|
suggestions = get_special_filter_suggestions(last, base_operators);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_sent_by_me_suggestions(last, base_operators);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_stream_suggestions(last, base_operators);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
var persons = people.get_all_persons();
|
|
|
|
suggestions = get_person_suggestions(persons, last, base_operators, 'pm-with');
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_person_suggestions(persons, last, base_operators, 'sender');
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_person_suggestions(persons, last, base_operators, 'from');
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_person_suggestions(persons, last, base_operators, 'group-pm-with');
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_group_suggestions(persons, last, base_operators);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_topic_suggestions(last, base_operators);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_operator_suggestions(last);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_containing_suggestions(last);
|
|
attach_suggestions(result, base, suggestions);
|
|
|
|
suggestions = get_operator_subset_suggestions(operators);
|
|
result = result.concat(suggestions);
|
|
|
|
_.each(result, function (sug) {
|
|
var first = sug.description.charAt(0).toUpperCase();
|
|
sug.description = first + sug.description.slice(1);
|
|
});
|
|
|
|
// Typeahead expects us to give it strings, not objects, so we maintain our own hash
|
|
// back to our objects, and we also filter duplicates here.
|
|
var lookup_table = {};
|
|
var unique_suggestions = [];
|
|
_.each(result, function (obj) {
|
|
if (!lookup_table[obj.search_string]) {
|
|
lookup_table[obj.search_string] = obj;
|
|
unique_suggestions.push(obj);
|
|
}
|
|
});
|
|
var strings = _.map(unique_suggestions, function (obj) {
|
|
return obj.search_string;
|
|
});
|
|
return {
|
|
strings: strings,
|
|
lookup_table: lookup_table,
|
|
};
|
|
};
|
|
|
|
|
|
return exports;
|
|
}());
|
|
if (typeof module !== 'undefined') {
|
|
module.exports = search_suggestion;
|
|
}
|