blob: 9cd061e5c60d929f8f8d7d0ff81a215535c63f0b [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
Polymer({
is: 'history-list',
properties: {
// The search term for the current query. Set when the query returns.
searchedTerm: {
type: String,
value: '',
},
resultLoadingDisabled_: {
type: Boolean,
value: false,
},
/**
* Indexes into historyData_ of selected items.
* @type {!Set<number>}
*/
selectedItems: {
type: Object,
value: /** @return {!Set<string>} */ function() {
return new Set();
},
},
canDeleteHistory_: {
type: Boolean,
value: loadTimeData.getBoolean('allowDeletingHistory'),
},
// An array of history entries in reverse chronological order.
historyData_: Array,
lastFocused_: Object,
lastSelectedIndex: Number,
/** @type {!QueryState} */
queryState: Object,
/**
* @private {?{
* index: number,
* item: !HistoryEntry,
* path: string,
* target: !HTMLElement
* }}
*/
actionMenuModel_: Object,
},
listeners: {
'history-checkbox-select': 'onItemSelected_',
'open-menu': 'onOpenMenu_',
'remove-bookmark-stars': 'onRemoveBookmarkStars_',
},
/** @override */
attached: function() {
// It is possible (eg, when middle clicking the reload button) for all other
// resize events to fire before the list is attached and can be measured.
// Adding another resize here ensures it will get sized correctly.
/** @type {IronListElement} */ (this.$['infinite-list']).notifyResize();
this.$['infinite-list'].scrollTarget = this;
this.$['scroll-threshold'].scrollTarget = this;
},
/////////////////////////////////////////////////////////////////////////////
// Public methods:
/**
* @param {HistoryQuery} info An object containing information about the
* query.
* @param {!Array<!HistoryEntry>} results A list of results.
*/
historyResult: function(info, results) {
this.initializeResults_(info, results);
this.closeMenu_();
if (info.term && !this.queryState.incremental) {
Polymer.IronA11yAnnouncer.requestAvailability();
this.fire('iron-announce', {
text:
md_history.HistoryItem.searchResultsTitle(results.length, info.term)
});
}
this.addNewResults(results, this.queryState.incremental, info.finished);
},
/**
* Adds the newly updated history results into historyData_. Adds new fields
* for each result.
* @param {!Array<!HistoryEntry>} historyResults The new history results.
* @param {boolean} incremental Whether the result is from loading more
* history, or a new search/list reload.
* @param {boolean} finished True if there are no more results available and
* result loading should be disabled.
*/
addNewResults: function(historyResults, incremental, finished) {
const results = historyResults.slice();
/** @type {IronScrollThresholdElement} */ (this.$['scroll-threshold'])
.clearTriggers();
if (!incremental) {
this.resultLoadingDisabled_ = false;
if (this.historyData_)
this.splice('historyData_', 0, this.historyData_.length);
this.fire('unselect-all');
this.scrollTop = 0;
}
if (this.historyData_) {
// If we have previously received data, push the new items onto the
// existing array.
results.unshift('historyData_');
this.push.apply(this, results);
} else {
// The first time we receive data, use set() to ensure the iron-list is
// initialized correctly.
this.set('historyData_', results);
}
this.resultLoadingDisabled_ = finished;
},
historyDeleted: function() {
// Do not reload the list when there are items checked.
if (this.getSelectedItemCount() > 0)
return;
// Reload the list with current search state.
this.fire('query-history', false);
},
selectOrUnselectAll: function() {
if (this.historyData_.length == this.getSelectedItemCount())
this.unselectAllItems();
else
this.selectAllItems();
},
/**
* Select each item in |historyData|.
*/
selectAllItems: function() {
if (this.historyData_.length == this.getSelectedItemCount())
return;
this.historyData_.forEach((item, index) => {
this.changeSelection_(index, true);
});
},
/**
* Deselect each item in |selectedItems|.
*/
unselectAllItems: function() {
this.selectedItems.forEach((index) => {
this.changeSelection_(index, false);
});
assert(this.selectedItems.size == 0);
},
/** @return {number} */
getSelectedItemCount: function() {
return this.selectedItems.size;
},
/**
* Delete all the currently selected history items. Will prompt the user with
* a dialog to confirm that the deletion should be performed.
*/
deleteSelectedWithPrompt: function() {
if (!this.canDeleteHistory_)
return;
const browserService = md_history.BrowserService.getInstance();
browserService.recordAction('RemoveSelected');
if (this.queryState.searchTerm != '')
browserService.recordAction('SearchResultRemove');
this.$.dialog.get().showModal();
// TODO(dbeam): remove focus flicker caused by showModal() + focus().
this.$$('.action-button').focus();
},
/////////////////////////////////////////////////////////////////////////////
// Private methods:
/**
* Set the selection status for an item at a particular index.
* @param {number} index
* @param {boolean} selected
* @private
*/
changeSelection_: function(index, selected) {
this.set(`historyData_.${index}.selected`, selected);
if (selected)
this.selectedItems.add(index);
else
this.selectedItems.delete(index);
},
/**
* Performs a request to the backend to delete all selected items. If
* successful, removes them from the view. Does not prompt the user before
* deleting -- see deleteSelectedWithPrompt for a version of this method which
* does prompt.
* @private
*/
deleteSelected_: function() {
const toBeRemoved = Array.from(this.selectedItems.values())
.map((index) => this.get(`historyData_.${index}`));
md_history.BrowserService.getInstance()
.deleteItems(toBeRemoved)
.then((items) => {
this.removeItemsByIndex_(Array.from(this.selectedItems));
this.fire('unselect-all');
if (this.historyData_.length == 0) {
// Try reloading if nothing is rendered.
this.fire('query-history', false);
}
});
},
/**
* Remove all |indices| from the history list. Uses notifySplices to send a
* single large notification to Polymer, rather than many small notifications,
* which greatly improves performance.
* @param {!Array<number>} indices
* @private
*/
removeItemsByIndex_: function(indices) {
const splices = [];
indices.sort(function(a, b) {
// Sort in reverse numerical order.
return b - a;
});
indices.forEach((index) => {
const item = this.historyData_.splice(index, 1);
splices.push({
index: index,
removed: [item],
addedCount: 0,
object: this.historyData_,
type: 'splice'
});
});
this.notifySplices('historyData_', splices);
},
/**
* Closes the overflow menu.
* @private
*/
closeMenu_: function() {
const menu = this.$.sharedMenu.getIfExists();
if (menu && menu.open) {
this.actionMenuModel_ = null;
menu.close();
}
},
/////////////////////////////////////////////////////////////////////////////
// Event listeners:
/** @private */
onDialogConfirmTap_: function() {
md_history.BrowserService.getInstance().recordAction(
'ConfirmRemoveSelected');
this.deleteSelected_();
const dialog = assert(this.$.dialog.getIfExists());
dialog.close();
},
/** @private */
onDialogCancelTap_: function() {
md_history.BrowserService.getInstance().recordAction(
'CancelRemoveSelected');
const dialog = assert(this.$.dialog.getIfExists());
dialog.close();
},
/**
* Remove bookmark star for history items with matching URLs.
* @param {{detail: !string}} e
* @private
*/
onRemoveBookmarkStars_: function(e) {
const url = e.detail;
if (this.historyData_ === undefined)
return;
for (let i = 0; i < this.historyData_.length; i++) {
if (this.historyData_[i].url == url)
this.set(`historyData_.${i}.starred`, false);
}
},
/**
* Called when the page is scrolled to near the bottom of the list.
* @private
*/
onScrollToBottom_: function() {
if (this.resultLoadingDisabled_ || this.queryState.querying)
return;
this.fire('query-history', true);
},
/**
* Open the overflow menu and ensure that the item is visible in the scroll
* pane when its menu is opened (it is possible to open off-screen items using
* keyboard shortcuts).
* @param {{detail: {
* index: number, item: !HistoryEntry,
* path: string, target: !HTMLElement
* }}} e
* @private
*/
onOpenMenu_: function(e) {
const index = e.detail.index;
const list = /** @type {IronListElement} */ (this.$['infinite-list']);
if (index < list.firstVisibleIndex || index > list.lastVisibleIndex)
list.scrollToIndex(index);
const target = e.detail.target;
this.actionMenuModel_ = e.detail;
const menu = /** @type {CrActionMenuElement} */ (this.$.sharedMenu.get());
menu.showAt(target);
},
/** @private */
onMoreFromSiteTap_: function() {
md_history.BrowserService.getInstance().recordAction(
'EntryMenuShowMoreFromSite');
const menu = assert(this.$.sharedMenu.getIfExists());
this.fire('change-query', {search: this.actionMenuModel_.item.domain});
this.actionMenuModel_ = null;
this.closeMenu_();
},
/** @private */
onRemoveFromHistoryTap_: function() {
const browserService = md_history.BrowserService.getInstance();
browserService.recordAction('EntryMenuRemoveFromHistory');
const menu = assert(this.$.sharedMenu.getIfExists());
const itemData = this.actionMenuModel_;
browserService.deleteItems([itemData.item]).then((items) => {
// This unselect-all resets the toolbar when deleting a selected item
// and clears selection state which can be invalid if items move
// around during deletion.
// TODO(tsergeant): Make this automatic based on observing list
// modifications.
this.fire('unselect-all');
this.removeItemsByIndex_([itemData.index]);
const index = itemData.index;
if (index == undefined)
return;
const browserService = md_history.BrowserService.getInstance();
browserService.recordHistogram(
'HistoryPage.RemoveEntryPosition',
Math.min(index, UMA_MAX_BUCKET_VALUE), UMA_MAX_BUCKET_VALUE);
if (index <= UMA_MAX_SUBSET_BUCKET_VALUE) {
browserService.recordHistogram(
'HistoryPage.RemoveEntryPositionSubset', index,
UMA_MAX_SUBSET_BUCKET_VALUE);
}
});
this.closeMenu_();
},
/**
* @param {Event} e
* @private
*/
onItemSelected_: function(e) {
const index = e.detail.index;
const indices = [];
// Handle shift selection. Change the selection state of all items between
// |path| and |lastSelected| to the selection state of |item|.
if (e.detail.shiftKey && this.lastSelectedIndex != undefined) {
for (let i = Math.min(index, this.lastSelectedIndex);
i <= Math.max(index, this.lastSelectedIndex); i++) {
indices.push(i);
}
}
if (indices.length == 0)
indices.push(index);
const selected = !this.selectedItems.has(index);
indices.forEach((index) => {
this.changeSelection_(index, selected);
});
this.lastSelectedIndex = index;
},
/////////////////////////////////////////////////////////////////////////////
// Template helpers:
/**
* Check whether the time difference between the given history item and the
* next one is large enough for a spacer to be required.
* @param {HistoryEntry} item
* @param {number} index The index of |item| in |historyData_|.
* @param {number} length The length of |historyData_|.
* @return {boolean} Whether or not time gap separator is required.
* @private
*/
needsTimeGap_: function(item, index, length) {
if (index >= length - 1 || length == 0)
return false;
const currentItem = this.historyData_[index];
const nextItem = this.historyData_[index + 1];
if (this.searchedTerm)
return currentItem.dateShort != nextItem.dateShort;
return currentItem.time - nextItem.time > BROWSING_GAP_TIME &&
currentItem.dateRelativeDay == nextItem.dateRelativeDay;
},
/**
* True if the given item is the beginning of a new card.
* @param {HistoryEntry} item
* @param {number} i Index of |item| within |historyData_|.
* @param {number} length
* @return {boolean}
* @private
*/
isCardStart_: function(item, i, length) {
if (length == 0 || i > length - 1)
return false;
return i == 0 ||
this.historyData_[i].dateRelativeDay !=
this.historyData_[i - 1].dateRelativeDay;
},
/**
* True if the given item is the end of a card.
* @param {HistoryEntry} item
* @param {number} i Index of |item| within |historyData_|.
* @param {number} length
* @return {boolean}
* @private
*/
isCardEnd_: function(item, i, length) {
if (length == 0 || i > length - 1)
return false;
return i == length - 1 ||
this.historyData_[i].dateRelativeDay !=
this.historyData_[i + 1].dateRelativeDay;
},
/**
* @param {number} historyDataLength
* @return {boolean}
* @private
*/
hasResults_: function(historyDataLength) {
return historyDataLength > 0;
},
/**
* @param {string} searchedTerm
* @return {string}
* @private
*/
noResultsMessage_: function(searchedTerm) {
const messageId = searchedTerm !== '' ? 'noSearchResults' : 'noResults';
return loadTimeData.getString(messageId);
},
/**
* @param {string} searchedTerm
* @param {string} domain
* @return {boolean}
* @private
*/
canSearchMoreFromSite_: function(searchedTerm, domain) {
return searchedTerm === '' || searchedTerm !== domain;
},
/**
* @param {HistoryQuery} info
* @param {!Array<HistoryEntry>} results
* @private
*/
initializeResults_: function(info, results) {
if (results.length == 0)
return;
let currentDate = results[0].dateRelativeDay;
for (let i = 0; i < results.length; i++) {
// Sets the default values for these fields to prevent undefined types.
results[i].selected = false;
results[i].readableTimestamp =
info.term == '' ? results[i].dateTimeOfDay : results[i].dateShort;
if (results[i].dateRelativeDay != currentDate) {
currentDate = results[i].dateRelativeDay;
}
}
},
});