VulpesOS/gaia/shared/js/contacts/search.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

696 lines
20 KiB
JavaScript
Executable file

'use strict';
/* global Normalizer */
/* global utils */
/* global HtmlHelper */
/* global LazyLoader */
/* global ImageLoader */
/* global fb */
/* exported Search */
(function(exports) {
var inSearchMode = false,
searchView,
searchBox,
searchList,
searchNoResult,
searchProgress,
searchTimer = null,
contactNodes = null,
selectableForm = null,
// On the steady state holds the list result of the current search
searchableNodes = null,
currentTextToSearch = '',
currentSearchTerms = [],
prevTextToSearch = '',
// Pointer to the nodes which are currently on the result list
currentSet = {},
searchTextCache = {},
canReuseSearchables = false,
blurList = false,
theClones = {},
CHUNK_SIZE = 10,
// Default to invalid page size and recalculate when first row added
searchPageSize = -1,
// Limit search result to hardLimit contacts, recalc with page size
hardLimit = 25,
emptySearch = true,
remainingPending = true,
imgLoader,
searchEnabled = false,
source = null,
navigationController = null;
// The _source argument should be an adapter object that provides access
// to the contact nodes in the app. This is done by defining the following
// functions on the adapter object:
// getNodes() An Array of all contact DOM nodes
// getFirstNode() First contact DOM node
// getNextNode(node) Given a node, find the next node
// expectMoreNodes() True if nodes will be added via appendNodes()
// clone(node) Clone the given contact node
// getNodeById(id) Get the node matching the given ID, or null
// getSearchText(node) Get the search text from the given node
// click(event) Click event handler to use
var init = function load(_source, defaultEnabled, navigation) {
searchView = document.getElementById('search-view');
searchList = document.getElementById('search-list');
selectableForm = document.getElementById('selectable-form');
if (!_source) {
throw new Error('Search requires a contact source!');
}
source = _source;
if (typeof source.click === 'function') {
searchList.addEventListener('click', source.click);
}
searchEnabled = !!defaultEnabled;
navigationController = navigation || window.MainNavigation;
};
var initialized = false;
var ignoreReturnKey = function ignoreReturnKey(evt) {
if (evt.keyCode == 13) { // VK_Return
evt.preventDefault();
}
};
var doInit = function doInit() {
if (initialized) {
return;
}
utils.listeners.add({
'#cancel-search': exitSearchMode,
'#search-contact': [
{
event: 'keypress',
handler: ignoreReturnKey
}
]
});
initialized = true;
searchBox = document.getElementById('search-contact');
var resetButton = searchBox.nextElementSibling;
resetButton.addEventListener('ontouchstart' in window ? 'touchstart' :
'mousedown', function() {
searchBox.value = '';
searchBox.focus();
resetState();
window.setTimeout(fillInitialSearchPage, 0);
});
searchList.parentNode.addEventListener('touchstart', function() {
if (searchableNodes && remainingPending) {
addRemainingResults(searchableNodes, searchPageSize);
}
blurList = true;
});
searchNoResult = document.getElementById('no-result');
searchProgress = document.getElementById('search-progress');
searchBox.addEventListener('blur', function() {
window.setTimeout(onSearchBlur, 0);
});
searchBox.addEventListener('focus', function() {
blurList = false;
});
LazyLoader.load([
'/contacts/js/fb_resolver.js',
'/shared/js/contacts/utilities/image_loader.js'
], function() {
imgLoader = new ImageLoader('#groups-list-search', 'li');
imgLoader.setResolver(fb.resolver);
});
};
var clearHighlights = function(node) {
// We traverse the DOM tree and remove highlighting marks.
// getElements instead of querySelector here because of
// performance.
var highlights = node.getElementsByTagName('mark');
while(highlights.length) {
var parent = highlights[0].parentNode;
while(highlights[0].firstChild) {
parent.insertBefore(highlights[0].firstChild, highlights[0]);
}
// This removes the item from 'highlights' HTMLCollection as well
// via live DOM updating.
parent.removeChild(highlights[0]);
}
};
/**
* Given a contact DOM node, this function highlights the search terms it
* contains. First, it extract the text contents, highlights every chunk and
* then rebuild the content of the node respecting the location of the
* <strong> tag. To know where every chunk starts and ends, we use the
* locateHTMLTag so we know what we have to highlight.
*
* Highlighting is done in the createHighlightHTML() function inside the
* HtmlHelper module.
*/
var highlightNode = function(node) {
function doHighlight(text) {
if (text === '' || text === ' ') {
return document.createTextNode('');
}
return HtmlHelper.createHighlightHTML(text, currentSearchTerms);
}
function locateHTMLTag(text) {
var tagLocation = [];
tagLocation.push(text.indexOf('<'));
tagLocation.push(text.indexOf('>', tagLocation[0]) + 1);
tagLocation.push(text.indexOf('<', tagLocation[1]));
tagLocation.push(text.indexOf('>', tagLocation[2]) + 1);
return tagLocation;
}
var textNode = node.querySelector('.contact-text bdi');
if (textNode === null) {
return;
}
var text = textNode.innerHTML;
var tagPos = locateHTMLTag(text);
var beforeTag = text.substring(0, tagPos[0]);
var inTag = text.substring(tagPos[1], tagPos[2]);
var afterTag = text.substring(tagPos[3]);
var realText = [beforeTag, inTag, afterTag];
if (tagPos[0] == -1) {
var content = doHighlight(Normalizer.unescapeHTML(text));
// clear the previous content (if any)
textNode.textContent = '';
textNode.appendChild(content);
} else {
for (var i = 0, len = realText.length; i < len; i++) {
realText[i] = doHighlight(Normalizer.unescapeHTML(realText[i]));
}
var fragment = document.createDocumentFragment();
var strongTag = document.createElement('strong');
strongTag.appendChild(realText[1]);
fragment.appendChild(realText[0]);
fragment.appendChild(strongTag);
fragment.appendChild(realText[2]);
// clear the previous content (if any)
textNode.textContent = '';
textNode.appendChild(fragment);
}
};
var updateSearchList = function updateSearchList(cb) {
if (!inSearchMode) {
if (cb) {
cb();
}
return;
}
window.setTimeout(function() {
// Resetting state
resetStateAndCache();
search(cb);
});
};
var resetStateAndCache = function resetStateAndCache() {
contactNodes = null;
searchTextCache = {};
resetState();
};
// Search mode instructions
var exitSearchMode = function exitSearchMode(evt) {
if (evt) {
evt.preventDefault();
}
searchView.classList.remove('insearchmode');
if (selectableForm) {
selectableForm.classList.remove('insearchmode');
}
if (navigationController) {
navigationController.back();
}
window.setTimeout(function exit_search() {
hideProgressResults();
searchBox.value = '';
// Resetting state
contactNodes = null;
searchTextCache = {};
resetState();
inSearchMode = false;
}, 0);
window.removeEventListener('input', onInput);
};
function resetState() {
prevTextToSearch = '';
currentTextToSearch = '';
currentSearchTerms = [];
searchableNodes = null;
canReuseSearchables = false;
currentSet = {};
// We don't know if the user will launch a new search later
theClones = {};
utils.dom.removeChildNodes(searchList);
emptySearch = true;
remainingPending = true;
}
function addRemainingResults(nodes, from) {
if (remainingPending !== true) {
return;
}
var fragment = document.createDocumentFragment();
for (var i = from; i < hardLimit && i < nodes.length; i++) {
var node = nodes[i].node;
var clon = getClone(node);
highlightNode(clon);
fragment.appendChild(clon);
currentSet[node.dataset.uuid] = clon;
}
if (fragment.hasChildNodes()) {
searchList.appendChild(fragment);
imgLoader.reload();
}
remainingPending = false;
}
function onSearchBlur(e) {
if (canReuseSearchables && searchableNodes &&
searchView.classList.contains('insearchmode') && blurList) {
// All the searchable nodes have to be added
addRemainingResults(searchableNodes, searchPageSize);
}
else if (emptySearch === true && remainingPending === true) {
var lastNode = searchList.querySelector('li:last-child');
if (lastNode) {
var lastNodeUid = lastNode.dataset.uuid;
var startNode = source.getNextNode(source.getNodeById(lastNodeUid));
fillIdentityResults(startNode, hardLimit - searchPageSize);
remainingPending = false;
imgLoader.reload();
}
}
}
function fillIdentityResults(startNode, number) {
var fragment = document.createDocumentFragment();
var contact = startNode;
for (var i = 0; i < number && contact; i++) {
var clonedNode = getClone(contact);
fragment.appendChild(clonedNode);
currentSet[contact.dataset.uuid] = clonedNode;
contact = source.getNextNode(contact);
}
if (fragment.hasChildNodes()) {
searchList.appendChild(fragment);
}
}
function getClone(node) {
var id = node.dataset.uuid;
var out = theClones[id];
if (!out) {
out = source.clone(node);
cacheClone(id, out);
}
return out;
}
function cacheClone(id, clone) {
theClones[id] = clone;
}
function onInput(e) {
if (e.target.id === searchBox.id) {
search();
}
}
var enterSearchMode = function searchMode(evt) {
evt.preventDefault();
if (!inSearchMode) {
window.addEventListener('input', onInput);
searchView.classList.add('insearchmode');
if (selectableForm) {
selectableForm.classList.add('insearchmode');
}
doInit();
fillInitialSearchPage();
inSearchMode = true;
emptySearch = true;
if (navigationController) {
navigationController.go('search-view', 'none');
}
setTimeout(function nextTick() {
searchBox.focus();
});
}
};
function fillInitialSearchPage(done) {
hideProgressResults();
var startContact = source.getFirstNode();
var numToFill = searchPageSize;
// Calculate rows visible on a single page the first time we get a row
// that we can measure.
if (startContact && searchPageSize < 1) {
fillIdentityResults(startContact, 1);
var viewHeight = searchList.getBoundingClientRect().height;
var rowHeight = searchList.children[0].getBoundingClientRect().height;
searchPageSize = Math.ceil(viewHeight / rowHeight);
hardLimit = ~~(3.5 * searchPageSize);
startContact = source.getNextNode(startContact);
numToFill = searchPageSize - 1;
}
fillIdentityResults(startContact, numToFill);
if (typeof done === 'function') {
done();
}
if (imgLoader) {
imgLoader.reload();
}
}
function doSearch(contacts, from, searchText, pattern, state) {
// Check whether the user enter a new term or not
if (currentTextToSearch.localeCompare(searchText) !== 0) {
canReuseSearchables = false;
window.console.warn('**** Cancelling current search ****');
return;
}
// Search the next chunk of contacts
var end = from + CHUNK_SIZE;
currentSearchTerms = pattern.source.split(/\s+/);
for (var c = from; c < end && c < contacts.length; c++) {
var contact = contacts[c].node || contacts[c];
var contactText = contacts[c].text || getSearchText(contacts[c]);
contactText = Normalizer.unescapeHTML(contactText);
if (!checkContactMatch(currentSearchTerms, contactText)) {
if (contact.dataset.uuid in currentSet) {
searchList.removeChild(currentSet[contact.dataset.uuid]);
delete currentSet[contact.dataset.uuid];
}
} else {
if (state.count === 0) {
hideProgressResults();
}
// Only an initial page of elements is loaded in the search list
if (Object.keys(currentSet).length < searchPageSize &&
!(contact.dataset.uuid in currentSet)) {
var clonedNode = getClone(contact);
currentSet[contact.dataset.uuid] = clonedNode;
searchList.appendChild(clonedNode);
}
if (currentSet[contact.dataset.uuid]) {
// We clear the highlights here because parts of the node could
// been already highlighted from a previous, more general search.
clearHighlights(currentSet[contact.dataset.uuid]);
highlightNode(currentSet[contact.dataset.uuid]);
}
state.searchables.push({
node: contact,
text: contactText
});
state.count++;
}
}
// If we are still searching through the list, then schedule
// the next batch of search comparisons.
if (c < contacts.length) {
searchTimer = window.setTimeout(function do_search() {
searchTimer = null;
doSearch(contacts, from + CHUNK_SIZE, searchText,
pattern, state);
}, 0);
return;
// If we expect to get more nodes, for example if the source is
// still loading, then delay finalizing the end of the search
} else if (source.expectMoreNodes()) {
// Since we're blocked waiting on more contacts to be provided,
// use some delay here to avoid a tight spin loop.
var delay = 250;
searchTimer = window.setTimeout(function do_search() {
doSearch(contacts, Math.min(end, contacts.length), searchText,
pattern, state);
}, delay);
return;
// Or we are complete with no results found
} else if (state.count === 0) {
showNoResults();
canReuseSearchables = false;
// Or we are complete with results that might have images to render
} else {
imgLoader.reload();
searchableNodes = state.searchables;
canReuseSearchables = true;
// If the user wished to scroll let's add the remaining results
if (blurList === true) {
searchTimer = window.setTimeout(function() {
searchTimer = null;
addRemainingResults(searchableNodes, searchPageSize);
},0);
}
}
if (typeof state.searchDoneCb === 'function') {
state.searchDoneCb();
}
}
var enableSearch = function enableSearch() {
if (searchEnabled) {
return;
}
searchEnabled = true;
// We perform the search when all the info have been loaded and the
// user wrote something in the entry field
invalidateCache();
search();
};
// Allow the main contacts list to asynchronously tell us about additional
// nodes as they are loaded.
var appendNodes = function appendNodes(nodes) {
if (!nodes || !nodes.length || !contactNodes) {
return;
}
contactNodes.push.apply(contactNodes, nodes);
// If there are no searches in progress, then we are done
if (!currentTextToSearch || !canReuseSearchables || !searchableNodes) {
return;
}
// If we have a current search then we need to determine whether the
// new nodes should show up in that search.
for (var i = 0, n = nodes.length; i < n; ++i) {
var node = nodes[i];
var nodeText = Normalizer.unescapeHTML(getSearchText(node));
if (!checkContactMatch(currentSearchTerms, nodeText)) {
searchableNodes.push({
node: node,
text: nodeText
});
}
}
};
var checkContactMatch = function checkContactMatch(searchTerms, text) {
for (var i=0, m=searchTerms.length; i < m; i++){
if (!RegExp(searchTerms[i], 'i').test(text)) {
return false;
}
}
return true;
};
var search = function performSearch(searchDoneCb) {
prevTextToSearch = currentTextToSearch;
currentTextToSearch = Normalizer.toAscii(searchBox.value.trim());
currentTextToSearch = Normalizer.escapeRegExp(currentTextToSearch);
var thisSearchText = String(currentTextToSearch);
if (thisSearchText.length === 0) {
resetState();
window.setTimeout(fillInitialSearchPage, 0, searchDoneCb);
}
else {
showProgress();
if (!searchEnabled) {
resetState();
return;
}
emptySearch = false;
// The remaining results have not been added yet
remainingPending = true;
var pattern = new RegExp(thisSearchText, 'i');
var contactsToSearch = getContactsToSearch(thisSearchText,
prevTextToSearch);
var state = {
count: 0,
searchables: [],
searchDoneCb: searchDoneCb
};
searchTimer = window.setTimeout(function do_search() {
searchTimer = null;
doSearch(contactsToSearch, 0, thisSearchText, pattern, state);
},0);
}
};
function getSearchText(contact) {
var out = '';
var uuid = contact.dataset.uuid;
if (uuid) {
out = searchTextCache[uuid];
if (!out) {
out = source.getSearchText(contact);
searchTextCache[uuid] = out;
}
}
else {
window.console.error('Search: Not uuid found for the provided node');
}
return out;
}
var getContactsDom = function contactsDom() {
if (!contactNodes) {
contactNodes = source.getNodes();
}
return contactNodes;
};
var getContactsToSearch = function getContactsToSearch(newText, prevText) {
var out;
if (canReuseSearchables && newText.length > prevText.length &&
prevText.length > 0 && newText.indexOf(prevText) === 0) {
out = searchableNodes || getContactsDom();
} else {
utils.dom.removeChildNodes(searchList);
currentSet = {};
out = getContactsDom();
canReuseSearchables = false;
}
return out;
};
var isInSearchMode = function isInSearchMode() {
return inSearchMode;
};
var invalidateCache = function s_invalidate() {
if (searchTimer) {
window.clearTimeout(searchTimer);
searchTimer = null;
}
currentTextToSearch = '';
canReuseSearchables = false;
searchableNodes = null;
contactNodes = null;
currentSet = {};
searchTextCache = {};
};
var removeContact = function s_removeContact(id) {
var contact = searchList.querySelector('li[data-uuid=\"' + id + '\"]');
searchList.removeChild(contact);
};
var selectRow = function s_selectRow(id, value) {
var check = searchList.querySelector(
'#search-view input[value="' + id + '"]');
if (check) {
check.checked = value;
}
};
function showProgress() {
searchNoResult.classList.add('hide');
searchProgress.classList.remove('hidden');
}
function showNoResults() {
searchNoResult.classList.remove('hide');
searchProgress.classList.add('hidden');
}
function hideProgressResults() {
searchNoResult.classList.add('hide');
searchProgress.classList.add('hidden');
}
exports.Search = {
'init': init,
'invalidateCache': invalidateCache,
'appendNodes': appendNodes,
'removeContact': removeContact,
'search': search,
'enterSearchMode': enterSearchMode,
'exitSearchMode': exitSearchMode,
'isInSearchMode': isInSearchMode,
'enableSearch': enableSearch,
'selectRow': selectRow,
'updateSearchList': updateSearchList
};
})(window);