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