• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5<include src="../uber/uber_utils.js">
6<include src="history_focus_manager.js">
7
8///////////////////////////////////////////////////////////////////////////////
9// Globals:
10/** @const */ var RESULTS_PER_PAGE = 150;
11
12// Amount of time between pageviews that we consider a 'break' in browsing,
13// measured in milliseconds.
14/** @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000;
15
16// The largest bucket value for UMA histogram, based on entry ID. All entries
17// with IDs greater than this will be included in this bucket.
18/** @const */ var UMA_MAX_BUCKET_VALUE = 1000;
19
20// The largest bucket value for a UMA histogram that is a subset of above.
21/** @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100;
22
23// TODO(glen): Get rid of these global references, replace with a controller
24//     or just make the classes own more of the page.
25var historyModel;
26var historyView;
27var pageState;
28var selectionAnchor = -1;
29var activeVisit = null;
30
31/** @const */ var Command = cr.ui.Command;
32/** @const */ var Menu = cr.ui.Menu;
33/** @const */ var MenuButton = cr.ui.MenuButton;
34
35/**
36 * Enum that shows the filtering behavior for a host or URL to a managed user.
37 * Must behave like the FilteringBehavior enum from managed_mode_url_filter.h.
38 * @enum {number}
39 */
40ManagedModeFilteringBehavior = {
41  ALLOW: 0,
42  WARN: 1,
43  BLOCK: 2
44};
45
46MenuButton.createDropDownArrows();
47
48/**
49 * Returns true if the mobile (non-desktop) version is being shown.
50 * @return {boolean} true if the mobile version is being shown.
51 */
52function isMobileVersion() {
53  return !document.body.classList.contains('uber-frame');
54}
55
56/**
57 * Record an action in UMA.
58 * @param {string} actionDesc The name of the action to be logged.
59 */
60function recordUmaAction(actionDesc) {
61  chrome.send('metricsHandler:recordAction', [actionDesc]);
62}
63
64/**
65 * Record a histogram value in UMA. If specified value is larger than the max
66 * bucket value, record the value in the largest bucket.
67 * @param {string} histogram The name of the histogram to be recorded in.
68 * @param {integer} maxBucketValue The max value for the last histogram bucket.
69 * @param {integer} value The value to record in the histogram.
70 */
71
72function recordUmaHistogram(histogram, maxBucketValue, value) {
73  chrome.send('metricsHandler:recordInHistogram',
74              [histogram,
75              ((value > maxBucketValue) ? maxBucketValue : value),
76              maxBucketValue]);
77}
78
79///////////////////////////////////////////////////////////////////////////////
80// Visit:
81
82/**
83 * Class to hold all the information about an entry in our model.
84 * @param {Object} result An object containing the visit's data.
85 * @param {boolean} continued Whether this visit is on the same day as the
86 *     visit before it.
87 * @param {HistoryModel} model The model object this entry belongs to.
88 * @constructor
89 */
90function Visit(result, continued, model) {
91  this.model_ = model;
92  this.title_ = result.title;
93  this.url_ = result.url;
94  this.domain_ = result.domain;
95  this.starred_ = result.starred;
96
97  // These identify the name and type of the device on which this visit
98  // occurred. They will be empty if the visit occurred on the current device.
99  this.deviceName = result.deviceName;
100  this.deviceType = result.deviceType;
101
102  // The ID will be set according to when the visit was displayed, not
103  // received. Set to -1 to show that it has not been set yet.
104  this.id_ = -1;
105
106  this.isRendered = false;  // Has the visit already been rendered on the page?
107
108  // All the date information is public so that owners can compare properties of
109  // two items easily.
110
111  this.date = new Date(result.time);
112
113  // See comment in BrowsingHistoryHandler::QueryComplete - we won't always
114  // get all of these.
115  this.dateRelativeDay = result.dateRelativeDay || '';
116  this.dateTimeOfDay = result.dateTimeOfDay || '';
117  this.dateShort = result.dateShort || '';
118
119  // Shows the filtering behavior for that host (only used for managed users).
120  // A value of |ManagedModeFilteringBehavior.ALLOW| is not displayed so it is
121  // used as the default value.
122  this.hostFilteringBehavior = ManagedModeFilteringBehavior.ALLOW;
123  if (typeof result.hostFilteringBehavior != 'undefined')
124    this.hostFilteringBehavior = result.hostFilteringBehavior;
125
126  this.blockedVisit = result.blockedVisit || false;
127
128  // Whether this is the continuation of a previous day.
129  this.continued = continued;
130
131  this.allTimestamps = result.allTimestamps;
132}
133
134// Visit, public: -------------------------------------------------------------
135
136/**
137 * Returns a dom structure for a browse page result or a search page result.
138 * @param {Object} propertyBag A bag of configuration properties, false by
139 * default:
140 *  - isSearchResult: Whether or not the result is a search result.
141 *  - addTitleFavicon: Whether or not the favicon should be added.
142 *  - useMonthDate: Whether or not the full date should be inserted (used for
143 * monthly view).
144 * @return {Node} A DOM node to represent the history entry or search result.
145 */
146Visit.prototype.getResultDOM = function(propertyBag) {
147  var isSearchResult = propertyBag.isSearchResult || false;
148  var addTitleFavicon = propertyBag.addTitleFavicon || false;
149  var useMonthDate = propertyBag.useMonthDate || false;
150  var node = createElementWithClassName('li', 'entry');
151  var time = createElementWithClassName('div', 'time');
152  var entryBox = createElementWithClassName('label', 'entry-box');
153  var domain = createElementWithClassName('div', 'domain');
154
155  this.id_ = this.model_.nextVisitId_++;
156
157  // Only create the checkbox if it can be used either to delete an entry or to
158  // block/allow it.
159  if (this.model_.editingEntriesAllowed) {
160    var checkbox = document.createElement('input');
161    checkbox.type = 'checkbox';
162    checkbox.id = 'checkbox-' + this.id_;
163    checkbox.time = this.date.getTime();
164    checkbox.addEventListener('click', checkboxClicked);
165    entryBox.appendChild(checkbox);
166
167    // Clicking anywhere in the entryBox will check/uncheck the checkbox.
168    entryBox.setAttribute('for', checkbox.id);
169    entryBox.addEventListener('mousedown', entryBoxMousedown);
170    entryBox.addEventListener('click', entryBoxClick);
171  }
172
173  // Keep track of the drop down that triggered the menu, so we know
174  // which element to apply the command to.
175  // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it.
176  var self = this;
177  var setActiveVisit = function(e) {
178    activeVisit = self;
179    var menu = $('action-menu');
180    menu.dataset.devicename = self.deviceName;
181    menu.dataset.devicetype = self.deviceType;
182  };
183  domain.textContent = this.domain_;
184
185  entryBox.appendChild(time);
186
187  var bookmarkSection = createElementWithClassName('div', 'bookmark-section');
188  if (this.starred_) {
189    bookmarkSection.classList.add('starred');
190    bookmarkSection.addEventListener('click', function f(e) {
191      recordUmaAction('HistoryPage_BookmarkStarClicked');
192      bookmarkSection.classList.remove('starred');
193      chrome.send('removeBookmark', [self.url_]);
194      bookmarkSection.removeEventListener('click', f);
195      e.preventDefault();
196    });
197  }
198  entryBox.appendChild(bookmarkSection);
199
200  var visitEntryWrapper = entryBox.appendChild(document.createElement('div'));
201  if (addTitleFavicon || this.blockedVisit)
202    visitEntryWrapper.classList.add('visit-entry');
203  if (this.blockedVisit) {
204    visitEntryWrapper.classList.add('blocked-indicator');
205    visitEntryWrapper.appendChild(this.getVisitAttemptDOM_());
206  } else {
207    visitEntryWrapper.appendChild(this.getTitleDOM_(isSearchResult));
208    if (addTitleFavicon)
209      this.addFaviconToElement_(visitEntryWrapper);
210    visitEntryWrapper.appendChild(domain);
211  }
212
213  if (isMobileVersion()) {
214    var removeButton = createElementWithClassName('button', 'remove-entry');
215    removeButton.setAttribute('aria-label',
216                              loadTimeData.getString('removeFromHistory'));
217    removeButton.classList.add('custom-appearance');
218    removeButton.addEventListener('click', function(e) {
219      self.removeFromHistory();
220      e.stopPropagation();
221      e.preventDefault();
222    });
223    entryBox.appendChild(removeButton);
224
225    // Support clicking anywhere inside the entry box.
226    entryBox.addEventListener('click', function(e) {
227      e.currentTarget.querySelector('a').click();
228    });
229  } else {
230    var dropDown = createElementWithClassName('button', 'drop-down');
231    dropDown.value = 'Open action menu';
232    dropDown.title = loadTimeData.getString('actionMenuDescription');
233    dropDown.setAttribute('menu', '#action-menu');
234    dropDown.setAttribute('aria-haspopup', 'true');
235    cr.ui.decorate(dropDown, MenuButton);
236
237    dropDown.addEventListener('mousedown', setActiveVisit);
238    dropDown.addEventListener('focus', setActiveVisit);
239
240    // Prevent clicks on the drop down from affecting the checkbox.  We need to
241    // call blur() explicitly because preventDefault() cancels any focus
242    // handling.
243    dropDown.addEventListener('click', function(e) {
244      e.preventDefault();
245      document.activeElement.blur();
246    });
247    entryBox.appendChild(dropDown);
248  }
249
250  // Let the entryBox be styled appropriately when it contains keyboard focus.
251  entryBox.addEventListener('focus', function() {
252    this.classList.add('contains-focus');
253  }, true);
254  entryBox.addEventListener('blur', function() {
255    this.classList.remove('contains-focus');
256  }, true);
257
258  var entryBoxContainer =
259      createElementWithClassName('div', 'entry-box-container');
260  node.appendChild(entryBoxContainer);
261  entryBoxContainer.appendChild(entryBox);
262
263  if (isSearchResult || useMonthDate) {
264    // Show the day instead of the time.
265    time.appendChild(document.createTextNode(this.dateShort));
266  } else {
267    time.appendChild(document.createTextNode(this.dateTimeOfDay));
268  }
269
270  this.domNode_ = node;
271  node.visit = this;
272
273  return node;
274};
275
276/**
277 * Remove this visit from the history.
278 */
279Visit.prototype.removeFromHistory = function() {
280  recordUmaAction('HistoryPage_EntryMenuRemoveFromHistory');
281  var self = this;
282  this.model_.removeVisitsFromHistory([this], function() {
283    removeEntryFromView(self.domNode_);
284  });
285};
286
287// Visit, private: ------------------------------------------------------------
288
289/**
290 * Add child text nodes to a node such that occurrences of the specified text is
291 * highlighted.
292 * @param {Node} node The node under which new text nodes will be made as
293 *     children.
294 * @param {string} content Text to be added beneath |node| as one or more
295 *     text nodes.
296 * @param {string} highlightText Occurences of this text inside |content| will
297 *     be highlighted.
298 * @private
299 */
300Visit.prototype.addHighlightedText_ = function(node, content, highlightText) {
301  var i = 0;
302  if (highlightText) {
303    var re = new RegExp(Visit.pregQuote_(highlightText), 'gim');
304    var match;
305    while (match = re.exec(content)) {
306      if (match.index > i)
307        node.appendChild(document.createTextNode(content.slice(i,
308                                                               match.index)));
309      i = re.lastIndex;
310      // Mark the highlighted text in bold.
311      var b = document.createElement('b');
312      b.textContent = content.substring(match.index, i);
313      node.appendChild(b);
314    }
315  }
316  if (i < content.length)
317    node.appendChild(document.createTextNode(content.slice(i)));
318};
319
320/**
321 * Returns the DOM element containing a link on the title of the URL for the
322 * current visit.
323 * @param {boolean} isSearchResult Whether or not the entry is a search result.
324 * @return {Element} DOM representation for the title block.
325 * @private
326 */
327Visit.prototype.getTitleDOM_ = function(isSearchResult) {
328  var node = createElementWithClassName('div', 'title');
329  var link = document.createElement('a');
330  link.href = this.url_;
331  link.id = 'id-' + this.id_;
332  link.target = '_top';
333  var integerId = parseInt(this.id_, 10);
334  link.addEventListener('click', function() {
335    recordUmaAction('HistoryPage_EntryLinkClick');
336    // Record the ID of the entry to signify how many entries are above this
337    // link on the page.
338    recordUmaHistogram('HistoryPage.ClickPosition',
339                       UMA_MAX_BUCKET_VALUE,
340                       integerId);
341    if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
342      recordUmaHistogram('HistoryPage.ClickPositionSubset',
343                         UMA_MAX_SUBSET_BUCKET_VALUE,
344                         integerId);
345    }
346  });
347  link.addEventListener('contextmenu', function() {
348    recordUmaAction('HistoryPage_EntryLinkRightClick');
349  });
350
351  if (isSearchResult) {
352    link.addEventListener('click', function() {
353      recordUmaAction('HistoryPage_SearchResultClick');
354    });
355  }
356
357  // Add a tooltip, since it might be ellipsized.
358  // TODO(dubroy): Find a way to show the tooltip only when necessary.
359  link.title = this.title_;
360
361  this.addHighlightedText_(link, this.title_, this.model_.getSearchText());
362  node.appendChild(link);
363
364  return node;
365};
366
367/**
368 * Returns the DOM element containing the text for a blocked visit attempt.
369 * @return {Element} DOM representation of the visit attempt.
370 * @private
371 */
372Visit.prototype.getVisitAttemptDOM_ = function() {
373  var node = createElementWithClassName('div', 'title');
374  node.innerHTML = loadTimeData.getStringF('blockedVisitText',
375                                           this.url_,
376                                           this.id_,
377                                           this.domain_);
378  return node;
379};
380
381/**
382 * Set the favicon for an element.
383 * @param {Element} el The DOM element to which to add the icon.
384 * @private
385 */
386Visit.prototype.addFaviconToElement_ = function(el) {
387  var url = isMobileVersion() ?
388      getFaviconImageSet(this.url_, 32, 'touch-icon') :
389      getFaviconImageSet(this.url_);
390  el.style.backgroundImage = url;
391};
392
393/**
394 * Launch a search for more history entries from the same domain.
395 * @private
396 */
397Visit.prototype.showMoreFromSite_ = function() {
398  recordUmaAction('HistoryPage_EntryMenuShowMoreFromSite');
399  historyView.setSearch(this.domain_);
400  $('search-field').focus();
401};
402
403// Visit, private, static: ----------------------------------------------------
404
405/**
406 * Quote a string so it can be used in a regular expression.
407 * @param {string} str The source string.
408 * @return {string} The escaped string.
409 * @private
410 */
411Visit.pregQuote_ = function(str) {
412  return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
413};
414
415///////////////////////////////////////////////////////////////////////////////
416// HistoryModel:
417
418/**
419 * Global container for history data. Future optimizations might include
420 * allowing the creation of a HistoryModel for each search string, allowing
421 * quick flips back and forth between results.
422 *
423 * The history model is based around pages, and only fetching the data to
424 * fill the currently requested page. This is somewhat dependent on the view,
425 * and so future work may wish to change history model to operate on
426 * timeframe (day or week) based containers.
427 *
428 * @constructor
429 */
430function HistoryModel() {
431  this.clearModel_();
432}
433
434// HistoryModel, Public: ------------------------------------------------------
435
436/** @enum {number} */
437HistoryModel.Range = {
438  ALL_TIME: 0,
439  WEEK: 1,
440  MONTH: 2
441};
442
443/**
444 * Sets our current view that is called when the history model changes.
445 * @param {HistoryView} view The view to set our current view to.
446 */
447HistoryModel.prototype.setView = function(view) {
448  this.view_ = view;
449};
450
451/**
452 * Reload our model with the current parameters.
453 */
454HistoryModel.prototype.reload = function() {
455  // Save user-visible state, clear the model, and restore the state.
456  var search = this.searchText_;
457  var page = this.requestedPage_;
458  var range = this.rangeInDays_;
459  var offset = this.offset_;
460  var groupByDomain = this.groupByDomain_;
461
462  this.clearModel_();
463  this.searchText_ = search;
464  this.requestedPage_ = page;
465  this.rangeInDays_ = range;
466  this.offset_ = offset;
467  this.groupByDomain_ = groupByDomain;
468  this.queryHistory_();
469};
470
471/**
472 * @return {string} The current search text.
473 */
474HistoryModel.prototype.getSearchText = function() {
475  return this.searchText_;
476};
477
478/**
479 * Tell the model that the view will want to see the current page. When
480 * the data becomes available, the model will call the view back.
481 * @param {number} page The page we want to view.
482 */
483HistoryModel.prototype.requestPage = function(page) {
484  this.requestedPage_ = page;
485  this.updateSearch_();
486};
487
488/**
489 * Receiver for history query.
490 * @param {Object} info An object containing information about the query.
491 * @param {Array} results A list of results.
492 */
493HistoryModel.prototype.addResults = function(info, results) {
494  // If no requests are in flight then this was an old request so we drop the
495  // results. Double check the search term as well.
496  if (!this.inFlight_ || info.term != this.searchText_)
497    return;
498
499  $('loading-spinner').hidden = true;
500  this.inFlight_ = false;
501  this.isQueryFinished_ = info.finished;
502  this.queryStartTime = info.queryStartTime;
503  this.queryEndTime = info.queryEndTime;
504
505  var lastVisit = this.visits_.slice(-1)[0];
506  var lastDay = lastVisit ? lastVisit.dateRelativeDay : null;
507
508  for (var i = 0, result; result = results[i]; i++) {
509    var thisDay = result.dateRelativeDay;
510    var isSameDay = lastDay == thisDay;
511    this.visits_.push(new Visit(result, isSameDay, this));
512    lastDay = thisDay;
513  }
514
515  if (loadTimeData.getBoolean('isUserSignedIn')) {
516    var message = loadTimeData.getString(
517        info.hasSyncedResults ? 'hasSyncedResults' : 'noSyncedResults');
518    this.view_.showNotification(message);
519  }
520
521  this.updateSearch_();
522};
523
524/**
525 * @return {number} The number of visits in the model.
526 */
527HistoryModel.prototype.getSize = function() {
528  return this.visits_.length;
529};
530
531/**
532 * Get a list of visits between specified index positions.
533 * @param {number} start The start index.
534 * @param {number} end The end index.
535 * @return {Array.<Visit>} A list of visits.
536 */
537HistoryModel.prototype.getNumberedRange = function(start, end) {
538  return this.visits_.slice(start, end);
539};
540
541/**
542 * Return true if there are more results beyond the current page.
543 * @return {boolean} true if the there are more results, otherwise false.
544 */
545HistoryModel.prototype.hasMoreResults = function() {
546  return this.haveDataForPage_(this.requestedPage_ + 1) ||
547      !this.isQueryFinished_;
548};
549
550/**
551 * Removes a list of visits from the history, and calls |callback| when the
552 * removal has successfully completed.
553 * @param {Array<Visit>} visits The visits to remove.
554 * @param {Function} callback The function to call after removal succeeds.
555 */
556HistoryModel.prototype.removeVisitsFromHistory = function(visits, callback) {
557  var toBeRemoved = [];
558  for (var i = 0; i < visits.length; i++) {
559    toBeRemoved.push({
560      url: visits[i].url_,
561      timestamps: visits[i].allTimestamps
562    });
563  }
564  chrome.send('removeVisits', toBeRemoved);
565  this.deleteCompleteCallback_ = callback;
566};
567
568/**
569 * Called when visits have been succesfully removed from the history.
570 */
571HistoryModel.prototype.deleteComplete = function() {
572  // Call the callback, with 'this' undefined inside the callback.
573  this.deleteCompleteCallback_.call();
574  this.deleteCompleteCallback_ = null;
575};
576
577// Getter and setter for HistoryModel.rangeInDays_.
578Object.defineProperty(HistoryModel.prototype, 'rangeInDays', {
579  get: function() {
580    return this.rangeInDays_;
581  },
582  set: function(range) {
583    this.rangeInDays_ = range;
584  }
585});
586
587/**
588 * Getter and setter for HistoryModel.offset_. The offset moves the current
589 * query 'window' |range| days behind. As such for range set to WEEK an offset
590 * of 0 refers to the last 7 days, an offset of 1 refers to the 7 day period
591 * that ended 7 days ago, etc. For MONTH an offset of 0 refers to the current
592 * calendar month, 1 to the previous one, etc.
593 */
594Object.defineProperty(HistoryModel.prototype, 'offset', {
595  get: function() {
596    return this.offset_;
597  },
598  set: function(offset) {
599    this.offset_ = offset;
600  }
601});
602
603// Setter for HistoryModel.requestedPage_.
604Object.defineProperty(HistoryModel.prototype, 'requestedPage', {
605  set: function(page) {
606    this.requestedPage_ = page;
607  }
608});
609
610// HistoryModel, Private: -----------------------------------------------------
611
612/**
613 * Clear the history model.
614 * @private
615 */
616HistoryModel.prototype.clearModel_ = function() {
617  this.inFlight_ = false;  // Whether a query is inflight.
618  this.searchText_ = '';
619  // Whether this user is a managed user.
620  this.isManagedProfile = loadTimeData.getBoolean('isManagedProfile');
621  this.deletingHistoryAllowed = loadTimeData.getBoolean('allowDeletingHistory');
622
623  // Only create checkboxes for editing entries if they can be used either to
624  // delete an entry or to block/allow it.
625  this.editingEntriesAllowed = this.deletingHistoryAllowed;
626
627  // Flag to show that the results are grouped by domain or not.
628  this.groupByDomain_ = false;
629
630  this.visits_ = [];  // Date-sorted list of visits (most recent first).
631  this.nextVisitId_ = 0;
632  selectionAnchor = -1;
633
634  // The page that the view wants to see - we only fetch slightly past this
635  // point. If the view requests a page that we don't have data for, we try
636  // to fetch it and call back when we're done.
637  this.requestedPage_ = 0;
638
639  // The range of history to view or search over.
640  this.rangeInDays_ = HistoryModel.Range.ALL_TIME;
641
642  // Skip |offset_| * weeks/months from the begining.
643  this.offset_ = 0;
644
645  // Keeps track of whether or not there are more results available than are
646  // currently held in |this.visits_|.
647  this.isQueryFinished_ = false;
648
649  if (this.view_)
650    this.view_.clear_();
651};
652
653/**
654 * Figure out if we need to do more queries to fill the currently requested
655 * page. If we think we can fill the page, call the view and let it know
656 * we're ready to show something. This only applies to the daily time-based
657 * view.
658 * @private
659 */
660HistoryModel.prototype.updateSearch_ = function() {
661  var doneLoading = this.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
662                    this.isQueryFinished_ ||
663                    this.canFillPage_(this.requestedPage_);
664
665  // Try to fetch more results if more results can arrive and the page is not
666  // full.
667  if (!doneLoading && !this.inFlight_)
668    this.queryHistory_();
669
670  // Show the result or a message if no results were returned.
671  this.view_.onModelReady(doneLoading);
672};
673
674/**
675 * Query for history, either for a search or time-based browsing.
676 * @private
677 */
678HistoryModel.prototype.queryHistory_ = function() {
679  var maxResults =
680      (this.rangeInDays_ == HistoryModel.Range.ALL_TIME) ? RESULTS_PER_PAGE : 0;
681
682  // If there are already some visits, pick up the previous query where it
683  // left off.
684  var lastVisit = this.visits_.slice(-1)[0];
685  var endTime = lastVisit ? lastVisit.date.getTime() : 0;
686
687  $('loading-spinner').hidden = false;
688  this.inFlight_ = true;
689  chrome.send('queryHistory',
690      [this.searchText_, this.offset_, this.rangeInDays_, endTime, maxResults]);
691};
692
693/**
694 * Check to see if we have data for the given page.
695 * @param {number} page The page number.
696 * @return {boolean} Whether we have any data for the given page.
697 * @private
698 */
699HistoryModel.prototype.haveDataForPage_ = function(page) {
700  return page * RESULTS_PER_PAGE < this.getSize();
701};
702
703/**
704 * Check to see if we have data to fill the given page.
705 * @param {number} page The page number.
706 * @return {boolean} Whether we have data to fill the page.
707 * @private
708 */
709HistoryModel.prototype.canFillPage_ = function(page) {
710  return ((page + 1) * RESULTS_PER_PAGE <= this.getSize());
711};
712
713/**
714 * Enables or disables grouping by domain.
715 * @param {boolean} groupByDomain New groupByDomain_ value.
716 */
717HistoryModel.prototype.setGroupByDomain = function(groupByDomain) {
718  this.groupByDomain_ = groupByDomain;
719  this.offset_ = 0;
720};
721
722/**
723 * Gets whether we are grouped by domain.
724 * @return {boolean} Whether the results are grouped by domain.
725 */
726HistoryModel.prototype.getGroupByDomain = function() {
727  return this.groupByDomain_;
728};
729
730///////////////////////////////////////////////////////////////////////////////
731// HistoryView:
732
733/**
734 * Functions and state for populating the page with HTML. This should one-day
735 * contain the view and use event handlers, rather than pushing HTML out and
736 * getting called externally.
737 * @param {HistoryModel} model The model backing this view.
738 * @constructor
739 */
740function HistoryView(model) {
741  this.editButtonTd_ = $('edit-button');
742  this.editingControlsDiv_ = $('editing-controls');
743  this.resultDiv_ = $('results-display');
744  this.pageDiv_ = $('results-pagination');
745  this.model_ = model;
746  this.pageIndex_ = 0;
747  this.lastDisplayed_ = [];
748
749  this.model_.setView(this);
750
751  this.currentVisits_ = [];
752
753  // If there is no search button, use the search button label as placeholder
754  // text in the search field.
755  if ($('search-button').offsetWidth == 0)
756    $('search-field').placeholder = $('search-button').value;
757
758  var self = this;
759
760  $('clear-browsing-data').addEventListener('click', openClearBrowsingData);
761  $('remove-selected').addEventListener('click', removeItems);
762
763  // Add handlers for the page navigation buttons at the bottom.
764  $('newest-button').addEventListener('click', function() {
765    recordUmaAction('HistoryPage_NewestHistoryClick');
766    self.setPage(0);
767  });
768  $('newer-button').addEventListener('click', function() {
769    recordUmaAction('HistoryPage_NewerHistoryClick');
770    self.setPage(self.pageIndex_ - 1);
771  });
772  $('older-button').addEventListener('click', function() {
773    recordUmaAction('HistoryPage_OlderHistoryClick');
774    self.setPage(self.pageIndex_ + 1);
775  });
776
777  var handleRangeChange = function(e) {
778    // Update the results and save the last state.
779    self.setRangeInDays(parseInt(e.target.value, 10));
780  };
781
782  // Add handlers for the range options.
783  $('timeframe-filter-all').addEventListener('change', handleRangeChange);
784  $('timeframe-filter-week').addEventListener('change', handleRangeChange);
785  $('timeframe-filter-month').addEventListener('change', handleRangeChange);
786
787  $('range-previous').addEventListener('click', function(e) {
788    if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
789      self.setPage(self.pageIndex_ + 1);
790    else
791      self.setOffset(self.getOffset() + 1);
792  });
793  $('range-next').addEventListener('click', function(e) {
794    if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
795      self.setPage(self.pageIndex_ - 1);
796    else
797      self.setOffset(self.getOffset() - 1);
798  });
799  $('range-today').addEventListener('click', function(e) {
800    if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
801      self.setPage(0);
802    else
803      self.setOffset(0);
804  });
805}
806
807// HistoryView, public: -------------------------------------------------------
808/**
809 * Do a search on a specific term.
810 * @param {string} term The string to search for.
811 */
812HistoryView.prototype.setSearch = function(term) {
813  window.scrollTo(0, 0);
814  this.setPageState(term, 0, this.getRangeInDays(), this.getOffset());
815};
816
817/**
818 * Reload the current view.
819 */
820HistoryView.prototype.reload = function() {
821  this.model_.reload();
822  this.updateSelectionEditButtons();
823  this.updateRangeButtons_();
824};
825
826/**
827 * Sets all the parameters for the history page and then reloads the view to
828 * update the results.
829 * @param {string} searchText The search string to set.
830 * @param {number} page The page to be viewed.
831 * @param {HistoryModel.Range} range The range to view or search over.
832 * @param {number} offset Set the begining of the query to the specific offset.
833 */
834HistoryView.prototype.setPageState = function(searchText, page, range, offset) {
835  this.clear_();
836  this.model_.searchText_ = searchText;
837  this.pageIndex_ = page;
838  this.model_.requestedPage_ = page;
839  this.model_.rangeInDays_ = range;
840  this.model_.groupByDomain_ = false;
841  if (range != HistoryModel.Range.ALL_TIME)
842    this.model_.groupByDomain_ = true;
843  this.model_.offset_ = offset;
844  this.reload();
845  pageState.setUIState(this.model_.getSearchText(),
846                       this.pageIndex_,
847                       this.getRangeInDays(),
848                       this.getOffset());
849};
850
851/**
852 * Switch to a specified page.
853 * @param {number} page The page we wish to view.
854 */
855HistoryView.prototype.setPage = function(page) {
856  // TODO(sergiu): Move this function to setPageState as well and see why one
857  // of the tests fails when using setPageState.
858  this.clear_();
859  this.pageIndex_ = parseInt(page, 10);
860  window.scrollTo(0, 0);
861  this.model_.requestPage(page);
862  pageState.setUIState(this.model_.getSearchText(),
863                       this.pageIndex_,
864                       this.getRangeInDays(),
865                       this.getOffset());
866};
867
868/**
869 * @return {number} The page number being viewed.
870 */
871HistoryView.prototype.getPage = function() {
872  return this.pageIndex_;
873};
874
875/**
876 * Set the current range for grouped results.
877 * @param {string} range The number of days to which the range should be set.
878 */
879HistoryView.prototype.setRangeInDays = function(range) {
880  // Set the range, offset and reset the page.
881  this.setPageState(this.model_.getSearchText(), 0, range, 0);
882};
883
884/**
885 * Get the current range in days.
886 * @return {number} Current range in days from the model.
887 */
888HistoryView.prototype.getRangeInDays = function() {
889  return this.model_.rangeInDays;
890};
891
892/**
893 * Set the current offset for grouped results.
894 * @param {number} offset Offset to set.
895 */
896HistoryView.prototype.setOffset = function(offset) {
897  // If there is another query already in flight wait for that to complete.
898  if (this.model_.inFlight_)
899    return;
900  this.setPageState(this.model_.getSearchText(),
901                    this.pageIndex_,
902                    this.getRangeInDays(),
903                    offset);
904};
905
906/**
907 * Get the current offset.
908 * @return {number} Current offset from the model.
909 */
910HistoryView.prototype.getOffset = function() {
911  return this.model_.offset;
912};
913
914/**
915 * Callback for the history model to let it know that it has data ready for us
916 * to view.
917 * @param {boolean} doneLoading Whether the current request is complete.
918 */
919HistoryView.prototype.onModelReady = function(doneLoading) {
920  this.displayResults_(doneLoading);
921
922  // Allow custom styling based on whether there are any results on the page.
923  // To make this easier, add a class to the body if there are any results.
924  if (this.model_.visits_.length)
925    document.body.classList.add('has-results');
926  else
927    document.body.classList.remove('has-results');
928
929  this.updateNavBar_();
930
931  if (isMobileVersion()) {
932    // Hide the search field if it is empty and there are no results.
933    var hasResults = this.model_.visits_.length > 0;
934    var isSearch = this.model_.getSearchText().length > 0;
935    $('search-field').hidden = !(hasResults || isSearch);
936  }
937};
938
939/**
940 * Enables or disables the buttons that control editing entries depending on
941 * whether there are any checked boxes.
942 */
943HistoryView.prototype.updateSelectionEditButtons = function() {
944  if (loadTimeData.getBoolean('allowDeletingHistory')) {
945    var anyChecked = document.querySelector('.entry input:checked') != null;
946    $('remove-selected').disabled = !anyChecked;
947  } else {
948    $('remove-selected').disabled = true;
949  }
950};
951
952/**
953 * Shows the notification bar at the top of the page with |innerHTML| as its
954 * content.
955 * @param {string} innerHTML The HTML content of the warning.
956 * @param {boolean} isWarning If true, style the notification as a warning.
957 */
958HistoryView.prototype.showNotification = function(innerHTML, isWarning) {
959  var bar = $('notification-bar');
960  bar.innerHTML = innerHTML;
961  bar.hidden = false;
962  if (isWarning)
963    bar.classList.add('warning');
964  else
965    bar.classList.remove('warning');
966
967  // Make sure that any links in the HTML are targeting the top level.
968  var links = bar.querySelectorAll('a');
969  for (var i = 0; i < links.length; i++)
970    links[i].target = '_top';
971
972  this.positionNotificationBar();
973};
974
975/**
976 * Adjusts the position of the notification bar based on the size of the page.
977 */
978HistoryView.prototype.positionNotificationBar = function() {
979  var bar = $('notification-bar');
980
981  // If the bar does not fit beside the editing controls, put it into the
982  // overflow state.
983  if (bar.getBoundingClientRect().top >=
984      $('editing-controls').getBoundingClientRect().bottom) {
985    bar.classList.add('alone');
986  } else {
987    bar.classList.remove('alone');
988  }
989};
990
991// HistoryView, private: ------------------------------------------------------
992
993/**
994 * Clear the results in the view.  Since we add results piecemeal, we need
995 * to clear them out when we switch to a new page or reload.
996 * @private
997 */
998HistoryView.prototype.clear_ = function() {
999  var alertOverlay = $('alertOverlay');
1000  if (alertOverlay && alertOverlay.classList.contains('showing'))
1001    hideConfirmationOverlay();
1002
1003  this.resultDiv_.textContent = '';
1004
1005  this.currentVisits_.forEach(function(visit) {
1006    visit.isRendered = false;
1007  });
1008  this.currentVisits_ = [];
1009
1010  document.body.classList.remove('has-results');
1011};
1012
1013/**
1014 * Record that the given visit has been rendered.
1015 * @param {Visit} visit The visit that was rendered.
1016 * @private
1017 */
1018HistoryView.prototype.setVisitRendered_ = function(visit) {
1019  visit.isRendered = true;
1020  this.currentVisits_.push(visit);
1021};
1022
1023/**
1024 * Generates and adds the grouped visits DOM for a certain domain. This
1025 * includes the clickable arrow and domain name and the visit entries for
1026 * that domain.
1027 * @param {Element} results DOM object to which to add the elements.
1028 * @param {string} domain Current domain name.
1029 * @param {Array} domainVisits Array of visits for this domain.
1030 * @private
1031 */
1032HistoryView.prototype.getGroupedVisitsDOM_ = function(
1033    results, domain, domainVisits) {
1034  // Add a new domain entry.
1035  var siteResults = results.appendChild(
1036      createElementWithClassName('li', 'site-entry'));
1037
1038  // Make a wrapper that will contain the arrow, the favicon and the domain.
1039  var siteDomainWrapper = siteResults.appendChild(
1040      createElementWithClassName('div', 'site-domain-wrapper'));
1041
1042  if (this.model_.editingEntriesAllowed) {
1043    var siteDomainCheckbox =
1044        createElementWithClassName('input', 'domain-checkbox');
1045
1046    siteDomainCheckbox.type = 'checkbox';
1047    siteDomainCheckbox.addEventListener('click', domainCheckboxClicked);
1048    siteDomainCheckbox.domain_ = domain;
1049
1050    siteDomainWrapper.appendChild(siteDomainCheckbox);
1051  }
1052
1053  var siteArrow = siteDomainWrapper.appendChild(
1054      createElementWithClassName('div', 'site-domain-arrow collapse'));
1055  var siteDomain = siteDomainWrapper.appendChild(
1056      createElementWithClassName('div', 'site-domain'));
1057  var siteDomainLink = siteDomain.appendChild(
1058      createElementWithClassName('button', 'link-button'));
1059  siteDomainLink.addEventListener('click', function(e) { e.preventDefault(); });
1060  siteDomainLink.textContent = domain;
1061  var numberOfVisits = createElementWithClassName('span', 'number-visits');
1062  var domainElement = document.createElement('span');
1063
1064  numberOfVisits.textContent = loadTimeData.getStringF('numberVisits',
1065                                                       domainVisits.length);
1066  siteDomain.appendChild(numberOfVisits);
1067
1068  domainVisits[0].addFaviconToElement_(siteDomain);
1069
1070  siteDomainWrapper.addEventListener('click', toggleHandler);
1071
1072  if (this.model_.isManagedProfile) {
1073    siteDomainWrapper.appendChild(
1074        getManagedStatusDOM(domainVisits[0].hostFilteringBehavior));
1075  }
1076
1077  siteResults.appendChild(siteDomainWrapper);
1078  var resultsList = siteResults.appendChild(
1079      createElementWithClassName('ol', 'site-results'));
1080  resultsList.classList.add('grouped');
1081
1082  // Collapse until it gets toggled.
1083  resultsList.style.height = 0;
1084
1085  // Add the results for each of the domain.
1086  var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH;
1087  for (var j = 0, visit; visit = domainVisits[j]; j++) {
1088    resultsList.appendChild(visit.getResultDOM({
1089      useMonthDate: isMonthGroupedResult
1090    }));
1091    this.setVisitRendered_(visit);
1092  }
1093};
1094
1095/**
1096 * Enables or disables the time range buttons.
1097 * @private
1098 */
1099HistoryView.prototype.updateRangeButtons_ = function() {
1100  // The enabled state for the previous, today and next buttons.
1101  var previousState = false;
1102  var todayState = false;
1103  var nextState = false;
1104  var usePage = (this.getRangeInDays() == HistoryModel.Range.ALL_TIME);
1105
1106  // Use pagination for most recent visits, offset otherwise.
1107  // TODO(sergiu): Maybe send just one variable in the future.
1108  if (usePage) {
1109    if (this.getPage() != 0) {
1110      nextState = true;
1111      todayState = true;
1112    }
1113    previousState = this.model_.hasMoreResults();
1114  } else {
1115    if (this.getOffset() != 0) {
1116      nextState = true;
1117      todayState = true;
1118    }
1119    previousState = !this.model_.isQueryFinished_;
1120  }
1121
1122  $('range-previous').disabled = !previousState;
1123  $('range-today').disabled = !todayState;
1124  $('range-next').disabled = !nextState;
1125};
1126
1127/**
1128 * Groups visits by domain, sorting them by the number of visits.
1129 * @param {Array} visits Visits received from the query results.
1130 * @param {Element} results Object where the results are added to.
1131 * @private
1132 */
1133HistoryView.prototype.groupVisitsByDomain_ = function(visits, results) {
1134  var visitsByDomain = {};
1135  var domains = [];
1136
1137  // Group the visits into a dictionary and generate a list of domains.
1138  for (var i = 0, visit; visit = visits[i]; i++) {
1139    var domain = visit.domain_;
1140    if (!visitsByDomain[domain]) {
1141      visitsByDomain[domain] = [];
1142      domains.push(domain);
1143    }
1144    visitsByDomain[domain].push(visit);
1145  }
1146  var sortByVisits = function(a, b) {
1147    return visitsByDomain[b].length - visitsByDomain[a].length;
1148  };
1149  domains.sort(sortByVisits);
1150
1151  for (var i = 0; i < domains.length; ++i) {
1152    var domain = domains[i];
1153    this.getGroupedVisitsDOM_(results, domain, visitsByDomain[domain]);
1154  }
1155};
1156
1157/**
1158 * Adds the results for a month.
1159 * @param {Array} visits Visits returned by the query.
1160 * @param {Element} parentElement Element to which to add the results to.
1161 * @private
1162 */
1163HistoryView.prototype.addMonthResults_ = function(visits, parentElement) {
1164  if (visits.length == 0)
1165    return;
1166
1167  var monthResults = parentElement.appendChild(
1168      createElementWithClassName('ol', 'month-results'));
1169  // Don't add checkboxes if entries can not be edited.
1170  if (!this.model_.editingEntriesAllowed)
1171    monthResults.classList.add('no-checkboxes');
1172
1173  this.groupVisitsByDomain_(visits, monthResults);
1174};
1175
1176/**
1177 * Adds the results for a certain day. This includes a title with the day of
1178 * the results and the results themselves, grouped or not.
1179 * @param {Array} visits Visits returned by the query.
1180 * @param {Element} parentElement Element to which to add the results to.
1181 * @private
1182 */
1183HistoryView.prototype.addDayResults_ = function(visits, parentElement) {
1184  if (visits.length == 0)
1185    return;
1186
1187  var firstVisit = visits[0];
1188  var day = parentElement.appendChild(createElementWithClassName('h3', 'day'));
1189  day.appendChild(document.createTextNode(firstVisit.dateRelativeDay));
1190  if (firstVisit.continued) {
1191    day.appendChild(document.createTextNode(' ' +
1192                                            loadTimeData.getString('cont')));
1193  }
1194  var dayResults = parentElement.appendChild(
1195      createElementWithClassName('ol', 'day-results'));
1196
1197  // Don't add checkboxes if entries can not be edited.
1198  if (!this.model_.editingEntriesAllowed)
1199    dayResults.classList.add('no-checkboxes');
1200
1201  if (this.model_.getGroupByDomain()) {
1202    this.groupVisitsByDomain_(visits, dayResults);
1203  } else {
1204    var lastTime;
1205
1206    for (var i = 0, visit; visit = visits[i]; i++) {
1207      // If enough time has passed between visits, indicate a gap in browsing.
1208      var thisTime = visit.date.getTime();
1209      if (lastTime && lastTime - thisTime > BROWSING_GAP_TIME)
1210        dayResults.appendChild(createElementWithClassName('li', 'gap'));
1211
1212      // Insert the visit into the DOM.
1213      dayResults.appendChild(visit.getResultDOM({ addTitleFavicon: true }));
1214      this.setVisitRendered_(visit);
1215
1216      lastTime = thisTime;
1217    }
1218  }
1219};
1220
1221/**
1222 * Adds the text that shows the current interval, used for week and month
1223 * results.
1224 * @param {Element} resultsFragment The element to which the interval will be
1225 *     added to.
1226 * @private
1227 */
1228HistoryView.prototype.addTimeframeInterval_ = function(resultsFragment) {
1229  if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME)
1230    return;
1231
1232  // If this is a time range result add some text that shows what is the
1233  // time range for the results the user is viewing.
1234  var timeFrame = resultsFragment.appendChild(
1235      createElementWithClassName('h2', 'timeframe'));
1236  // TODO(sergiu): Figure the best way to show this for the first day of
1237  // the month.
1238  timeFrame.appendChild(document.createTextNode(loadTimeData.getStringF(
1239      'historyInterval',
1240      this.model_.queryStartTime,
1241      this.model_.queryEndTime)));
1242};
1243
1244/**
1245 * Update the page with results.
1246 * @param {boolean} doneLoading Whether the current request is complete.
1247 * @private
1248 */
1249HistoryView.prototype.displayResults_ = function(doneLoading) {
1250  // Either show a page of results received for the all time results or all the
1251  // received results for the weekly and monthly view.
1252  var results = this.model_.visits_;
1253  if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) {
1254    var rangeStart = this.pageIndex_ * RESULTS_PER_PAGE;
1255    var rangeEnd = rangeStart + RESULTS_PER_PAGE;
1256    results = this.model_.getNumberedRange(rangeStart, rangeEnd);
1257  }
1258  var searchText = this.model_.getSearchText();
1259  var groupByDomain = this.model_.getGroupByDomain();
1260
1261  if (searchText) {
1262    // Add a header for the search results, if there isn't already one.
1263    if (!this.resultDiv_.querySelector('h3')) {
1264      var header = document.createElement('h3');
1265      header.textContent = loadTimeData.getStringF('searchResultsFor',
1266                                                   searchText);
1267      this.resultDiv_.appendChild(header);
1268    }
1269
1270    this.addTimeframeInterval_(this.resultDiv_);
1271
1272    var searchResults = createElementWithClassName('ol', 'search-results');
1273
1274    // Don't add checkboxes if entries can not be edited.
1275    if (!this.model_.editingEntriesAllowed)
1276      searchResults.classList.add('no-checkboxes');
1277
1278    if (results.length == 0 && doneLoading) {
1279      var noSearchResults = searchResults.appendChild(
1280          createElementWithClassName('div', 'no-results-message'));
1281      noSearchResults.textContent = loadTimeData.getString('noSearchResults');
1282    } else {
1283      for (var i = 0, visit; visit = results[i]; i++) {
1284        if (!visit.isRendered) {
1285          searchResults.appendChild(visit.getResultDOM({
1286            isSearchResult: true,
1287            addTitleFavicon: true
1288          }));
1289          this.setVisitRendered_(visit);
1290        }
1291      }
1292    }
1293    this.resultDiv_.appendChild(searchResults);
1294  } else {
1295    var resultsFragment = document.createDocumentFragment();
1296
1297    this.addTimeframeInterval_(resultsFragment);
1298
1299    if (results.length == 0 && doneLoading) {
1300      var noResults = resultsFragment.appendChild(
1301          createElementWithClassName('div', 'no-results-message'));
1302      noResults.textContent = loadTimeData.getString('noResults');
1303      this.resultDiv_.appendChild(resultsFragment);
1304      return;
1305    }
1306
1307    if (this.getRangeInDays() == HistoryModel.Range.MONTH &&
1308        groupByDomain) {
1309      // Group everything together in the month view.
1310      this.addMonthResults_(results, resultsFragment);
1311    } else {
1312      var dayStart = 0;
1313      var dayEnd = 0;
1314      // Go through all of the visits and process them in chunks of one day.
1315      while (dayEnd < results.length) {
1316        // Skip over the ones that are already rendered.
1317        while (dayStart < results.length && results[dayStart].isRendered)
1318          ++dayStart;
1319        var dayEnd = dayStart + 1;
1320        while (dayEnd < results.length && results[dayEnd].continued)
1321          ++dayEnd;
1322
1323        this.addDayResults_(
1324            results.slice(dayStart, dayEnd), resultsFragment, groupByDomain);
1325      }
1326    }
1327
1328    // Add all the days and their visits to the page.
1329    this.resultDiv_.appendChild(resultsFragment);
1330  }
1331  // After the results have been added to the DOM, determine the size of the
1332  // time column.
1333  this.setTimeColumnWidth_(this.resultDiv_);
1334};
1335
1336/**
1337 * Update the visibility of the page navigation buttons.
1338 * @private
1339 */
1340HistoryView.prototype.updateNavBar_ = function() {
1341  this.updateRangeButtons_();
1342
1343  // Managed users have the control bar on top, don't show it on the bottom
1344  // as well.
1345  if (!loadTimeData.getBoolean('isManagedProfile')) {
1346    $('newest-button').hidden = this.pageIndex_ == 0;
1347    $('newer-button').hidden = this.pageIndex_ == 0;
1348    $('older-button').hidden =
1349        this.model_.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
1350        !this.model_.hasMoreResults();
1351  }
1352};
1353
1354/**
1355 * Updates the visibility of the 'Clear browsing data' button.
1356 * Only used on mobile platforms.
1357 * @private
1358 */
1359HistoryView.prototype.updateClearBrowsingDataButton_ = function() {
1360  // Ideally, we should hide the 'Clear browsing data' button whenever the
1361  // soft keyboard is visible. This is not possible, so instead, hide the
1362  // button whenever the search field has focus.
1363  $('clear-browsing-data').hidden =
1364      (document.activeElement === $('search-field'));
1365};
1366
1367/**
1368 * Dynamically sets the min-width of the time column for history entries.
1369 * This ensures that all entry times will have the same width, without
1370 * imposing a fixed width that may not be appropriate for some locales.
1371 * @private
1372 */
1373HistoryView.prototype.setTimeColumnWidth_ = function() {
1374  // Find the maximum width of all the time elements on the page.
1375  var times = this.resultDiv_.querySelectorAll('.entry .time');
1376  var widths = Array.prototype.map.call(times, function(el) {
1377    el.style.minWidth = '-webkit-min-content';
1378    var width = el.clientWidth;
1379    el.style.minWidth = '';
1380
1381    // Add an extra pixel to prevent rounding errors from causing the text to
1382    // be ellipsized at certain zoom levels (see crbug.com/329779).
1383    return width + 1;
1384  });
1385  var maxWidth = widths.length ? Math.max.apply(null, widths) : 0;
1386
1387  // Add a dynamic stylesheet to the page (or replace the existing one), to
1388  // ensure that all entry times have the same width.
1389  var styleEl = $('timeColumnStyle');
1390  if (!styleEl) {
1391    styleEl = document.head.appendChild(document.createElement('style'));
1392    styleEl.id = 'timeColumnStyle';
1393  }
1394  styleEl.textContent = '.entry .time { min-width: ' + maxWidth + 'px; }';
1395};
1396
1397///////////////////////////////////////////////////////////////////////////////
1398// State object:
1399/**
1400 * An 'AJAX-history' implementation.
1401 * @param {HistoryModel} model The model we're representing.
1402 * @param {HistoryView} view The view we're representing.
1403 * @constructor
1404 */
1405function PageState(model, view) {
1406  // Enforce a singleton.
1407  if (PageState.instance) {
1408    return PageState.instance;
1409  }
1410
1411  this.model = model;
1412  this.view = view;
1413
1414  if (typeof this.checker_ != 'undefined' && this.checker_) {
1415    clearInterval(this.checker_);
1416  }
1417
1418  // TODO(glen): Replace this with a bound method so we don't need
1419  //     public model and view.
1420  this.checker_ = window.setInterval(function(stateObj) {
1421    var hashData = stateObj.getHashData();
1422    var page = parseInt(hashData.page, 10);
1423    var range = parseInt(hashData.range, 10);
1424    var offset = parseInt(hashData.offset, 10);
1425    if (hashData.q != stateObj.model.getSearchText() ||
1426        page != stateObj.view.getPage() ||
1427        range != stateObj.model.rangeInDays ||
1428        offset != stateObj.model.offset) {
1429      stateObj.view.setPageState(hashData.q, page, range, offset);
1430    }
1431  }, 50, this);
1432}
1433
1434/**
1435 * Holds the singleton instance.
1436 */
1437PageState.instance = null;
1438
1439/**
1440 * @return {Object} An object containing parameters from our window hash.
1441 */
1442PageState.prototype.getHashData = function() {
1443  var result = {
1444    q: '',
1445    page: 0,
1446    grouped: false,
1447    range: 0,
1448    offset: 0
1449  };
1450
1451  if (!window.location.hash)
1452    return result;
1453
1454  var hashSplit = window.location.hash.substr(1).split('&');
1455  for (var i = 0; i < hashSplit.length; i++) {
1456    var pair = hashSplit[i].split('=');
1457    if (pair.length > 1) {
1458      result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' '));
1459    }
1460  }
1461
1462  return result;
1463};
1464
1465/**
1466 * Set the hash to a specified state, this will create an entry in the
1467 * session history so the back button cycles through hash states, which
1468 * are then picked up by our listener.
1469 * @param {string} term The current search string.
1470 * @param {number} page The page currently being viewed.
1471 * @param {HistoryModel.Range} range The range to view or search over.
1472 * @param {number} offset Set the begining of the query to the specific offset.
1473 */
1474PageState.prototype.setUIState = function(term, page, range, offset) {
1475  // Make sure the form looks pretty.
1476  $('search-field').value = term;
1477  var hash = this.getHashData();
1478  if (hash.q != term || hash.page != page || hash.range != range ||
1479      hash.offset != offset) {
1480    window.location.hash = PageState.getHashString(term, page, range, offset);
1481  }
1482};
1483
1484/**
1485 * Static method to get the hash string for a specified state
1486 * @param {string} term The current search string.
1487 * @param {number} page The page currently being viewed.
1488 * @param {HistoryModel.Range} range The range to view or search over.
1489 * @param {number} offset Set the begining of the query to the specific offset.
1490 * @return {string} The string to be used in a hash.
1491 */
1492PageState.getHashString = function(term, page, range, offset) {
1493  // Omit elements that are empty.
1494  var newHash = [];
1495
1496  if (term)
1497    newHash.push('q=' + encodeURIComponent(term));
1498
1499  if (page)
1500    newHash.push('page=' + page);
1501
1502  if (range)
1503    newHash.push('range=' + range);
1504
1505  if (offset)
1506    newHash.push('offset=' + offset);
1507
1508  return newHash.join('&');
1509};
1510
1511///////////////////////////////////////////////////////////////////////////////
1512// Document Functions:
1513/**
1514 * Window onload handler, sets up the page.
1515 */
1516function load() {
1517  uber.onContentFrameLoaded();
1518
1519  var searchField = $('search-field');
1520
1521  historyModel = new HistoryModel();
1522  historyView = new HistoryView(historyModel);
1523  pageState = new PageState(historyModel, historyView);
1524
1525  // Create default view.
1526  var hashData = pageState.getHashData();
1527  var grouped = (hashData.grouped == 'true') || historyModel.getGroupByDomain();
1528  var page = parseInt(hashData.page, 10) || historyView.getPage();
1529  var range = parseInt(hashData.range, 10) || historyView.getRangeInDays();
1530  var offset = parseInt(hashData.offset, 10) || historyView.getOffset();
1531  historyView.setPageState(hashData.q, page, range, offset);
1532
1533  if ($('overlay')) {
1534    cr.ui.overlay.setupOverlay($('overlay'));
1535    cr.ui.overlay.globalInitialization();
1536  }
1537  HistoryFocusManager.getInstance().initialize();
1538
1539  var doSearch = function(e) {
1540    recordUmaAction('HistoryPage_Search');
1541    historyView.setSearch(searchField.value);
1542
1543    if (isMobileVersion())
1544      searchField.blur();  // Dismiss the keyboard.
1545  };
1546
1547  var mayRemoveVisits = loadTimeData.getBoolean('allowDeletingHistory');
1548  $('remove-visit').disabled = !mayRemoveVisits;
1549
1550  if (mayRemoveVisits) {
1551    $('remove-visit').addEventListener('activate', function(e) {
1552      activeVisit.removeFromHistory();
1553      activeVisit = null;
1554    });
1555  }
1556
1557  if (!loadTimeData.getBoolean('showDeleteVisitUI'))
1558    $('remove-visit').hidden = true;
1559
1560  searchField.addEventListener('search', doSearch);
1561  $('search-button').addEventListener('click', doSearch);
1562
1563  $('more-from-site').addEventListener('activate', function(e) {
1564    activeVisit.showMoreFromSite_();
1565    activeVisit = null;
1566  });
1567
1568  // Only show the controls if the command line switch is activated.
1569  if (loadTimeData.getBoolean('groupByDomain') ||
1570      loadTimeData.getBoolean('isManagedProfile')) {
1571    // Hide the top container which has the "Clear browsing data" and "Remove
1572    // selected entries" buttons since they're unavailable in managed mode
1573    $('top-container').hidden = true;
1574    $('history-page').classList.add('big-topbar-page');
1575    $('filter-controls').hidden = false;
1576  }
1577
1578  uber.setTitle(loadTimeData.getString('title'));
1579
1580  // Adjust the position of the notification bar when the window size changes.
1581  window.addEventListener('resize',
1582      historyView.positionNotificationBar.bind(historyView));
1583
1584  cr.ui.FocusManager.disableMouseFocusOnButtons();
1585
1586  if (isMobileVersion()) {
1587    // Move the search box out of the header.
1588    var resultsDisplay = $('results-display');
1589    resultsDisplay.parentNode.insertBefore($('search-field'), resultsDisplay);
1590
1591    window.addEventListener(
1592        'resize', historyView.updateClearBrowsingDataButton_);
1593
1594    // When the search field loses focus, add a delay before updating the
1595    // visibility, otherwise the button will flash on the screen before the
1596    // keyboard animates away.
1597    searchField.addEventListener('blur', function() {
1598      setTimeout(historyView.updateClearBrowsingDataButton_, 250);
1599    });
1600
1601    // Move the button to the bottom of the page.
1602    $('history-page').appendChild($('clear-browsing-data'));
1603  } else {
1604    window.addEventListener('message', function(e) {
1605      if (e.data.method == 'frameSelected')
1606        searchField.focus();
1607    });
1608    searchField.focus();
1609  }
1610
1611<if expr="is_ios">
1612  function checkKeyboardVisibility() {
1613    // Figure out the real height based on the orientation, becauase
1614    // screen.width and screen.height don't update after rotation.
1615    var screenHeight = window.orientation % 180 ? screen.width : screen.height;
1616
1617    // Assume that the keyboard is visible if more than 30% of the screen is
1618    // taken up by window chrome.
1619    var isKeyboardVisible = (window.innerHeight / screenHeight) < 0.7;
1620
1621    document.body.classList.toggle('ios-keyboard-visible', isKeyboardVisible);
1622  }
1623  window.addEventListener('orientationchange', checkKeyboardVisibility);
1624  window.addEventListener('resize', checkKeyboardVisibility);
1625</if> /* is_ios */
1626}
1627
1628/**
1629 * Updates the managed filter status labels of a host/URL entry to the current
1630 * value.
1631 * @param {Element} statusElement The div which contains the status labels.
1632 * @param {ManagedModeFilteringBehavior} newStatus The filter status of the
1633 *     current domain/URL.
1634 */
1635function updateHostStatus(statusElement, newStatus) {
1636  var filteringBehaviorDiv =
1637      statusElement.querySelector('.filtering-behavior');
1638  // Reset to the base class first, then add modifier classes if needed.
1639  filteringBehaviorDiv.className = 'filtering-behavior';
1640  if (newStatus == ManagedModeFilteringBehavior.BLOCK) {
1641    filteringBehaviorDiv.textContent =
1642        loadTimeData.getString('filterBlocked');
1643    filteringBehaviorDiv.classList.add('filter-blocked');
1644  } else {
1645    filteringBehaviorDiv.textContent = '';
1646  }
1647}
1648
1649/**
1650 * Click handler for the 'Clear browsing data' dialog.
1651 * @param {Event} e The click event.
1652 */
1653function openClearBrowsingData(e) {
1654  recordUmaAction('HistoryPage_InitClearBrowsingData');
1655  chrome.send('clearBrowsingData');
1656}
1657
1658/**
1659 * Shows the dialog for the user to confirm removal of selected history entries.
1660 */
1661function showConfirmationOverlay() {
1662  $('alertOverlay').classList.add('showing');
1663  $('overlay').hidden = false;
1664  uber.invokeMethodOnParent('beginInterceptingEvents');
1665}
1666
1667/**
1668 * Hides the confirmation overlay used to confirm selected history entries.
1669 */
1670function hideConfirmationOverlay() {
1671  $('alertOverlay').classList.remove('showing');
1672  $('overlay').hidden = true;
1673  uber.invokeMethodOnParent('stopInterceptingEvents');
1674}
1675
1676/**
1677 * Shows the confirmation alert for history deletions and permits browser tests
1678 * to override the dialog.
1679 * @param {function=} okCallback A function to be called when the user presses
1680 *     the ok button.
1681 * @param {function=} cancelCallback A function to be called when the user
1682 *     presses the cancel button.
1683 */
1684function confirmDeletion(okCallback, cancelCallback) {
1685  alertOverlay.setValues(
1686      loadTimeData.getString('removeSelected'),
1687      loadTimeData.getString('deleteWarning'),
1688      loadTimeData.getString('cancel'),
1689      loadTimeData.getString('deleteConfirm'),
1690      cancelCallback,
1691      okCallback);
1692  showConfirmationOverlay();
1693}
1694
1695/**
1696 * Click handler for the 'Remove selected items' button.
1697 * Confirms the deletion with the user, and then deletes the selected visits.
1698 */
1699function removeItems() {
1700  recordUmaAction('HistoryPage_RemoveSelected');
1701  if (!loadTimeData.getBoolean('allowDeletingHistory'))
1702    return;
1703
1704  var checked = $('results-display').querySelectorAll(
1705      '.entry-box input[type=checkbox]:checked:not([disabled])');
1706  var disabledItems = [];
1707  var toBeRemoved = [];
1708
1709  for (var i = 0; i < checked.length; i++) {
1710    var checkbox = checked[i];
1711    var entry = findAncestorByClass(checkbox, 'entry');
1712    toBeRemoved.push(entry.visit);
1713
1714    // Disable the checkbox and put a strikethrough style on the link, so the
1715    // user can see what will be deleted.
1716    var link = entry.querySelector('a');
1717    checkbox.disabled = true;
1718    link.classList.add('to-be-removed');
1719    disabledItems.push(checkbox);
1720    var integerId = parseInt(entry.visit.id_, 10);
1721    // Record the ID of the entry to signify how many entries are above this
1722    // link on the page.
1723    recordUmaHistogram('HistoryPage.RemoveEntryPosition',
1724                       UMA_MAX_BUCKET_VALUE,
1725                       integerId);
1726    if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
1727      recordUmaHistogram('HistoryPage.RemoveEntryPositionSubset',
1728                         UMA_MAX_SUBSET_BUCKET_VALUE,
1729                         integerId);
1730    }
1731    if (entry.parentNode.className == 'search-results')
1732      recordUmaAction('HistoryPage_SearchResultRemove');
1733  }
1734
1735  function onConfirmRemove() {
1736    recordUmaAction('HistoryPage_ConfirmRemoveSelected');
1737    historyModel.removeVisitsFromHistory(toBeRemoved,
1738        historyView.reload.bind(historyView));
1739    $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
1740    hideConfirmationOverlay();
1741  }
1742
1743  function onCancelRemove() {
1744    recordUmaAction('HistoryPage_CancelRemoveSelected');
1745    // Return everything to its previous state.
1746    for (var i = 0; i < disabledItems.length; i++) {
1747      var checkbox = disabledItems[i];
1748      checkbox.disabled = false;
1749
1750      var entryBox = findAncestorByClass(checkbox, 'entry-box');
1751      entryBox.querySelector('a').classList.remove('to-be-removed');
1752    }
1753    $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
1754    hideConfirmationOverlay();
1755  }
1756
1757  if (checked.length) {
1758    confirmDeletion(onConfirmRemove, onCancelRemove);
1759    $('overlay').addEventListener('cancelOverlay', onCancelRemove);
1760  }
1761}
1762
1763/**
1764 * Handler for the 'click' event on a checkbox.
1765 * @param {Event} e The click event.
1766 */
1767function checkboxClicked(e) {
1768  handleCheckboxStateChange(e.currentTarget, e.shiftKey);
1769}
1770
1771/**
1772 * Post-process of checkbox state change. This handles range selection and
1773 * updates internal state.
1774 * @param {!HTMLInputElement} checkbox Clicked checkbox.
1775 * @param {boolean} shiftKey true if shift key is pressed.
1776 */
1777function handleCheckboxStateChange(checkbox, shiftKey) {
1778  updateParentCheckbox(checkbox);
1779  var id = Number(checkbox.id.slice('checkbox-'.length));
1780  // Handle multi-select if shift was pressed.
1781  if (shiftKey && (selectionAnchor != -1)) {
1782    var checked = checkbox.checked;
1783    // Set all checkboxes from the anchor up to the clicked checkbox to the
1784    // state of the clicked one.
1785    var begin = Math.min(id, selectionAnchor);
1786    var end = Math.max(id, selectionAnchor);
1787    for (var i = begin; i <= end; i++) {
1788      var checkbox = document.querySelector('#checkbox-' + i);
1789      if (checkbox) {
1790        checkbox.checked = checked;
1791        updateParentCheckbox(checkbox);
1792      }
1793    }
1794  }
1795  selectionAnchor = id;
1796
1797  historyView.updateSelectionEditButtons();
1798}
1799
1800/**
1801 * Handler for the 'click' event on a domain checkbox. Checkes or unchecks the
1802 * checkboxes of the visits to this domain in the respective group.
1803 * @param {Event} e The click event.
1804 */
1805function domainCheckboxClicked(e) {
1806  var siteEntry = findAncestorByClass(e.currentTarget, 'site-entry');
1807  var checkboxes =
1808      siteEntry.querySelectorAll('.site-results input[type=checkbox]');
1809  for (var i = 0; i < checkboxes.length; i++)
1810    checkboxes[i].checked = e.currentTarget.checked;
1811  historyView.updateSelectionEditButtons();
1812  // Stop propagation as clicking the checkbox would otherwise trigger the
1813  // group to collapse/expand.
1814  e.stopPropagation();
1815}
1816
1817/**
1818 * Updates the domain checkbox for this visit checkbox if it has been
1819 * unchecked.
1820 * @param {Element} checkbox The checkbox that has been clicked.
1821 */
1822function updateParentCheckbox(checkbox) {
1823  if (checkbox.checked)
1824    return;
1825
1826  var entry = findAncestorByClass(checkbox, 'site-entry');
1827  if (!entry)
1828    return;
1829
1830  var groupCheckbox = entry.querySelector('.site-domain-wrapper input');
1831  if (groupCheckbox)
1832      groupCheckbox.checked = false;
1833}
1834
1835function entryBoxMousedown(event) {
1836  // Prevent text selection when shift-clicking to select multiple entries.
1837  if (event.shiftKey)
1838    event.preventDefault();
1839}
1840
1841/**
1842 * Handle click event for entryBox labels.
1843 * @param {!MouseEvent} event A click event.
1844 */
1845function entryBoxClick(event) {
1846  // Do nothing if a bookmark star is clicked.
1847  if (event.defaultPrevented)
1848    return;
1849  var element = event.target;
1850  // Do nothing if the event happened in an interactive element.
1851  for (; element != event.currentTarget; element = element.parentNode) {
1852    switch (element.tagName) {
1853      case 'A':
1854      case 'BUTTON':
1855      case 'INPUT':
1856        return;
1857    }
1858  }
1859  var checkbox = event.currentTarget.control;
1860  checkbox.checked = !checkbox.checked;
1861  handleCheckboxStateChange(checkbox, event.shiftKey);
1862  // We don't want to focus on the checkbox.
1863  event.preventDefault();
1864}
1865
1866/**
1867 * Called when an individual history entry has been removed from the page.
1868 * This will only be called when all the elements affected by the deletion
1869 * have been removed from the DOM and the animations have completed.
1870 */
1871function onEntryRemoved() {
1872  historyView.updateSelectionEditButtons();
1873}
1874
1875/**
1876 * Triggers a fade-out animation, and then removes |node| from the DOM.
1877 * @param {Node} node The node to be removed.
1878 * @param {Function?} onRemove A function to be called after the node
1879 *     has been removed from the DOM.
1880 */
1881function removeNode(node, onRemove) {
1882  node.classList.add('fade-out'); // Trigger CSS fade out animation.
1883
1884  // Delete the node when the animation is complete.
1885  node.addEventListener('webkitTransitionEnd', function(e) {
1886    node.parentNode.removeChild(node);
1887
1888    // In case there is nested deletion happening, prevent this event from
1889    // being handled by listeners on ancestor nodes.
1890    e.stopPropagation();
1891
1892    if (onRemove)
1893      onRemove();
1894  });
1895}
1896
1897/**
1898 * Removes a single entry from the view. Also removes gaps before and after
1899 * entry if necessary.
1900 * @param {Node} entry The DOM node representing the entry to be removed.
1901 */
1902function removeEntryFromView(entry) {
1903  var nextEntry = entry.nextSibling;
1904  var previousEntry = entry.previousSibling;
1905  var dayResults = findAncestorByClass(entry, 'day-results');
1906
1907  var toRemove = [entry];
1908
1909  // if there is no previous entry, and the next entry is a gap, remove it
1910  if (!previousEntry && nextEntry && nextEntry.className == 'gap')
1911    toRemove.push(nextEntry);
1912
1913  // if there is no next entry, and the previous entry is a gap, remove it
1914  if (!nextEntry && previousEntry && previousEntry.className == 'gap')
1915    toRemove.push(previousEntry);
1916
1917  // if both the next and previous entries are gaps, remove one
1918  if (nextEntry && nextEntry.className == 'gap' &&
1919      previousEntry && previousEntry.className == 'gap') {
1920    toRemove.push(nextEntry);
1921  }
1922
1923  // If removing the last entry on a day, remove the entire day.
1924  if (dayResults && dayResults.querySelectorAll('.entry').length == 1) {
1925    toRemove.push(dayResults.previousSibling);  // Remove the 'h3'.
1926    toRemove.push(dayResults);
1927  }
1928
1929  // Callback to be called when each node has finished animating. It detects
1930  // when all the animations have completed, and then calls |onEntryRemoved|.
1931  function onRemove() {
1932    for (var i = 0; i < toRemove.length; ++i) {
1933      if (toRemove[i].parentNode)
1934        return;
1935    }
1936    onEntryRemoved();
1937  }
1938
1939  // Kick off the removal process.
1940  for (var i = 0; i < toRemove.length; ++i) {
1941    removeNode(toRemove[i], onRemove);
1942  }
1943}
1944
1945/**
1946 * Toggles an element in the grouped history.
1947 * @param {Element} e The element which was clicked on.
1948 */
1949function toggleHandler(e) {
1950  var innerResultList = e.currentTarget.parentElement.querySelector(
1951      '.site-results');
1952  var innerArrow = e.currentTarget.parentElement.querySelector(
1953      '.site-domain-arrow');
1954  if (innerArrow.classList.contains('collapse')) {
1955    innerResultList.style.height = 'auto';
1956    // -webkit-transition does not work on height:auto elements so first set
1957    // the height to auto so that it is computed and then set it to the
1958    // computed value in pixels so the transition works properly.
1959    var height = innerResultList.clientHeight;
1960    innerResultList.style.height = 0;
1961    setTimeout(function() {
1962      innerResultList.style.height = height + 'px';
1963    }, 0);
1964    innerArrow.classList.remove('collapse');
1965    innerArrow.classList.add('expand');
1966  } else {
1967    innerResultList.style.height = 0;
1968    innerArrow.classList.remove('expand');
1969    innerArrow.classList.add('collapse');
1970  }
1971}
1972
1973/**
1974 * Builds the DOM elements to show the managed status of a domain/URL.
1975 * @param {ManagedModeFilteringBehavior} filteringBehavior The filter behavior
1976 *     for this item.
1977 * @return {Element} Returns the DOM elements which show the status.
1978 */
1979function getManagedStatusDOM(filteringBehavior) {
1980  var filterStatusDiv = createElementWithClassName('div', 'filter-status');
1981  var filteringBehaviorDiv =
1982      createElementWithClassName('div', 'filtering-behavior');
1983  filterStatusDiv.appendChild(filteringBehaviorDiv);
1984
1985  updateHostStatus(filterStatusDiv, filteringBehavior);
1986  return filterStatusDiv;
1987}
1988
1989
1990///////////////////////////////////////////////////////////////////////////////
1991// Chrome callbacks:
1992
1993/**
1994 * Our history system calls this function with results from searches.
1995 * @param {Object} info An object containing information about the query.
1996 * @param {Array} results A list of results.
1997 */
1998function historyResult(info, results) {
1999  historyModel.addResults(info, results);
2000}
2001
2002/**
2003 * Called by the history backend when history removal is successful.
2004 */
2005function deleteComplete() {
2006  historyModel.deleteComplete();
2007}
2008
2009/**
2010 * Called by the history backend when history removal is unsuccessful.
2011 */
2012function deleteFailed() {
2013  window.console.log('Delete failed');
2014}
2015
2016/**
2017 * Called when the history is deleted by someone else.
2018 */
2019function historyDeleted() {
2020  var anyChecked = document.querySelector('.entry input:checked') != null;
2021  // Reload the page, unless the user has any items checked.
2022  // TODO(dubroy): We should just reload the page & restore the checked items.
2023  if (!anyChecked)
2024    historyView.reload();
2025}
2026
2027// Add handlers to HTML elements.
2028document.addEventListener('DOMContentLoaded', load);
2029
2030// This event lets us enable and disable menu items before the menu is shown.
2031document.addEventListener('canExecute', function(e) {
2032  e.canExecute = true;
2033});
2034