Files
zulip/zephyr/static/js/search.js
Steve Howell 04e1567ef8 Suggest special filters like "All" and "Private messages" in search.
(They will show up for empty queries or queries that are prefixes
of either the operator syntax or the label used in the sidebar.)

(imported from commit 3765896e3bc1fc95caf987194e22d89d649ba5ae)
2013-07-22 16:05:36 -04:00

467 lines
14 KiB
JavaScript

var search = (function () {
var exports = {};
var cached_term = "";
var cached_matches = [];
var cached_index;
var cached_table = $('table.focused_table');
var current_search_term;
// Data storage for the typeahead -- to go from object to string representation and vice versa.
var search_object = {};
function phrase_match(phrase, q) {
// match "tes" to "test" and "stream test" but not "hostess"
var i;
q = q.toLowerCase();
var parts = phrase.split(' ');
for (i = 0; i < parts.length; i++) {
if (parts[i].toLowerCase().indexOf(q) === 0) {
return true;
}
}
return false;
}
function person_matches_query(person, q) {
return phrase_match(person.full_name, q) || phrase_match(person.email, q);
}
function stream_matches_query(stream_name, q) {
return phrase_match(stream_name, q);
}
// Convert a list of operators to a human-readable description.
function describe(operators) {
if (operators.length === 0) {
return 'Go to Home view';
}
var parts = [];
if (operators.length >= 2) {
if (operators[0][0] === 'stream' && operators[1][0] === 'topic') {
var stream = operators[0][1];
var topic = operators[1][1];
var part = 'Narrow to ' + stream + ' > ' + topic;
parts = [part];
operators = operators.slice(2);
}
}
var more_parts = $.map(operators, function (elem) {
var operand = elem[1];
switch (narrow.canonicalize_operator(elem[0])) {
case 'is':
if (operand === 'private') {
return 'Narrow to all private messages';
} else if (operand === 'starred') {
return 'Narrow to starred messages';
} else if (operand === 'mentioned') {
return 'Narrow to mentioned messages';
}
break;
case 'stream':
return 'Narrow to stream ' + operand;
case 'topic':
return 'Narrow to topic ' + operand;
case 'sender':
return 'Narrow to sender ' + operand;
case 'pm-with':
return 'Narrow to private messages with ' + operand;
case 'search':
return 'Search for ' + operand;
case 'in':
return 'Narrow to messages in ' + operand;
}
return 'Narrow to (unknown operator)';
});
return parts.concat(more_parts).join(', ');
}
function narrow_or_search_for_term(item) {
var search_query_box = $("#search_query");
var obj = search_object[item];
ui.change_tab_to('#home');
var operators = narrow.parse(obj.search_string);
narrow.activate(operators, {trigger: 'search'});
// It's sort of annoying that this is not in a position to
// blur the search box, because it means that Esc won't
// unnarrow, it'll leave the searchbox.
// Narrowing will have already put some operators in the search box,
// so leave the current text in.
search_query_box.blur();
return search_query_box.val();
}
function update_buttons_with_focus(focused) {
var search_query = $('#search_query');
// Show buttons iff the search input is focused, or has non-empty contents,
// or we are narrowed.
if (focused
|| search_query.val()
|| narrow.active()) {
$('.search_button').removeAttr('disabled');
} else {
$('.search_button').attr('disabled', 'disabled');
}
}
exports.update_button_visibility = function () {
update_buttons_with_focus($('#search_query').is(':focus'));
};
function highlight_person(query, person) {
var hilite = typeahead_helper.highlight_query_in_phrase;
return hilite(query, person.full_name) + " &lt;" + hilite(query, person.email) + "&gt;";
}
function get_stream_suggestions(query) {
var streams = subs.subscribed_streams();
streams = $.grep(streams, function (stream) {
return stream_matches_query(stream, query);
});
streams = typeahead_helper.sorter(query, streams);
var objs = $.map(streams, function (stream) {
var prefix = 'Narrow to stream';
var highlighted_stream = typeahead_helper.highlight_query_in_phrase(query, stream);
var description = prefix + ' ' + highlighted_stream;
var search_string = narrow.unparse([['stream', stream]]);
return {description: description, search_string: search_string};
});
return objs;
}
function get_person_suggestions(all_people, query, prefix, operator) {
if (query === '') {
return [];
}
var people = $.grep(all_people, function (person) {
return person_matches_query(person, query);
});
people.sort(typeahead_helper.compare_by_pms);
var objs = $.map(people, function (person) {
var name = highlight_person(query, person);
var description = prefix + ' ' + name;
var search_string = operator + ':' + person.email;
return {description: description, search_string: search_string};
});
return objs;
}
function get_suggestion_based_on_query(search_string, operators) {
// We expect caller to call narrow.parse to get operators from search_string.
var description = describe(operators);
description = Handlebars.Utils.escapeExpression(description);
return {description: description, search_string: search_string};
}
function get_topic_suggestions(query, query_operators) {
if (query_operators.length === 0) {
return [];
}
var last_term = query_operators.slice(-1)[0];
var operator = narrow.canonicalize_operator(last_term[0]);
var operand = last_term[1];
var stream;
var guess;
var filter;
// 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 narrow.parse() is currently lossy
// in terms of telling us whether they provided the operator,
// i.e. "foo" and "search:foo" both become [['search', 'foo']].
switch (operator) {
case 'stream':
filter = new narrow.Filter(query_operators);
if (filter.has_operator('topic')) {
return [];
}
guess = '';
stream = operand;
break;
case 'topic':
case 'search':
guess = operand;
query_operators = query_operators.slice(0, -1);
filter = new narrow.Filter(query_operators);
if (filter.has_operator('topic')) {
return [];
}
if (filter.has_operator('stream')) {
stream = filter.operands('stream')[0];
} else {
stream = narrow.stream();
query_operators.push(['stream', stream]);
}
break;
default:
return [];
}
if (!stream) {
return [];
}
stream = subs.canonicalized_name(stream);
var topics = recent_subjects[stream];
if (!topics) {
return [];
}
// Be defensive here in case recent_subjects gets super huge, but
// still slice off enough topics to find matches.
topics = topics.slice(0, 100);
// topics = $.map(topics, (t) -> t.subject)
topics = $.map(topics, function (topic) {
return topic.subject; // "subject" is just the name of the topic
});
if (guess !== '') {
topics = $.grep(topics, function (topic) {
// Don't suggest a topic if the user has already typed out
// the full topic name.
var redundant = topic.toLowerCase() === guess.toLowerCase();
if (redundant) {
return false;
}
return phrase_match(topic, guess);
});
}
// Just use alphabetical order. While recency and read/unreadness of
// subjects 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_operator = ['topic', topic];
var operators = query_operators.concat([topic_operator]);
var search_string = narrow.unparse(operators);
var description = describe(operators);
return {description: description, search_string: search_string};
});
}
function get_special_filter_suggestions(query, operators) {
if (operators.length >= 2) {
return [];
}
var suggestions = [
{
search_string: '',
description: 'Home'
},
{
search_string: 'in:all',
description: 'All messages'
},
{
search_string: 'is:private',
description: 'Private messages'
},
{
search_string: 'is:starred',
description: 'Starred messages'
},
{
search_string: 'is:mentioned',
description: '@-mentions'
}
];
query = query.toLowerCase();
suggestions = $.grep(suggestions, function (s) {
if (s.search_string.toLowerCase() === query) {
return false; // redundant
}
if (query === '') {
return true;
}
return (s.search_string.toLowerCase().indexOf(query) === 0) ||
(s.description.toLowerCase().indexOf(query) === 0);
});
return suggestions;
}
exports.initialize = function () {
$( "#search_query" ).typeahead({
source: function (query, process) {
var result = [];
var suggestion;
var suggestions;
// Add an entry for narrow by operators.
var operators = narrow.parse(query);
suggestion = get_suggestion_based_on_query(query, operators);
result = [suggestion];
suggestions = get_special_filter_suggestions(query, operators);
result = result.concat(suggestions);
suggestions = get_stream_suggestions(query);
result = result.concat(suggestions);
var people = page_params.people_list;
suggestions = get_person_suggestions(people, query, 'Narrow to private messages with', 'pm-with');
result = result.concat(suggestions);
suggestions = get_person_suggestions(people, query, 'Narrow to messages sent by', 'sender');
result = result.concat(suggestions);
suggestions = get_topic_suggestions(query, operators);
result = result.concat(suggestions);
// We can't send typeahead objects, only strings.
search_object = {};
$.each(result, function (idx, obj) {
search_object[obj.search_string] = obj;
});
return $.map(result, function (obj) {
return obj.search_string;
});
},
items: 50,
helpOnEmptyStrings: true,
highlighter: function (item) {
var obj = search_object[item];
return obj.description;
},
matcher: function (item) {
return true;
},
updater: narrow_or_search_for_term,
sorter: function (items) {
return items;
},
smartSpaceBar: true
});
$("#searchbox_form").keydown(function (e) {
exports.update_button_visibility();
var code = e.which;
var search_query_box = $("#search_query");
if (code === 13 && search_query_box.is(":focus")) {
// Don't submit the form so that the typeahead can instead
// handle our Enter keypress. Any searching that needs
// to be done will be handled in the keyup.
e.preventDefault();
return false;
}
}).keyup(function (e) {
var code = e.which;
var search_query_box = $("#search_query");
if (code === 13 && search_query_box.is(":focus")) {
// We just pressed enter and the box had focus, which
// means we didn't use the typeahead at all. In that
// case, we should act as though we're searching by
// operators. (The reason the other actions don't call
// this codepath is that they first all blur the box to
// indicate that they've done what they need to do)
narrow.activate(narrow.parse(search_query_box.val()));
search_query_box.blur();
update_buttons_with_focus(false);
}
});
// Some of these functions don't actually need to be exported,
// but the code was moved here from elsewhere, and it would be
// more work to re-order everything and make them private.
$('#search_exit' ).on('click', exports.clear_search);
var query = $('#search_query');
query.on('focus', exports.focus_search)
.on('blur' , function () {
// The search query box is a visual cue as to
// whether search or narrowing is active. If
// neither is active, we should clear the box on
// blur.
//
// But we can't do this right away, because
// selecting something in the typeahead menu causes
// the box to lose focus a moment before. We would
// clear the thing we're about to search for.
//
// The workaround is to check 100ms later -- long
// enough for the search to have gone through, but
// short enough that the user won't notice (though
// really it would be OK if they did).
setTimeout(function () {
if (!(narrow.active())) {
query.val('');
}
exports.update_button_visibility();
}, 100);
});
};
function match_on_visible_text(row, search_term) {
// You can't select on :visible, since that includes hidden elements that
// take up space.
return row.find(".message_content, .message_header")
.text().toLowerCase().indexOf(search_term) !== -1;
}
exports.focus_search = function () {
// The search bar is not focused yet, but will be.
update_buttons_with_focus(true);
};
exports.initiate_search = function () {
$('#search_query').select();
};
exports.clear_search = function () {
narrow.deactivate();
$('table tr').removeHighlight();
$('#search_query').blur();
exports.update_button_visibility();
};
return exports;
}());