summaryrefslogtreecommitdiffstats
path: root/ios
diff options
context:
space:
mode:
authordroger <droger@chromium.org>2016-01-14 08:55:36 -0800
committerCommit bot <commit-bot@chromium.org>2016-01-14 16:57:14 +0000
commitfad78b3b680b864536515744b1b2ba0ca4a642ae (patch)
tree4bbf76b1b7c344c853954704ffee6ca9af393039 /ios
parente4e16091487cf5a42167c3b48d3dc7fda85cb9a0 (diff)
downloadchromium_src-fad78b3b680b864536515744b1b2ba0ca4a642ae.zip
chromium_src-fad78b3b680b864536515744b1b2ba0ca4a642ae.tar.gz
chromium_src-fad78b3b680b864536515744b1b2ba0ca4a642ae.tar.bz2
[iOS] Duplicate HTML resources for chrome://history
References to unused //chrome resources are removed on iOS. This duplication is temporary until a native UI is implemented on iOS, and is done to unblock the upstreaming of iOS code. NOPRESUBMIT=true BUG=554966 Review URL: https://codereview.chromium.org/1583493002 Cr-Commit-Position: refs/heads/master@{#369434}
Diffstat (limited to 'ios')
-rw-r--r--ios/chrome/app/resources/history/OWNERS1
-rw-r--r--ios/chrome/app/resources/history/alert_overlay.css20
-rw-r--r--ios/chrome/app/resources/history/alert_overlay.html13
-rw-r--r--ios/chrome/app/resources/history/history.css631
-rw-r--r--ios/chrome/app/resources/history/history.html115
-rw-r--r--ios/chrome/app/resources/history/history.js2328
-rw-r--r--ios/chrome/app/resources/history/history_mobile.css330
-rw-r--r--ios/chrome/app/resources/history/other_devices.js573
-rw-r--r--ios/chrome/app/resources/ios_resources.grd3
9 files changed, 4014 insertions, 0 deletions
diff --git a/ios/chrome/app/resources/history/OWNERS b/ios/chrome/app/resources/history/OWNERS
new file mode 100644
index 0000000..936c033
--- /dev/null
+++ b/ios/chrome/app/resources/history/OWNERS
@@ -0,0 +1 @@
+jyquinn@chromium.org
diff --git a/ios/chrome/app/resources/history/alert_overlay.css b/ios/chrome/app/resources/history/alert_overlay.css
new file mode 100644
index 0000000..5c6409a
--- /dev/null
+++ b/ios/chrome/app/resources/history/alert_overlay.css
@@ -0,0 +1,20 @@
+/* Copyright 2013 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file. */
+
+.overlay .page .button-strip > button {
+ min-width: 7em;
+}
+
+.overlay {
+ z-index: 4;
+}
+
+#alertOverlayMessage {
+ width: 400px;
+}
+
+#alertOverlayMessage {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+} \ No newline at end of file
diff --git a/ios/chrome/app/resources/history/alert_overlay.html b/ios/chrome/app/resources/history/alert_overlay.html
new file mode 100644
index 0000000..34494d6
--- /dev/null
+++ b/ios/chrome/app/resources/history/alert_overlay.html
@@ -0,0 +1,13 @@
+<div id="alertOverlay" class="page">
+ <div class="close-button"></div>
+ <h1 id="alertOverlayTitle"></h1>
+ <div class="content-area">
+ <div id="alertOverlayMessage"></div>
+ </div>
+ <div class="action-area">
+ <div class="button-strip">
+ <button id="alertOverlayCancel" type="reset"></button>
+ <button id="alertOverlayOk" class="default-button" type="submit"></button>
+ </div>
+ </div>
+</div>
diff --git a/ios/chrome/app/resources/history/history.css b/ios/chrome/app/resources/history/history.css
new file mode 100644
index 0000000..dd04428
--- /dev/null
+++ b/ios/chrome/app/resources/history/history.css
@@ -0,0 +1,631 @@
+/* Copyright (c) 2012 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file. */
+
+html[dir='rtl'] body.uber-frame > .page {
+ -webkit-margin-end: 0;
+}
+
+body.uber-frame > .page.big-topbar-page {
+ padding-top: 78px;
+}
+
+#top-container {
+ margin-top: 16px;
+ overflow: hidden;
+}
+
+#editing-controls,
+#loading-spinner {
+ white-space: nowrap;
+}
+
+#search-button {
+ margin: 0;
+}
+
+#spinner {
+ -webkit-margin-end: 5px;
+ vertical-align: bottom;
+}
+
+#notification-bar {
+ float: right;
+ padding-top: 5px;
+}
+
+html[dir='rtl'] #notification-bar {
+ float: left;
+}
+
+#notification-bar.alone {
+ float: left;
+ margin-top: 12px;
+}
+
+html[dir='rtl'] #notification-bar.alone {
+ float: right;
+}
+
+#filter-controls,
+#top-container,
+#results-display,
+#results-pagination {
+ max-width: 718px;
+}
+
+#filter-controls {
+ display: flex;
+ margin-bottom: 4px;
+ margin-top: 4px;
+}
+
+#filter-controls > * {
+ flex: 1;
+}
+
+#editing-controls {
+ -webkit-margin-end: 12px;
+ float: left;
+}
+
+html[dir='rtl'] #editing-controls {
+ float: right;
+}
+
+#editing-controls button:first-of-type {
+ -webkit-margin-start: 0;
+}
+
+#range-next,
+#range-previous {
+ background-position: center;
+ background-repeat: no-repeat;
+ border-radius: 0 2px 2px 0;
+}
+
+#range-next:disabled,
+#range-previous:disabled {
+ border-color: rgba(67, 67, 67, 0.5);
+ opacity: 0.5;
+}
+
+html[dir='rtl'] #range-today,
+html[dir='rtl'] #range-previous,
+html[dir='rtl'] #range-next {
+ float: right;
+}
+
+html[dir='rtl'] #range-next,
+#range-previous {
+ -webkit-transform: scalex(-1);
+}
+
+html[dir='rtl'] #range-previous {
+ -webkit-transform: scaleX(1);
+}
+
+#range-today {
+ -webkit-margin-end: 10px;
+}
+
+#range-today,
+#range-previous,
+#range-next {
+ float: left;
+ height: 26px;
+ padding-bottom: 4px;
+ padding-top: 4px;
+}
+
+#range-next {
+ -webkit-margin-start: -1px;
+}
+
+#range-previous {
+ -webkit-margin-end: 0;
+}
+
+#timeframe-controls {
+ display: flex;
+ justify-content: flex-end;
+}
+
+#timeframe-controls input[type='radio'] {
+ background-color: buttonface;
+ background-image: -webkit-linear-gradient(rgb(237, 237, 237),
+ rgb(237, 237, 237) 38%,
+ rgb(222, 222, 222));
+ border: 1px solid rgba(0, 0, 0, 0.25);
+ border-radius: 0;
+ bottom: auto;
+ box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08),
+ inset 0 1px 2px rgba(255, 255, 255, 0.75);
+ box-sizing: border-box;
+ color: rgb(68, 68, 68);
+ display: inline-block;
+ height: 100%;
+ letter-spacing: normal;
+ line-height: 2em;
+ margin: 0;
+ min-height: 2em;
+ min-width: 4em;
+ text-align: center;
+ text-indent: 0;
+ text-shadow: 0 1px 0 rgb(240, 240, 240);
+ text-transform: none;
+ vertical-align: middle;
+ width: auto;
+ word-spacing: normal;
+}
+
+#timeframe-controls input[type='radio']:not(:first-of-type) {
+ -webkit-border-start-width: 0;
+}
+
+#timeframe-controls input[type='radio']:not(:first-of-type):focus {
+ -webkit-border-start-width: 1px;
+ -webkit-margin-start: -1px;
+}
+
+html[dir='ltr'] #timeframe-controls input[type='radio']:first-of-type,
+html[dir='rtl'] #timeframe-controls input[type='radio']:last-of-type {
+ border-bottom-left-radius: 2px;
+ border-top-left-radius: 2px;
+}
+
+html[dir='ltr'] #timeframe-controls input[type='radio']:last-of-type,
+html[dir='rtl'] #timeframe-controls input[type='radio']:first-of-type {
+ border-bottom-right-radius: 2px;
+ border-top-right-radius: 2px;
+}
+
+#timeframe-controls input[type='radio']:checked {
+ background-image: -webkit-linear-gradient(rgb(185, 185, 185),
+ rgb(216, 216, 216) 38%,
+ rgb(167, 167, 167));
+}
+
+#timeframe-controls input[type='radio']:focus {
+ border-color: rgb(77, 144, 254);
+}
+
+#timeframe-controls input[type='radio']::before {
+ display: none; /* Hide the ( )/(O). */
+}
+
+#timeframe-controls input[type='radio']::after {
+ content: attr(aria-label);
+ padding: 0 1em;
+}
+
+#results-display {
+ margin: 16px 0 0 0;
+}
+
+.edit-button {
+ -webkit-appearance: none;
+ background: none;
+ border: 0;
+ color: blue; /* -webkit-link makes it purple :'( */
+ cursor: pointer;
+ display: inline-block;
+ font: inherit;
+ padding: 0 9px;
+ text-decoration: underline;
+}
+
+.entry,
+.gap,
+.no-entries,
+.site-entry {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.gap {
+ -webkit-border-end: 1px solid rgb(192, 195, 198);
+ height: 14px;
+ margin: 1px 0;
+ width: 45px;
+}
+
+.no-checkboxes .gap {
+ width: 25px;
+}
+
+.entry-box,
+.site-domain-row {
+ align-items: center;
+ display: flex;
+ margin-bottom: 6px;
+ /* The box should be no bigger than its parent. */
+ max-width: 100%;
+ min-height: 2em;
+ overflow: hidden;
+ padding-bottom: 1px;
+}
+
+.entry-box {
+ /* Ensures a consistent baseline on all platforms. */
+ line-height: 1.75em;
+}
+
+.site-domain-wrapper {
+ cursor: pointer;
+ display: flex;
+ width: 100%;
+}
+
+.search-results,
+.day-results {
+ margin: 0 0 24px 0;
+ padding: 0;
+}
+
+.site-results {
+ -webkit-transition: height 350ms ease-in-out;
+ clear: left;
+ margin: 0;
+ overflow: hidden;
+ padding: 0;
+}
+
+.site-results.grouped {
+ -webkit-padding-start: 18px;
+}
+
+.no-checkboxes .site-results.grouped {
+ -webkit-padding-start: 21px;
+}
+
+.month-results {
+ -webkit-padding-start: 0;
+}
+
+html[dir='rtl'] .site-results {
+ clear: both;
+}
+
+h2.timeframe {
+ font-size: 1.5em;
+}
+
+.entry .domain {
+ -webkit-padding-end: 6px;
+ -webkit-padding-start: 2px;
+ color: rgb(151, 156, 160);
+ min-width: -webkit-min-content;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.site-results .domain {
+ display: none;
+}
+
+html[dir='rtl'] .number-visits {
+ /* This element contains parentheses, which without the unicode-bidi: embed
+ * directive would show up incorrectly (e.g. '(www.google.com (5'). Using
+ * 'embed' makes the engine set the text in the parentheses as LTR even
+ * when the layout is set to RTL, which makes using -webkit-*-start
+ * impossible. So use margins and dir='rtl'.
+ */
+ direction: rtl;
+ unicode-bidi: embed;
+}
+
+.number-visits {
+ color: rgb(151, 156, 160);
+}
+
+.drop-down {
+ margin-top: 1px;
+}
+
+html[dir='rtl'] .entry .title,
+html[dir='rtl'] .site-domain {
+ /* Put the favicon on the right. */
+ background-position-x: right;
+}
+
+.entry .time {
+ color: rgb(151, 156, 160);
+ max-width: 90px;
+ min-width: -webkit-min-content;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.entry input[type='checkbox'],
+.site-domain-row input[type='checkbox'] {
+ -webkit-margin-end: 6px;
+ line-height: 1em;
+ min-width: 13px;
+ top: 0;
+}
+
+.site-domain-wrapper:hover input[type='checkbox']:not(:focus),
+.site-domain-wrapper input[type='checkbox']:not(:focus):checked,
+.entry-box:hover input[type='checkbox']:not(:focus),
+.entry-box input[type='checkbox']:not(:focus):checked {
+ border-color: rgba(0, 0, 0, .5);
+}
+
+.site-domain-wrapper:hover .site-domain-row,
+.entry-box:hover {
+ background-color: rgba(0, 0, 0, .025);
+}
+
+.filter-status {
+ -webkit-margin-start: 10px;
+}
+
+.filter-status > div {
+ flex: 0 0 auto;
+ -webkit-transition: background-color 150ms;
+ border-radius: 3px;
+ display: none;
+ font-size: 11px;
+ height: 14px;
+ line-height: 12px;
+ white-space: nowrap;
+}
+
+.filter-status > div.filter-allowed,
+.filter-status > div.filter-blocked,
+.filter-status > div.in-content-pack-active,
+.filter-status > div.in-content-pack-passive,
+.filter-status > div.blocked-visit-active {
+ display: block;
+ margin: 3px 3px 3px 0;
+ padding: 0 4px;
+}
+
+.filter-allowed,
+.in-content-pack-active {
+ background-color: rgb(141, 240, 127);
+ border: 1px solid rgb(33, 190, 33);
+ color: rgb(54, 54, 54);
+}
+
+.filter-blocked {
+ border: 1px solid rgb(207, 207, 207);
+ background-color: rgb(231, 231, 231);
+ color: rgb(54, 54, 54);
+}
+
+.in-content-pack-passive {
+ border: 1px solid rgb(155, 224, 163);
+ background-color: rgb(225, 255, 205);
+ color: rgb(148, 148, 148);
+}
+
+.entry-box,
+.site-domain-row {
+ -webkit-padding-end: 6px;
+ -webkit-padding-start: 6px;
+ border-radius: 2px;
+}
+
+.entry-box > div,
+.site-domain-row > div {
+ min-width: 0;
+}
+
+.focus-row-active :-webkit-any(.entry-box, .site-domain-row) {
+ background-color: rgba(0, 0, 0, .05);
+}
+
+.entry-box-container {
+ display: flex;
+}
+
+.entry .visit-entry {
+ display: flex;
+ min-width: 0;
+}
+
+.entry .title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.entry .visit-entry,
+.site-domain {
+ /* Make room for the favicon. */
+ -webkit-padding-start: 16px;
+}
+
+.entry .visit-entry,
+.site-domain,
+.blocked-indicator {
+ /* Control the favicon appearance. */
+ background-position-y: center;
+ background-repeat: no-repeat;
+ background-size: 16px;
+}
+
+html[dir='rtl'] .entry .visit-entry,
+html[dir='rtl'] .site-domain,
+html[dir='rtl'] .blocked-indicator {
+ background-position-x: right;
+}
+
+/* TODO(sergiu): If this is the final icon replace it with a separate resource.
+ */
+.entry .blocked-indicator {
+ -webkit-padding-start: 20px; /* 16px for favicon, 4px for <a> padding. */
+}
+
+.blocked-indicator .title {
+ color: rgb(151, 156, 160);
+}
+
+.site-domain button:hover {
+ text-decoration: none;
+}
+
+.site-domain-arrow {
+ -webkit-transform: rotate(0);
+ -webkit-transition: -webkit-transform 300ms linear;
+ background-position: 5px 5px;
+ color: rgb(143, 143, 143);
+ height: 21px;
+ margin-right: 2px;
+ opacity: 0.58;
+ text-align: center;
+ width: 21px;
+}
+
+html[dir='rtl'] .site-domain-arrow {
+ -webkit-transform: rotate(180deg);
+}
+
+html .expand .site-domain-arrow {
+ -webkit-transform: rotate(90deg);
+}
+
+.entry .bookmark-section {
+ -webkit-margin-end: 3px;
+ -webkit-margin-start: 8px;
+ background: no-repeat center
+ url(../../../../../ui/webui/resources/images/star_small.png);
+ border: none;
+ display: inline-block;
+ height: 15px;
+ min-width: 15px;
+ visibility: hidden;
+}
+
+.entry .starred {
+ visibility: visible;
+}
+
+.entry .title > a,
+.site-domain [is='action-link'] {
+ color: rgb(48, 57, 66);
+ margin: 2px;
+ padding: 2px;
+ /* Focus outlines are rendered differently for each platform. */
+<if expr="is_macosx">
+ margin: 4px;
+ padding: 0;
+</if>
+<if expr="is_win">
+ padding: 0 2px;
+</if>
+ text-decoration: none;
+}
+
+.entry .title > a.to-be-removed {
+ text-decoration: line-through;
+}
+
+.entry .title > a:hover {
+ text-decoration: underline;
+}
+
+.fade-out {
+ -webkit-transition: opacity 200ms;
+ opacity: 0;
+}
+
+button.menu-button.drop-down {
+ -webkit-margin-end: 0;
+ min-width: 12px;
+ top: 0;
+}
+
+#action-menu > [role=menuitem] {
+ line-height: 29px;
+ outline: none;
+}
+
+body:not(.has-results) #results-pagination {
+ display: none;
+}
+
+#older-button {
+ float: right;
+}
+
+html[dir='rtl'] #older-button {
+ float: left;
+}
+
+html[dir='ltr'] #newest-button::before {
+ /* Left-pointing double angle quotation mark followed by '&nbsp;'. */
+ content: '\00AB\A0';
+}
+
+html[dir='rtl'] #newest-button::after {
+ /* '&nbsp;' followed by right-pointing double angle quotation mark. */
+ content: '\A0\00BB';
+}
+
+html[dir='ltr'] #newer-button::before,
+html[dir='rtl'] #older-button::before {
+ /* Single left-pointing angle quotation mark followed by '&nbsp;'. */
+ content: '\2039\A0';
+}
+
+html[dir='ltr'] #older-button::after,
+html[dir='rtl'] #newer-button::after {
+ /* 'nbsp;' followed by single right-pointing angle quotation mark. */
+ content: '\A0\203A';
+}
+
+/* Clear the float to ensure that #results-pagination encloses its children. */
+#results-pagination::after {
+ clear: both;
+ content: '';
+ display: block;
+ height: 0;
+ visibility: hidden;
+}
+
+/* Styles for the action menu of visits that come from other devices, triggered
+ by setting the "data-devicename" attribute of the menu. */
+
+#action-menu[data-devicename]:not([data-devicename='']) {
+ padding-top: 0;
+}
+
+#action-menu[data-devicename]::before {
+ background-color: rgb(245, 245, 245);
+ background-position: 18px center;
+ background-repeat: no-repeat;
+ background-size: 24px;
+ border-bottom: 1px solid rgb(232, 232, 232);
+ color: rgb(151, 156, 160);
+ content: attr(data-devicename);
+ display: block;
+ font-size: 11px;
+ line-height: 29px;
+ margin-bottom: 8px;
+ padding: 0 19px 0 51px;
+}
+
+#action-menu[data-devicename='']::before {
+ display: none;
+}
+
+#action-menu[data-devicetype='laptop']::before {
+ background-image: url(../../../../../ui/webui/resources/images/laptop.svg);
+}
+
+#action-menu[data-devicetype='phone']::before {
+ background-image:
+ url(../../../../../ui/webui/resources/images/smartphone.svg);
+ background-position: 14px center;
+ padding-left: 43px;
+}
+
+#action-menu[data-devicetype='tablet']::before {
+ background-image: url(../../../../../ui/webui/resources/images/tablet.svg);
+}
diff --git a/ios/chrome/app/resources/history/history.html b/ios/chrome/app/resources/history/history.html
new file mode 100644
index 0000000..e7b33b8
--- /dev/null
+++ b/ios/chrome/app/resources/history/history.html
@@ -0,0 +1,115 @@
+<!doctype html>
+<html i18n-values="dir:textdirection;lang:language">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0,
+ maximum-scale=1.0, user-scalable=no">
+<title i18n-content="title"></title>
+<link rel="stylesheet" href="chrome://resources/css/butter_bar.css">
+<link rel="stylesheet" href="chrome://resources/css/chrome_shared.css">
+<link rel="stylesheet" href="chrome://resources/css/menu.css">
+<link rel="stylesheet" href="chrome://resources/css/menu_button.css">
+<link rel="stylesheet" href="chrome://resources/css/spinner.css">
+<link rel="stylesheet" href="history.css">
+<link rel="stylesheet" href="history_mobile.css">
+
+<script src="chrome://resources/js/ios/web_ui.js"></script>
+<script src="chrome://resources/js/action_link.js"></script>
+<script src="chrome://resources/js/assert.js"></script>
+<script src="chrome://resources/js/event_tracker.js"></script>
+<script src="chrome://resources/js/util.js"></script>
+<script src="chrome://resources/js/cr.js"></script>
+<script src="chrome://resources/js/cr/ui.js"></script>
+<script src="chrome://resources/js/cr/ui/command.js"></script>
+<script src="chrome://resources/js/cr/ui/focus_outline_manager.js"></script>
+<script src="chrome://resources/js/cr/ui/focus_row.js"></script>
+<script src="chrome://resources/js/cr/ui/focus_grid.js"></script>
+<script src="chrome://resources/js/cr/ui/menu_item.js"></script>
+<script src="chrome://resources/js/cr/ui/menu.js"></script>
+<script src="chrome://resources/js/cr/ui/position_util.js"></script>
+<script src="chrome://resources/js/cr/ui/menu_button.js"></script>
+<script src="chrome://resources/js/cr/ui/context_menu_button.js"></script>
+<script src="chrome://resources/js/cr/event_target.js"></script>
+<script src="chrome://resources/js/cr/ui/context_menu_handler.js"></script>
+
+<script src="chrome://resources/js/load_time_data.js"></script>
+<script src="chrome://resources/js/util.js"></script>
+
+<script src="chrome://history-frame/history.js"></script>
+<script src="chrome://history-frame/other_devices.js"></script>
+
+</head>
+
+<body>
+
+<div id="history-page" class="page">
+ <div id="scrolling-container">
+ <header>
+ <h1 i18n-content="history"></h1>
+ <div id="search-form" class="search-field-container">
+ <input type="search" id="search-field"
+ i18n-values="aria-label:searchButton">
+ <input type="submit" id="search-button"
+ i18n-values="value:searchButton" aria-controls="results-header">
+ </div>
+ <div id="filter-controls" hidden>
+ <div id="range-controls">
+ <button id="range-today" i18n-content="rangeToday"
+ i18n-values="aria-label:rangeToday" disabled></button>
+ <button id="range-previous" i18n-values="aria-label:rangePrevious"
+ disabled></button>
+ <button id="range-next" i18n-values="aria-label:rangeNext" disabled>
+ </button>
+ </div>
+ <div id="timeframe-controls">
+ <input type="radio" name="timeframe-filter" value="0" checked
+ i18n-values="aria-label:rangeAllTime"
+ aria-controls="results-header">
+ <input type="radio" name="timeframe-filter" value="1"
+ i18n-values="aria-label:rangeWeek"
+ aria-controls="results-header">
+ <input type="radio" name="timeframe-filter" value="2"
+ i18n-values="aria-label:rangeMonth"
+ aria-controls="results-header">
+ </div>
+ </div>
+ </header>
+ <div id="other-devices" class="other-devices"></div>
+ <div id="top-container">
+ <div id="editing-controls">
+ <button id="clear-browsing-data" i18n-content="clearAllHistory">
+ </button>
+ <button id="remove-selected" disabled="disabled"
+ i18n-content="removeSelected"></button>
+ </div>
+ <div id="notification-bar" hidden></div>
+ </div>
+
+ <div id="results-display">
+ <h3 id="results-header" aria-live="polite"></h3>
+ </div>
+ <div id="loading-spinner" hidden>
+ <span id="loading">
+ <div id="spinner" class="inline-spinner"></div>
+ <span i18n-content="loading"></span>
+ </span>
+ </div>
+ <div id="results-pagination">
+ <a is="action-link" id="newest-button" i18n-content="newest" hidden></a>
+ <a is="action-link" id="newer-button" i18n-content="newer" hidden></a>
+ <a is="action-link" id="older-button" i18n-content="older" hidden></a>
+ </div>
+ </div>
+</div>
+
+<command id="remove-visit-command"></command>
+<cr-menu id="action-menu" hidden>
+ <button id="more-from-site" i18n-content="moreFromSite"></button>
+ <button id="remove-visit" i18n-content="removeFromHistory"
+ command="#remove-visit-command"></button>
+</cr-menu>
+
+<script src="chrome://history-frame/strings.js"></script>
+<script src="chrome://resources/js/i18n_template.js"></script>
+</body>
+</html>
diff --git a/ios/chrome/app/resources/history/history.js b/ios/chrome/app/resources/history/history.js
new file mode 100644
index 0000000..c2c27ae
--- /dev/null
+++ b/ios/chrome/app/resources/history/history.js
@@ -0,0 +1,2328 @@
+// Copyright (c) 2012 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+///////////////////////////////////////////////////////////////////////////////
+// Globals:
+/** @const */ var RESULTS_PER_PAGE = 150;
+
+// Amount of time between pageviews that we consider a 'break' in browsing,
+// measured in milliseconds.
+/** @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000;
+
+// The largest bucket value for UMA histogram, based on entry ID. All entries
+// with IDs greater than this will be included in this bucket.
+/** @const */ var UMA_MAX_BUCKET_VALUE = 1000;
+
+// The largest bucket value for a UMA histogram that is a subset of above.
+/** @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100;
+
+// TODO(glen): Get rid of these global references, replace with a controller
+// or just make the classes own more of the page.
+var historyModel;
+var historyView;
+var pageState;
+var selectionAnchor = -1;
+var activeVisit = null;
+
+/** @const */ var Command = cr.ui.Command;
+/** @const */ var FocusOutlineManager = cr.ui.FocusOutlineManager;
+/** @const */ var Menu = cr.ui.Menu;
+/** @const */ var MenuItem = cr.ui.MenuItem;
+
+/**
+ * Enum that shows the filtering behavior for a host or URL to a supervised
+ * user. Must behave like the FilteringBehavior enum from
+ * supervised_user_url_filter.h.
+ * @enum {number}
+ */
+var SupervisedUserFilteringBehavior = {
+ ALLOW: 0,
+ WARN: 1,
+ BLOCK: 2
+};
+
+/**
+ * The type of the history result object. The definition is based on
+ * chrome/browser/ui/webui/history_ui.cc:
+ * BrowsingHistoryHandler::HistoryEntry::ToValue()
+ * @typedef {{allTimestamps: Array<number>,
+ * blockedVisit: (boolean|undefined),
+ * dateRelativeDay: (string|undefined),
+ * dateShort: string,
+ * dateTimeOfDay: (string|undefined),
+ * deviceName: string,
+ * deviceType: string,
+ * domain: string,
+ * hostFilteringBehavior: (number|undefined),
+ * snippet: (string|undefined),
+ * starred: boolean,
+ * time: number,
+ * title: string,
+ * url: string}}
+ */
+var HistoryEntry;
+
+/**
+ * The type of the history results info object. The definition is based on
+ * chrome/browser/ui/webui/history_ui.cc:
+ * BrowsingHistoryHandler::QueryComplete()
+ * @typedef {{finished: boolean,
+ * hasSyncedResults: (boolean|undefined),
+ * queryEndTime: string,
+ * queryStartTime: string,
+ * term: string}}
+ */
+var HistoryQuery;
+
+/**
+ * Record an action in UMA.
+ * @param {string} actionDesc The name of the action to be logged.
+ */
+function recordUmaAction(actionDesc) {
+ chrome.send('metricsHandler:recordAction', [actionDesc]);
+}
+
+/**
+ * Record a histogram value in UMA. If specified value is larger than the max
+ * bucket value, record the value in the largest bucket.
+ * @param {string} histogram The name of the histogram to be recorded in.
+ * @param {number} maxBucketValue The max value for the last histogram bucket.
+ * @param {number} value The value to record in the histogram.
+ */
+function recordUmaHistogram(histogram, maxBucketValue, value) {
+ chrome.send('metricsHandler:recordInHistogram',
+ [histogram,
+ ((value > maxBucketValue) ? maxBucketValue : value),
+ maxBucketValue]);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Visit:
+
+/**
+ * Class to hold all the information about an entry in our model.
+ * @param {HistoryEntry} result An object containing the visit's data.
+ * @param {boolean} continued Whether this visit is on the same day as the
+ * visit before it.
+ * @param {HistoryModel} model The model object this entry belongs to.
+ * @constructor
+ */
+function Visit(result, continued, model) {
+ this.model_ = model;
+ this.title_ = result.title;
+ this.url_ = result.url;
+ this.domain_ = result.domain;
+ this.starred_ = result.starred;
+
+ // These identify the name and type of the device on which this visit
+ // occurred. They will be empty if the visit occurred on the current device.
+ this.deviceName = result.deviceName;
+ this.deviceType = result.deviceType;
+
+ // The ID will be set according to when the visit was displayed, not
+ // received. Set to -1 to show that it has not been set yet.
+ this.id_ = -1;
+
+ this.isRendered = false; // Has the visit already been rendered on the page?
+
+ // All the date information is public so that owners can compare properties of
+ // two items easily.
+
+ this.date = new Date(result.time);
+
+ // See comment in BrowsingHistoryHandler::QueryComplete - we won't always
+ // get all of these.
+ this.dateRelativeDay = result.dateRelativeDay || '';
+ this.dateTimeOfDay = result.dateTimeOfDay || '';
+ this.dateShort = result.dateShort || '';
+
+ // Shows the filtering behavior for that host (only used for supervised
+ // users).
+ // A value of |SupervisedUserFilteringBehavior.ALLOW| is not displayed so it
+ // is used as the default value.
+ this.hostFilteringBehavior = SupervisedUserFilteringBehavior.ALLOW;
+ if (typeof result.hostFilteringBehavior != 'undefined')
+ this.hostFilteringBehavior = result.hostFilteringBehavior;
+
+ this.blockedVisit = result.blockedVisit || false;
+
+ // Whether this is the continuation of a previous day.
+ this.continued = continued;
+
+ this.allTimestamps = result.allTimestamps;
+}
+
+// Visit, public: -------------------------------------------------------------
+
+/**
+ * Returns a dom structure for a browse page result or a search page result.
+ * @param {Object} propertyBag A bag of configuration properties, false by
+ * default:
+ * - isSearchResult: Whether or not the result is a search result.
+ * - addTitleFavicon: Whether or not the favicon should be added.
+ * - useMonthDate: Whether or not the full date should be inserted (used for
+ * monthly view).
+ * @return {Node} A DOM node to represent the history entry or search result.
+ */
+Visit.prototype.getResultDOM = function(propertyBag) {
+ var isSearchResult = propertyBag.isSearchResult || false;
+ var addTitleFavicon = propertyBag.addTitleFavicon || false;
+ var useMonthDate = propertyBag.useMonthDate || false;
+ var focusless = propertyBag.focusless || false;
+ var node = createElementWithClassName('li', 'entry');
+ var time = createElementWithClassName('span', 'time');
+ var entryBox = createElementWithClassName('div', 'entry-box');
+ var domain = createElementWithClassName('div', 'domain');
+
+ this.id_ = this.model_.getNextVisitId();
+ var self = this;
+
+ // Only create the checkbox if it can be used to delete an entry.
+ if (this.model_.editingEntriesAllowed) {
+ var checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.id = 'checkbox-' + this.id_;
+ checkbox.time = this.date.getTime();
+ checkbox.setAttribute('aria-label', loadTimeData.getStringF(
+ 'entrySummary',
+ this.dateTimeOfDay,
+ this.starred_ ? loadTimeData.getString('bookmarked') : '',
+ this.title_,
+ this.domain_));
+ checkbox.addEventListener('click', checkboxClicked);
+ entryBox.appendChild(checkbox);
+
+ if (focusless)
+ checkbox.tabIndex = -1;
+ }
+
+ // Keep track of the drop down that triggered the menu, so we know
+ // which element to apply the command to.
+ // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it.
+ var setActiveVisit = function(e) {
+ activeVisit = self;
+ var menu = $('action-menu');
+ menu.dataset.devicename = self.deviceName;
+ menu.dataset.devicetype = self.deviceType;
+ };
+ domain.textContent = this.domain_;
+
+ entryBox.appendChild(time);
+
+ var bookmarkSection = createElementWithClassName(
+ 'button', 'bookmark-section custom-appearance');
+ if (this.starred_) {
+ bookmarkSection.title = loadTimeData.getString('removeBookmark');
+ bookmarkSection.classList.add('starred');
+ bookmarkSection.addEventListener('click', function f(e) {
+ recordUmaAction('HistoryPage_BookmarkStarClicked');
+ chrome.send('removeBookmark', [self.url_]);
+
+ this.model_.getView().onBeforeUnstarred(this);
+ bookmarkSection.classList.remove('starred');
+ this.model_.getView().onAfterUnstarred(this);
+
+ bookmarkSection.removeEventListener('click', f);
+ e.preventDefault();
+ }.bind(this));
+ }
+
+ if (focusless)
+ bookmarkSection.tabIndex = -1;
+
+ entryBox.appendChild(bookmarkSection);
+
+ var visitEntryWrapper = /** @type {HTMLElement} */(
+ entryBox.appendChild(document.createElement('div')));
+ if (addTitleFavicon || this.blockedVisit)
+ visitEntryWrapper.classList.add('visit-entry');
+ if (this.blockedVisit) {
+ visitEntryWrapper.classList.add('blocked-indicator');
+ visitEntryWrapper.appendChild(this.getVisitAttemptDOM_());
+ } else {
+ var title = visitEntryWrapper.appendChild(
+ this.getTitleDOM_(isSearchResult));
+
+ if (addTitleFavicon)
+ this.addFaviconToElement_(visitEntryWrapper);
+
+ if (focusless)
+ title.querySelector('a').tabIndex = -1;
+
+ visitEntryWrapper.appendChild(domain);
+ }
+
+ if (this.model_.editingEntriesAllowed) {
+ var removeButton = createElementWithClassName('button', 'remove-entry');
+ removeButton.setAttribute('aria-label',
+ loadTimeData.getString('removeFromHistory'));
+ removeButton.classList.add('custom-appearance');
+ removeButton.addEventListener(
+ 'click', this.removeEntryFromHistory_.bind(this));
+ entryBox.appendChild(removeButton);
+
+ // Support clicking anywhere inside the entry box.
+ entryBox.addEventListener('click', function(e) {
+ if (!e.defaultPrevented) {
+ self.titleLink.focus();
+ self.titleLink.click();
+ }
+ });
+ }
+
+ // Let the entryBox be styled appropriately when it contains keyboard focus.
+ entryBox.addEventListener('focus', function() {
+ this.classList.add('contains-focus');
+ }, true);
+ entryBox.addEventListener('blur', function() {
+ this.classList.remove('contains-focus');
+ }, true);
+
+ var entryBoxContainer =
+ createElementWithClassName('div', 'entry-box-container');
+ node.appendChild(entryBoxContainer);
+ entryBoxContainer.appendChild(entryBox);
+
+ if (isSearchResult || useMonthDate) {
+ // Show the day instead of the time.
+ time.appendChild(document.createTextNode(this.dateShort));
+ } else {
+ time.appendChild(document.createTextNode(this.dateTimeOfDay));
+ }
+
+ this.domNode_ = node;
+ node.visit = this;
+
+ return node;
+};
+
+/**
+ * Remove this visit from the history.
+ */
+Visit.prototype.removeFromHistory = function() {
+ recordUmaAction('HistoryPage_EntryMenuRemoveFromHistory');
+ this.model_.removeVisitsFromHistory([this], function() {
+ this.model_.getView().removeVisit(this);
+ }.bind(this));
+};
+
+// Closure Compiler doesn't support Object.defineProperty().
+// https://github.com/google/closure-compiler/issues/302
+Object.defineProperty(Visit.prototype, 'checkBox', {
+ get: /** @this {Visit} */function() {
+ return this.domNode_.querySelector('input[type=checkbox]');
+ },
+});
+
+Object.defineProperty(Visit.prototype, 'bookmarkStar', {
+ get: /** @this {Visit} */function() {
+ return this.domNode_.querySelector('.bookmark-section.starred');
+ },
+});
+
+Object.defineProperty(Visit.prototype, 'titleLink', {
+ get: /** @this {Visit} */function() {
+ return this.domNode_.querySelector('.title a');
+ },
+});
+
+Object.defineProperty(Visit.prototype, 'dropDown', {
+ get: /** @this {Visit} */function() {
+ return this.domNode_.querySelector('button.drop-down');
+ },
+});
+
+// Visit, private: ------------------------------------------------------------
+
+/**
+ * Add child text nodes to a node such that occurrences of the specified text is
+ * highlighted.
+ * @param {Node} node The node under which new text nodes will be made as
+ * children.
+ * @param {string} content Text to be added beneath |node| as one or more
+ * text nodes.
+ * @param {string} highlightText Occurences of this text inside |content| will
+ * be highlighted.
+ * @private
+ */
+Visit.prototype.addHighlightedText_ = function(node, content, highlightText) {
+ var i = 0;
+ if (highlightText) {
+ var re = new RegExp(Visit.pregQuote_(highlightText), 'gim');
+ var match;
+ while (match = re.exec(content)) {
+ if (match.index > i)
+ node.appendChild(document.createTextNode(content.slice(i,
+ match.index)));
+ i = re.lastIndex;
+ // Mark the highlighted text in bold.
+ var b = document.createElement('b');
+ b.textContent = content.substring(match.index, i);
+ node.appendChild(b);
+ }
+ }
+ if (i < content.length)
+ node.appendChild(document.createTextNode(content.slice(i)));
+};
+
+/**
+ * Returns the DOM element containing a link on the title of the URL for the
+ * current visit.
+ * @param {boolean} isSearchResult Whether or not the entry is a search result.
+ * @return {Element} DOM representation for the title block.
+ * @private
+ */
+Visit.prototype.getTitleDOM_ = function(isSearchResult) {
+ var node = createElementWithClassName('div', 'title');
+ var link = document.createElement('a');
+ link.href = this.url_;
+ link.id = 'id-' + this.id_;
+ link.target = '_top';
+ var integerId = parseInt(this.id_, 10);
+ link.addEventListener('click', function() {
+ recordUmaAction('HistoryPage_EntryLinkClick');
+ // Record the ID of the entry to signify how many entries are above this
+ // link on the page.
+ recordUmaHistogram('HistoryPage.ClickPosition',
+ UMA_MAX_BUCKET_VALUE,
+ integerId);
+ if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
+ recordUmaHistogram('HistoryPage.ClickPositionSubset',
+ UMA_MAX_SUBSET_BUCKET_VALUE,
+ integerId);
+ }
+ });
+ link.addEventListener('contextmenu', function() {
+ recordUmaAction('HistoryPage_EntryLinkRightClick');
+ });
+
+ if (isSearchResult) {
+ link.addEventListener('click', function() {
+ recordUmaAction('HistoryPage_SearchResultClick');
+ });
+ }
+
+ // Add a tooltip, since it might be ellipsized.
+ // TODO(dubroy): Find a way to show the tooltip only when necessary.
+ link.title = this.title_;
+
+ this.addHighlightedText_(link, this.title_, this.model_.getSearchText());
+ node.appendChild(link);
+
+ return node;
+};
+
+/**
+ * Returns the DOM element containing the text for a blocked visit attempt.
+ * @return {Element} DOM representation of the visit attempt.
+ * @private
+ */
+Visit.prototype.getVisitAttemptDOM_ = function() {
+ var node = createElementWithClassName('div', 'title');
+ node.innerHTML = loadTimeData.getStringF('blockedVisitText',
+ this.url_,
+ this.id_,
+ this.domain_);
+ return node;
+};
+
+/**
+ * Set the favicon for an element.
+ * @param {Element} el The DOM element to which to add the icon.
+ * @private
+ */
+Visit.prototype.addFaviconToElement_ = function(el) {
+ var url = getFaviconImageSet(this.url_, 32, 'touch-icon');
+ el.style.backgroundImage = url;
+ chrome.requestFavicon(el, url);
+};
+
+/**
+ * Launch a search for more history entries from the same domain.
+ * @private
+ */
+Visit.prototype.showMoreFromSite_ = function() {
+ recordUmaAction('HistoryPage_EntryMenuShowMoreFromSite');
+ historyView.setSearch(this.domain_);
+ $('search-field').focus();
+};
+
+/**
+ * @param {Event} e A keydown event to handle.
+ * @private
+ */
+Visit.prototype.handleKeydown_ = function(e) {
+ // Delete or Backspace should delete the entry if allowed.
+ if (e.keyIdentifier == 'U+0008' || e.keyIdentifier == 'U+007F')
+ this.removeEntryFromHistory_(e);
+};
+
+/**
+ * @param {Event} event A mousedown event.
+ * @private
+ */
+Visit.prototype.handleMousedown_ = function(event) {
+ // Prevent text selection when shift-clicking to select multiple entries.
+ if (event.shiftKey) {
+ event.preventDefault();
+
+ var target = assertInstanceof(event.target, HTMLElement);
+ if (this.model_.getView().isInFocusGrid(target))
+ target.focus();
+ }
+};
+
+/**
+ * Removes a history entry on click or keydown and finds a new entry to focus.
+ * @param {Event} e A click or keydown event.
+ * @private
+ */
+Visit.prototype.removeEntryFromHistory_ = function(e) {
+ if (!this.model_.deletingHistoryAllowed || this.model_.isDeletingVisits() ||
+ this.domNode_.classList.contains('fade-out')) {
+ return;
+ }
+
+ this.model_.getView().onBeforeRemove(this);
+ this.removeFromHistory();
+ e.preventDefault();
+};
+
+// Visit, private, static: ----------------------------------------------------
+
+/**
+ * Quote a string so it can be used in a regular expression.
+ * @param {string} str The source string.
+ * @return {string} The escaped string.
+ * @private
+ */
+Visit.pregQuote_ = function(str) {
+ return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1');
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// HistoryModel:
+
+/**
+ * Global container for history data. Future optimizations might include
+ * allowing the creation of a HistoryModel for each search string, allowing
+ * quick flips back and forth between results.
+ *
+ * The history model is based around pages, and only fetching the data to
+ * fill the currently requested page. This is somewhat dependent on the view,
+ * and so future work may wish to change history model to operate on
+ * timeframe (day or week) based containers.
+ *
+ * @constructor
+ */
+function HistoryModel() {
+ this.clearModel_();
+}
+
+// HistoryModel, Public: ------------------------------------------------------
+
+/** @enum {number} */
+HistoryModel.Range = {
+ ALL_TIME: 0,
+ WEEK: 1,
+ MONTH: 2
+};
+
+/**
+ * Sets our current view that is called when the history model changes.
+ * @param {HistoryView} view The view to set our current view to.
+ */
+HistoryModel.prototype.setView = function(view) {
+ this.view_ = view;
+};
+
+
+/**
+ * @return {HistoryView|undefined} Returns the view for this model (if set).
+ */
+HistoryModel.prototype.getView = function() {
+ return this.view_;
+};
+
+/**
+ * Reload our model with the current parameters.
+ */
+HistoryModel.prototype.reload = function() {
+ // Save user-visible state, clear the model, and restore the state.
+ var search = this.searchText_;
+ var page = this.requestedPage_;
+ var range = this.rangeInDays_;
+ var offset = this.offset_;
+ var groupByDomain = this.groupByDomain_;
+
+ this.clearModel_();
+ this.searchText_ = search;
+ this.requestedPage_ = page;
+ this.rangeInDays_ = range;
+ this.offset_ = offset;
+ this.groupByDomain_ = groupByDomain;
+ this.queryHistory_();
+};
+
+/**
+ * @return {string} The current search text.
+ */
+HistoryModel.prototype.getSearchText = function() {
+ return this.searchText_;
+};
+
+/**
+ * Tell the model that the view will want to see the current page. When
+ * the data becomes available, the model will call the view back.
+ * @param {number} page The page we want to view.
+ */
+HistoryModel.prototype.requestPage = function(page) {
+ this.requestedPage_ = page;
+ this.updateSearch_();
+};
+
+/**
+ * Receiver for history query.
+ * @param {HistoryQuery} info An object containing information about the query.
+ * @param {Array<HistoryEntry>} results A list of results.
+ */
+HistoryModel.prototype.addResults = function(info, results) {
+ // If no requests are in flight then this was an old request so we drop the
+ // results. Double check the search term as well.
+ if (!this.inFlight_ || info.term != this.searchText_)
+ return;
+
+ $('loading-spinner').hidden = true;
+ this.inFlight_ = false;
+ this.isQueryFinished_ = info.finished;
+ this.queryStartTime = info.queryStartTime;
+ this.queryEndTime = info.queryEndTime;
+
+ var lastVisit = this.visits_.slice(-1)[0];
+ var lastDay = lastVisit ? lastVisit.dateRelativeDay : null;
+
+ for (var i = 0, result; result = results[i]; i++) {
+ var thisDay = result.dateRelativeDay;
+ var isSameDay = lastDay == thisDay;
+ this.visits_.push(new Visit(result, isSameDay, this));
+ lastDay = thisDay;
+ }
+
+ if (loadTimeData.getBoolean('isUserSignedIn')) {
+ var message = loadTimeData.getString(
+ info.hasSyncedResults ? 'hasSyncedResults' : 'noSyncedResults');
+ this.view_.showNotification(message);
+ }
+
+ this.updateSearch_();
+};
+
+/**
+ * @return {number} The number of visits in the model.
+ */
+HistoryModel.prototype.getSize = function() {
+ return this.visits_.length;
+};
+
+/**
+ * Get a list of visits between specified index positions.
+ * @param {number} start The start index.
+ * @param {number} end The end index.
+ * @return {Array<Visit>} A list of visits.
+ */
+HistoryModel.prototype.getNumberedRange = function(start, end) {
+ return this.visits_.slice(start, end);
+};
+
+/**
+ * Return true if there are more results beyond the current page.
+ * @return {boolean} true if the there are more results, otherwise false.
+ */
+HistoryModel.prototype.hasMoreResults = function() {
+ return this.haveDataForPage_(this.requestedPage_ + 1) ||
+ !this.isQueryFinished_;
+};
+
+/**
+ * Removes a list of visits from the history, and calls |callback| when the
+ * removal has successfully completed.
+ * @param {Array<Visit>} visits The visits to remove.
+ * @param {Function} callback The function to call after removal succeeds.
+ */
+HistoryModel.prototype.removeVisitsFromHistory = function(visits, callback) {
+ assert(this.deletingHistoryAllowed);
+
+ var toBeRemoved = [];
+ for (var i = 0; i < visits.length; i++) {
+ toBeRemoved.push({
+ url: visits[i].url_,
+ timestamps: visits[i].allTimestamps
+ });
+ }
+
+ this.deleteCompleteCallback_ = callback;
+ chrome.send('removeVisits', toBeRemoved);
+};
+
+/** @return {boolean} Whether the model is currently deleting a visit. */
+HistoryModel.prototype.isDeletingVisits = function() {
+ return !!this.deleteCompleteCallback_;
+};
+
+/**
+ * Called when visits have been succesfully removed from the history.
+ */
+HistoryModel.prototype.deleteComplete = function() {
+ // Call the callback, with 'this' undefined inside the callback.
+ this.deleteCompleteCallback_.call();
+ this.deleteCompleteCallback_ = null;
+};
+
+// Getter and setter for HistoryModel.rangeInDays_.
+Object.defineProperty(HistoryModel.prototype, 'rangeInDays', {
+ get: /** @this {HistoryModel} */function() {
+ return this.rangeInDays_;
+ },
+ set: /** @this {HistoryModel} */function(range) {
+ this.rangeInDays_ = range;
+ }
+});
+
+/**
+ * Getter and setter for HistoryModel.offset_. The offset moves the current
+ * query 'window' |range| days behind. As such for range set to WEEK an offset
+ * of 0 refers to the last 7 days, an offset of 1 refers to the 7 day period
+ * that ended 7 days ago, etc. For MONTH an offset of 0 refers to the current
+ * calendar month, 1 to the previous one, etc.
+ */
+Object.defineProperty(HistoryModel.prototype, 'offset', {
+ get: /** @this {HistoryModel} */function() {
+ return this.offset_;
+ },
+ set: /** @this {HistoryModel} */function(offset) {
+ this.offset_ = offset;
+ }
+});
+
+// Setter for HistoryModel.requestedPage_.
+Object.defineProperty(HistoryModel.prototype, 'requestedPage', {
+ set: /** @this {HistoryModel} */function(page) {
+ this.requestedPage_ = page;
+ }
+});
+
+/**
+ * Removes |visit| from this model.
+ * @param {Visit} visit A visit to remove.
+ */
+HistoryModel.prototype.removeVisit = function(visit) {
+ var index = this.visits_.indexOf(visit);
+ if (index >= 0)
+ this.visits_.splice(index, 1);
+};
+
+/**
+ * Automatically generates a new visit ID.
+ * @return {number} The next visit ID.
+ */
+HistoryModel.prototype.getNextVisitId = function() {
+ return this.nextVisitId_++;
+};
+
+// HistoryModel, Private: -----------------------------------------------------
+
+/**
+ * Clear the history model.
+ * @private
+ */
+HistoryModel.prototype.clearModel_ = function() {
+ this.inFlight_ = false; // Whether a query is inflight.
+ this.searchText_ = '';
+ // Whether this user is a supervised user.
+ this.isSupervisedProfile = loadTimeData.getBoolean('isSupervisedProfile');
+ this.deletingHistoryAllowed = loadTimeData.getBoolean('allowDeletingHistory');
+
+ // Only create checkboxes for editing entries if they can be used either to
+ // delete an entry or to block/allow it.
+ this.editingEntriesAllowed = this.deletingHistoryAllowed;
+
+ // Flag to show that the results are grouped by domain or not.
+ this.groupByDomain_ = false;
+
+ this.visits_ = []; // Date-sorted list of visits (most recent first).
+ this.nextVisitId_ = 0;
+ selectionAnchor = -1;
+
+ // The page that the view wants to see - we only fetch slightly past this
+ // point. If the view requests a page that we don't have data for, we try
+ // to fetch it and call back when we're done.
+ this.requestedPage_ = 0;
+
+ // The range of history to view or search over.
+ this.rangeInDays_ = HistoryModel.Range.ALL_TIME;
+
+ // Skip |offset_| * weeks/months from the begining.
+ this.offset_ = 0;
+
+ // Keeps track of whether or not there are more results available than are
+ // currently held in |this.visits_|.
+ this.isQueryFinished_ = false;
+
+ if (this.view_)
+ this.view_.clear_();
+};
+
+/**
+ * Figure out if we need to do more queries to fill the currently requested
+ * page. If we think we can fill the page, call the view and let it know
+ * we're ready to show something. This only applies to the daily time-based
+ * view.
+ * @private
+ */
+HistoryModel.prototype.updateSearch_ = function() {
+ var doneLoading = this.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
+ this.isQueryFinished_ ||
+ this.canFillPage_(this.requestedPage_);
+
+ // Try to fetch more results if more results can arrive and the page is not
+ // full.
+ if (!doneLoading && !this.inFlight_)
+ this.queryHistory_();
+
+ // Show the result or a message if no results were returned.
+ this.view_.onModelReady(doneLoading);
+};
+
+/**
+ * Query for history, either for a search or time-based browsing.
+ * @private
+ */
+HistoryModel.prototype.queryHistory_ = function() {
+ var maxResults =
+ (this.rangeInDays_ == HistoryModel.Range.ALL_TIME) ? RESULTS_PER_PAGE : 0;
+
+ // If there are already some visits, pick up the previous query where it
+ // left off.
+ var lastVisit = this.visits_.slice(-1)[0];
+ var endTime = lastVisit ? lastVisit.date.getTime() : 0;
+
+ $('loading-spinner').hidden = false;
+ this.inFlight_ = true;
+ chrome.send('queryHistory',
+ [this.searchText_, this.offset_, this.rangeInDays_, endTime, maxResults]);
+};
+
+/**
+ * Check to see if we have data for the given page.
+ * @param {number} page The page number.
+ * @return {boolean} Whether we have any data for the given page.
+ * @private
+ */
+HistoryModel.prototype.haveDataForPage_ = function(page) {
+ return page * RESULTS_PER_PAGE < this.getSize();
+};
+
+/**
+ * Check to see if we have data to fill the given page.
+ * @param {number} page The page number.
+ * @return {boolean} Whether we have data to fill the page.
+ * @private
+ */
+HistoryModel.prototype.canFillPage_ = function(page) {
+ return ((page + 1) * RESULTS_PER_PAGE <= this.getSize());
+};
+
+/**
+ * Gets whether we are grouped by domain.
+ * @return {boolean} Whether the results are grouped by domain.
+ */
+HistoryModel.prototype.getGroupByDomain = function() {
+ return this.groupByDomain_;
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// HistoryFocusRow:
+
+/**
+ * Provides an implementation for a single column grid.
+ * @param {!Element} root
+ * @param {Node} boundary
+ * @constructor
+ * @extends {cr.ui.FocusRow}
+ */
+function HistoryFocusRow(root, boundary) {
+ cr.ui.FocusRow.call(this, root, boundary);
+
+ // None of these are guaranteed to exist in all versions of the UI.
+ this.addItem('checkbox', '.entry-box input');
+ this.addItem('checkbox', '.domain-checkbox');
+ this.addItem('star', '.bookmark-section.starred');
+ this.addItem('domain', '[is="action-link"]');
+ this.addItem('title', '.title a');
+ this.addItem('menu', '.drop-down');
+}
+
+HistoryFocusRow.prototype = {
+ __proto__: cr.ui.FocusRow.prototype,
+
+ /** @override */
+ getCustomEquivalent: function(sampleElement) {
+ var equivalent;
+
+ switch (this.getTypeForElement(sampleElement)) {
+ case 'star':
+ equivalent = this.getFirstFocusable('title') ||
+ this.getFirstFocusable('domain');
+ break;
+ case 'domain':
+ equivalent = this.getFirstFocusable('title');
+ break;
+ case 'title':
+ equivalent = this.getFirstFocusable('domain');
+ break;
+ case 'menu':
+ equivalent = this.getFocusableElements().slice(-1)[0];
+ break;
+ }
+
+ return equivalent ||
+ cr.ui.FocusRow.prototype.getCustomEquivalent.call(this, sampleElement);
+ },
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// HistoryView:
+
+/**
+ * Functions and state for populating the page with HTML. This should one-day
+ * contain the view and use event handlers, rather than pushing HTML out and
+ * getting called externally.
+ * @param {HistoryModel} model The model backing this view.
+ * @constructor
+ */
+function HistoryView(model) {
+ this.editButtonTd_ = $('edit-button');
+ this.editingControlsDiv_ = $('editing-controls');
+ this.resultDiv_ = $('results-display');
+ this.focusGrid_ = new cr.ui.FocusGrid();
+ this.pageDiv_ = $('results-pagination');
+ this.model_ = model;
+ this.pageIndex_ = 0;
+ this.lastDisplayed_ = [];
+
+ this.model_.setView(this);
+
+ this.currentVisits_ = [];
+
+ // If there is no search button, use the search button label as placeholder
+ // text in the search field.
+ if ($('search-button').offsetWidth == 0)
+ $('search-field').placeholder = $('search-button').value;
+
+ var self = this;
+
+ $('clear-browsing-data').addEventListener('click', openClearBrowsingData);
+ $('remove-selected').addEventListener('click', removeItems);
+
+ // Add handlers for the page navigation buttons at the bottom.
+ $('newest-button').addEventListener('click', function() {
+ recordUmaAction('HistoryPage_NewestHistoryClick');
+ self.setPage(0);
+ });
+ $('newer-button').addEventListener('click', function() {
+ recordUmaAction('HistoryPage_NewerHistoryClick');
+ self.setPage(self.pageIndex_ - 1);
+ });
+ $('older-button').addEventListener('click', function() {
+ recordUmaAction('HistoryPage_OlderHistoryClick');
+ self.setPage(self.pageIndex_ + 1);
+ });
+
+ $('timeframe-controls').onchange = function(e) {
+ var value = parseInt(e.target.value, 10);
+ self.setRangeInDays(/** @type {HistoryModel.Range<number>} */(value));
+ };
+
+ $('range-previous').addEventListener('click', function(e) {
+ if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
+ self.setPage(self.pageIndex_ + 1);
+ else
+ self.setOffset(self.getOffset() + 1);
+ });
+ $('range-next').addEventListener('click', function(e) {
+ if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
+ self.setPage(self.pageIndex_ - 1);
+ else
+ self.setOffset(self.getOffset() - 1);
+ });
+ $('range-today').addEventListener('click', function(e) {
+ if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME)
+ self.setPage(0);
+ else
+ self.setOffset(0);
+ });
+}
+
+// HistoryView, public: -------------------------------------------------------
+/**
+ * Do a search on a specific term.
+ * @param {string} term The string to search for.
+ */
+HistoryView.prototype.setSearch = function(term) {
+ window.scrollTo(0, 0);
+ this.setPageState(term, 0, this.getRangeInDays(), this.getOffset());
+};
+
+/**
+ * Reload the current view.
+ */
+HistoryView.prototype.reload = function() {
+ this.model_.reload();
+ this.updateSelectionEditButtons();
+ this.updateRangeButtons_();
+};
+
+/**
+ * Sets all the parameters for the history page and then reloads the view to
+ * update the results.
+ * @param {string} searchText The search string to set.
+ * @param {number} page The page to be viewed.
+ * @param {HistoryModel.Range} range The range to view or search over.
+ * @param {number} offset Set the begining of the query to the specific offset.
+ */
+HistoryView.prototype.setPageState = function(searchText, page, range, offset) {
+ this.clear_();
+ this.model_.searchText_ = searchText;
+ this.pageIndex_ = page;
+ this.model_.requestedPage_ = page;
+ this.model_.rangeInDays_ = range;
+ this.model_.groupByDomain_ = false;
+ if (range != HistoryModel.Range.ALL_TIME)
+ this.model_.groupByDomain_ = true;
+ this.model_.offset_ = offset;
+ this.reload();
+ pageState.setUIState(this.model_.getSearchText(),
+ this.pageIndex_,
+ this.getRangeInDays(),
+ this.getOffset());
+};
+
+/**
+ * Switch to a specified page.
+ * @param {number} page The page we wish to view.
+ */
+HistoryView.prototype.setPage = function(page) {
+ // TODO(sergiu): Move this function to setPageState as well and see why one
+ // of the tests fails when using setPageState.
+ this.clear_();
+ this.pageIndex_ = parseInt(page, 10);
+ window.scrollTo(0, 0);
+ this.model_.requestPage(page);
+ pageState.setUIState(this.model_.getSearchText(),
+ this.pageIndex_,
+ this.getRangeInDays(),
+ this.getOffset());
+};
+
+/**
+ * @return {number} The page number being viewed.
+ */
+HistoryView.prototype.getPage = function() {
+ return this.pageIndex_;
+};
+
+/**
+ * Set the current range for grouped results.
+ * @param {HistoryModel.Range} range The number of days to which the range
+ * should be set.
+ */
+HistoryView.prototype.setRangeInDays = function(range) {
+ // Set the range, offset and reset the page.
+ this.setPageState(this.model_.getSearchText(), 0, range, 0);
+};
+
+/**
+ * Get the current range in days.
+ * @return {HistoryModel.Range} Current range in days from the model.
+ */
+HistoryView.prototype.getRangeInDays = function() {
+ return this.model_.rangeInDays;
+};
+
+/**
+ * Set the current offset for grouped results.
+ * @param {number} offset Offset to set.
+ */
+HistoryView.prototype.setOffset = function(offset) {
+ // If there is another query already in flight wait for that to complete.
+ if (this.model_.inFlight_)
+ return;
+ this.setPageState(this.model_.getSearchText(),
+ this.pageIndex_,
+ this.getRangeInDays(),
+ offset);
+};
+
+/**
+ * Get the current offset.
+ * @return {number} Current offset from the model.
+ */
+HistoryView.prototype.getOffset = function() {
+ return this.model_.offset;
+};
+
+/**
+ * Callback for the history model to let it know that it has data ready for us
+ * to view.
+ * @param {boolean} doneLoading Whether the current request is complete.
+ */
+HistoryView.prototype.onModelReady = function(doneLoading) {
+ this.displayResults_(doneLoading);
+
+ // Allow custom styling based on whether there are any results on the page.
+ // To make this easier, add a class to the body if there are any results.
+ var hasResults = this.model_.visits_.length > 0;
+ document.body.classList.toggle('has-results', hasResults);
+
+ this.updateFocusGrid_();
+ this.updateNavBar_();
+
+ // Hide the search field if it is empty and there are no results.
+ var isSearch = this.model_.getSearchText().length > 0;
+ $('search-field').hidden = !(hasResults || isSearch);
+};
+
+/**
+ * Enables or disables the buttons that control editing entries depending on
+ * whether there are any checked boxes.
+ */
+HistoryView.prototype.updateSelectionEditButtons = function() {
+ if (loadTimeData.getBoolean('allowDeletingHistory')) {
+ var anyChecked = document.querySelector('.entry input:checked') != null;
+ $('remove-selected').disabled = !anyChecked;
+ } else {
+ $('remove-selected').disabled = true;
+ }
+};
+
+/**
+ * Shows the notification bar at the top of the page with |innerHTML| as its
+ * content.
+ * @param {string} innerHTML The HTML content of the warning.
+ * @param {boolean} isWarning If true, style the notification as a warning.
+ */
+HistoryView.prototype.showNotification = function(innerHTML, isWarning) {
+ var bar = $('notification-bar');
+ bar.innerHTML = innerHTML;
+ bar.hidden = false;
+ if (isWarning)
+ bar.classList.add('warning');
+ else
+ bar.classList.remove('warning');
+
+ // Make sure that any links in the HTML are targeting the top level.
+ var links = bar.querySelectorAll('a');
+ for (var i = 0; i < links.length; i++)
+ links[i].target = '_top';
+
+ this.positionNotificationBar();
+};
+
+/**
+ * @param {Visit} visit The visit about to be removed from this view.
+ */
+HistoryView.prototype.onBeforeRemove = function(visit) {
+ assert(this.currentVisits_.indexOf(visit) >= 0);
+
+ var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement);
+ if (rowIndex == -1)
+ return;
+
+ var rowToFocus = this.focusGrid_.rows[rowIndex + 1] ||
+ this.focusGrid_.rows[rowIndex - 1];
+ if (rowToFocus)
+ rowToFocus.getEquivalentElement(document.activeElement).focus();
+};
+
+/** @param {Visit} visit The visit about to be unstarred. */
+HistoryView.prototype.onBeforeUnstarred = function(visit) {
+ assert(this.currentVisits_.indexOf(visit) >= 0);
+ assert(visit.bookmarkStar == document.activeElement);
+
+ var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement);
+ var row = this.focusGrid_.rows[rowIndex];
+
+ // Focus the title or domain when the bookmarked star is removed because the
+ // star will no longer be focusable.
+ row.root.querySelector('[focus-type=title], [focus-type=domain]').focus();
+};
+
+/** @param {Visit} visit The visit that was just unstarred. */
+HistoryView.prototype.onAfterUnstarred = function(visit) {
+ this.updateFocusGrid_();
+};
+
+/**
+ * Removes a single entry from the view. Also removes gaps before and after
+ * entry if necessary.
+ * @param {Visit} visit The visit to be removed.
+ */
+HistoryView.prototype.removeVisit = function(visit) {
+ var entry = visit.domNode_;
+ var previousEntry = entry.previousSibling;
+ var nextEntry = entry.nextSibling;
+ var toRemove = [entry];
+
+ // If there is no previous entry, and the next entry is a gap, remove it.
+ if (!previousEntry && nextEntry && nextEntry.classList.contains('gap'))
+ toRemove.push(nextEntry);
+
+ // If there is no next entry, and the previous entry is a gap, remove it.
+ if (!nextEntry && previousEntry && previousEntry.classList.contains('gap'))
+ toRemove.push(previousEntry);
+
+ // If both the next and previous entries are gaps, remove the next one.
+ if (nextEntry && nextEntry.classList.contains('gap') &&
+ previousEntry && previousEntry.classList.contains('gap')) {
+ toRemove.push(nextEntry);
+ }
+
+ // If removing the last entry on a day, remove the entire day.
+ var dayResults = findAncestorByClass(entry, 'day-results');
+ if (dayResults && dayResults.querySelectorAll('.entry').length <= 1) {
+ toRemove.push(dayResults.previousSibling); // Remove the 'h3'.
+ toRemove.push(dayResults);
+ }
+
+ // Callback to be called when each node has finished animating. It detects
+ // when all the animations have completed.
+ function onRemove() {
+ for (var i = 0; i < toRemove.length; ++i) {
+ if (toRemove[i].parentNode)
+ return;
+ }
+ onEntryRemoved();
+ }
+
+ // Kick off the removal process.
+ for (var i = 0; i < toRemove.length; ++i) {
+ removeNode(toRemove[i], onRemove, this);
+ }
+ this.updateFocusGrid_();
+
+ var index = this.currentVisits_.indexOf(visit);
+ if (index >= 0)
+ this.currentVisits_.splice(index, 1);
+
+ this.model_.removeVisit(visit);
+};
+
+/**
+ * Called when an individual history entry has been removed from the page.
+ * This will only be called when all the elements affected by the deletion
+ * have been removed from the DOM and the animations have completed.
+ */
+HistoryView.prototype.onEntryRemoved = function() {
+ this.updateSelectionEditButtons();
+
+ if (this.model_.getSize() == 0) {
+ this.clear_();
+ this.onModelReady(true); // Shows "No entries" message.
+ }
+};
+
+/**
+ * Adjusts the position of the notification bar based on the size of the page.
+ */
+HistoryView.prototype.positionNotificationBar = function() {
+ var bar = $('notification-bar');
+
+ // If the bar does not fit beside the editing controls, put it into the
+ // overflow state.
+ if (bar.getBoundingClientRect().top >=
+ $('editing-controls').getBoundingClientRect().bottom) {
+ bar.classList.add('alone');
+ } else {
+ bar.classList.remove('alone');
+ }
+};
+
+/**
+ * @param {Element} el An element to look for.
+ * @return {boolean} Whether |el| is in |this.focusGrid_|.
+ */
+HistoryView.prototype.isInFocusGrid = function(el) {
+ return this.focusGrid_.getRowIndexForTarget(el) != -1;
+};
+
+// HistoryView, private: ------------------------------------------------------
+
+/**
+ * Clear the results in the view. Since we add results piecemeal, we need
+ * to clear them out when we switch to a new page or reload.
+ * @private
+ */
+HistoryView.prototype.clear_ = function() {
+ var alertOverlay = $('alertOverlay');
+ if (alertOverlay && alertOverlay.classList.contains('showing'))
+ hideConfirmationOverlay();
+
+ // Remove everything but <h3 id="results-header"> (the first child).
+ while (this.resultDiv_.children.length > 1) {
+ this.resultDiv_.removeChild(this.resultDiv_.lastElementChild);
+ }
+ $('results-header').textContent = '';
+
+ this.currentVisits_.forEach(function(visit) {
+ visit.isRendered = false;
+ });
+ this.currentVisits_ = [];
+
+ document.body.classList.remove('has-results');
+};
+
+/**
+ * Record that the given visit has been rendered.
+ * @param {Visit} visit The visit that was rendered.
+ * @private
+ */
+HistoryView.prototype.setVisitRendered_ = function(visit) {
+ visit.isRendered = true;
+ this.currentVisits_.push(visit);
+};
+
+/**
+ * Generates and adds the grouped visits DOM for a certain domain. This
+ * includes the clickable arrow and domain name and the visit entries for
+ * that domain.
+ * @param {Element} results DOM object to which to add the elements.
+ * @param {string} domain Current domain name.
+ * @param {Array} domainVisits Array of visits for this domain.
+ * @private
+ */
+HistoryView.prototype.getGroupedVisitsDOM_ = function(
+ results, domain, domainVisits) {
+ // Add a new domain entry.
+ var siteResults = results.appendChild(
+ createElementWithClassName('li', 'site-entry'));
+
+ var siteDomainWrapper = siteResults.appendChild(
+ createElementWithClassName('div', 'site-domain-wrapper'));
+ // Make a row that will contain the arrow, the favicon and the domain.
+ var siteDomainRow = siteDomainWrapper.appendChild(
+ createElementWithClassName('div', 'site-domain-row'));
+
+ if (this.model_.editingEntriesAllowed) {
+ var siteDomainCheckbox =
+ createElementWithClassName('input', 'domain-checkbox');
+
+ siteDomainCheckbox.type = 'checkbox';
+ siteDomainCheckbox.addEventListener('click', domainCheckboxClicked);
+ siteDomainCheckbox.domain_ = domain;
+ siteDomainCheckbox.setAttribute('aria-label', domain);
+ siteDomainRow.appendChild(siteDomainCheckbox);
+ }
+
+ var siteArrow = siteDomainRow.appendChild(
+ createElementWithClassName('div', 'site-domain-arrow'));
+ var siteDomain = siteDomainRow.appendChild(
+ createElementWithClassName('div', 'site-domain'));
+ var siteDomainLink = siteDomain.appendChild(new ActionLink);
+ siteDomainLink.textContent = domain;
+ var numberOfVisits = createElementWithClassName('span', 'number-visits');
+ var domainElement = document.createElement('span');
+
+ numberOfVisits.textContent = loadTimeData.getStringF('numberVisits',
+ domainVisits.length);
+ siteDomain.appendChild(numberOfVisits);
+
+ domainVisits[0].addFaviconToElement_(siteDomain);
+
+ siteDomainWrapper.addEventListener(
+ 'click', this.toggleGroupedVisits_.bind(this));
+
+ if (this.model_.isSupervisedProfile) {
+ siteDomainRow.appendChild(
+ getFilteringStatusDOM(domainVisits[0].hostFilteringBehavior));
+ }
+
+ siteResults.appendChild(siteDomainWrapper);
+ var resultsList = siteResults.appendChild(
+ createElementWithClassName('ol', 'site-results'));
+ resultsList.classList.add('grouped');
+
+ // Collapse until it gets toggled.
+ resultsList.style.height = 0;
+ resultsList.setAttribute('aria-hidden', 'true');
+
+ // Add the results for each of the domain.
+ var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH;
+ for (var j = 0, visit; visit = domainVisits[j]; j++) {
+ resultsList.appendChild(visit.getResultDOM({
+ focusless: true,
+ useMonthDate: isMonthGroupedResult,
+ }));
+ this.setVisitRendered_(visit);
+ }
+};
+
+/**
+ * Enables or disables the time range buttons.
+ * @private
+ */
+HistoryView.prototype.updateRangeButtons_ = function() {
+ // The enabled state for the previous, today and next buttons.
+ var previousState = false;
+ var todayState = false;
+ var nextState = false;
+ var usePage = (this.getRangeInDays() == HistoryModel.Range.ALL_TIME);
+
+ // Use pagination for most recent visits, offset otherwise.
+ // TODO(sergiu): Maybe send just one variable in the future.
+ if (usePage) {
+ if (this.getPage() != 0) {
+ nextState = true;
+ todayState = true;
+ }
+ previousState = this.model_.hasMoreResults();
+ } else {
+ if (this.getOffset() != 0) {
+ nextState = true;
+ todayState = true;
+ }
+ previousState = !this.model_.isQueryFinished_;
+ }
+
+ $('range-previous').disabled = !previousState;
+ $('range-today').disabled = !todayState;
+ $('range-next').disabled = !nextState;
+};
+
+/**
+ * Groups visits by domain, sorting them by the number of visits.
+ * @param {Array} visits Visits received from the query results.
+ * @param {Element} results Object where the results are added to.
+ * @private
+ */
+HistoryView.prototype.groupVisitsByDomain_ = function(visits, results) {
+ var visitsByDomain = {};
+ var domains = [];
+
+ // Group the visits into a dictionary and generate a list of domains.
+ for (var i = 0, visit; visit = visits[i]; i++) {
+ var domain = visit.domain_;
+ if (!visitsByDomain[domain]) {
+ visitsByDomain[domain] = [];
+ domains.push(domain);
+ }
+ visitsByDomain[domain].push(visit);
+ }
+ var sortByVisits = function(a, b) {
+ return visitsByDomain[b].length - visitsByDomain[a].length;
+ };
+ domains.sort(sortByVisits);
+
+ for (var i = 0; i < domains.length; ++i) {
+ var domain = domains[i];
+ this.getGroupedVisitsDOM_(results, domain, visitsByDomain[domain]);
+ }
+};
+
+/**
+ * Adds the results for a month.
+ * @param {Array} visits Visits returned by the query.
+ * @param {Node} parentNode Node to which to add the results to.
+ * @private
+ */
+HistoryView.prototype.addMonthResults_ = function(visits, parentNode) {
+ if (visits.length == 0)
+ return;
+
+ var monthResults = /** @type {HTMLOListElement} */(parentNode.appendChild(
+ createElementWithClassName('ol', 'month-results')));
+ // Don't add checkboxes if entries can not be edited.
+ if (!this.model_.editingEntriesAllowed)
+ monthResults.classList.add('no-checkboxes');
+
+ this.groupVisitsByDomain_(visits, monthResults);
+};
+
+/**
+ * Adds the results for a certain day. This includes a title with the day of
+ * the results and the results themselves, grouped or not.
+ * @param {Array} visits Visits returned by the query.
+ * @param {Node} parentNode Node to which to add the results to.
+ * @private
+ */
+HistoryView.prototype.addDayResults_ = function(visits, parentNode) {
+ if (visits.length == 0)
+ return;
+
+ var firstVisit = visits[0];
+ var day = parentNode.appendChild(createElementWithClassName('h3', 'day'));
+ day.appendChild(document.createTextNode(firstVisit.dateRelativeDay));
+ if (firstVisit.continued) {
+ day.appendChild(document.createTextNode(' ' +
+ loadTimeData.getString('cont')));
+ }
+ var dayResults = /** @type {HTMLElement} */(parentNode.appendChild(
+ createElementWithClassName('ol', 'day-results')));
+
+ // Don't add checkboxes if entries can not be edited.
+ if (!this.model_.editingEntriesAllowed)
+ dayResults.classList.add('no-checkboxes');
+
+ if (this.model_.getGroupByDomain()) {
+ this.groupVisitsByDomain_(visits, dayResults);
+ } else {
+ var lastTime;
+
+ for (var i = 0, visit; visit = visits[i]; i++) {
+ // If enough time has passed between visits, indicate a gap in browsing.
+ var thisTime = visit.date.getTime();
+ if (lastTime && lastTime - thisTime > BROWSING_GAP_TIME)
+ dayResults.appendChild(createElementWithClassName('li', 'gap'));
+
+ // Insert the visit into the DOM.
+ dayResults.appendChild(visit.getResultDOM({ addTitleFavicon: true }));
+ this.setVisitRendered_(visit);
+
+ lastTime = thisTime;
+ }
+ }
+};
+
+/**
+ * Adds the text that shows the current interval, used for week and month
+ * results.
+ * @param {Node} resultsFragment The element to which the interval will be
+ * added to.
+ * @private
+ */
+HistoryView.prototype.addTimeframeInterval_ = function(resultsFragment) {
+ if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME)
+ return;
+
+ // If this is a time range result add some text that shows what is the
+ // time range for the results the user is viewing.
+ var timeFrame = resultsFragment.appendChild(
+ createElementWithClassName('h2', 'timeframe'));
+ // TODO(sergiu): Figure the best way to show this for the first day of
+ // the month.
+ timeFrame.appendChild(document.createTextNode(loadTimeData.getStringF(
+ 'historyInterval',
+ this.model_.queryStartTime,
+ this.model_.queryEndTime)));
+};
+
+/**
+ * Update the page with results.
+ * @param {boolean} doneLoading Whether the current request is complete.
+ * @private
+ */
+HistoryView.prototype.displayResults_ = function(doneLoading) {
+ // Either show a page of results received for the all time results or all the
+ // received results for the weekly and monthly view.
+ var results = this.model_.visits_;
+ if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) {
+ var rangeStart = this.pageIndex_ * RESULTS_PER_PAGE;
+ var rangeEnd = rangeStart + RESULTS_PER_PAGE;
+ results = this.model_.getNumberedRange(rangeStart, rangeEnd);
+ }
+ var searchText = this.model_.getSearchText();
+ var groupByDomain = this.model_.getGroupByDomain();
+
+ if (searchText) {
+ var headerText;
+ if (!doneLoading) {
+ headerText = loadTimeData.getStringF('searchResultsFor', searchText);
+ } else if (results.length == 0) {
+ headerText = loadTimeData.getString('noSearchResults');
+ } else {
+ var resultId = results.length == 1 ? 'searchResult' : 'searchResults';
+ headerText = loadTimeData.getStringF('foundSearchResults',
+ results.length,
+ loadTimeData.getString(resultId),
+ searchText);
+ }
+ $('results-header').textContent = headerText;
+
+ this.addTimeframeInterval_(this.resultDiv_);
+
+ var searchResults = createElementWithClassName('ol', 'search-results');
+
+ // Don't add checkboxes if entries can not be edited.
+ if (!this.model_.editingEntriesAllowed)
+ searchResults.classList.add('no-checkboxes');
+
+ if (doneLoading) {
+ for (var i = 0, visit; visit = results[i]; i++) {
+ if (!visit.isRendered) {
+ searchResults.appendChild(visit.getResultDOM({
+ isSearchResult: true,
+ addTitleFavicon: true
+ }));
+ this.setVisitRendered_(visit);
+ }
+ }
+ }
+ this.resultDiv_.appendChild(searchResults);
+ } else {
+ var resultsFragment = document.createDocumentFragment();
+
+ this.addTimeframeInterval_(resultsFragment);
+
+ var noResults = results.length == 0 && doneLoading;
+ $('results-header').textContent = noResults ?
+ loadTimeData.getString('noResults') : '';
+
+ if (noResults)
+ return;
+
+ if (this.getRangeInDays() == HistoryModel.Range.MONTH &&
+ groupByDomain) {
+ // Group everything together in the month view.
+ this.addMonthResults_(results, resultsFragment);
+ } else {
+ var dayStart = 0;
+ var dayEnd = 0;
+ // Go through all of the visits and process them in chunks of one day.
+ while (dayEnd < results.length) {
+ // Skip over the ones that are already rendered.
+ while (dayStart < results.length && results[dayStart].isRendered)
+ ++dayStart;
+ var dayEnd = dayStart + 1;
+ while (dayEnd < results.length && results[dayEnd].continued)
+ ++dayEnd;
+
+ this.addDayResults_(
+ results.slice(dayStart, dayEnd), resultsFragment);
+ }
+ }
+
+ // Add all the days and their visits to the page.
+ this.resultDiv_.appendChild(resultsFragment);
+ }
+ // After the results have been added to the DOM, determine the size of the
+ // time column.
+ this.setTimeColumnWidth_();
+};
+
+var focusGridRowSelector = [
+ ':-webkit-any(.day-results, .search-results) > .entry:not(.fade-out)',
+ '.expand .grouped .entry:not(.fade-out)',
+ '.site-domain-wrapper'
+].join(', ');
+
+/** @private */
+HistoryView.prototype.updateFocusGrid_ = function() {
+ var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector);
+ this.focusGrid_.destroy();
+
+ for (var i = 0; i < rows.length; ++i) {
+ assert(rows[i].parentNode);
+ this.focusGrid_.addRow(new HistoryFocusRow(rows[i], this.resultDiv_));
+ }
+ this.focusGrid_.ensureRowActive();
+};
+
+/**
+ * Update the visibility of the page navigation buttons.
+ * @private
+ */
+HistoryView.prototype.updateNavBar_ = function() {
+ this.updateRangeButtons_();
+
+ // If grouping by domain is enabled, there's a control bar on top, don't show
+ // the one on the bottom as well.
+ if (!loadTimeData.getBoolean('groupByDomain')) {
+ $('newest-button').hidden = this.pageIndex_ == 0;
+ $('newer-button').hidden = this.pageIndex_ == 0;
+ $('older-button').hidden =
+ this.model_.rangeInDays_ != HistoryModel.Range.ALL_TIME ||
+ !this.model_.hasMoreResults();
+ }
+};
+
+/**
+ * Updates the visibility of the 'Clear browsing data' button.
+ * Only used on mobile platforms.
+ * @private
+ */
+HistoryView.prototype.updateClearBrowsingDataButton_ = function() {
+ // Ideally, we should hide the 'Clear browsing data' button whenever the
+ // soft keyboard is visible. This is not possible, so instead, hide the
+ // button whenever the search field has focus.
+ $('clear-browsing-data').hidden =
+ (document.activeElement === $('search-field'));
+
+ // If clear browsing button is not positioned at the bottom, set display to
+ // force a redraw. This occurs when switching between screen sizes in iOS
+ // multi-tasking.
+ if (!$('clear-browsing-data').hidden &&
+ $('clear-browsing-data').getBoundingClientRect().bottom !==
+ $('history-page').getBoundingClientRect().height) {
+ $('clear-browsing-data').style.display = 'block';
+ }
+};
+
+/**
+ * Dynamically sets the min-width of the time column for history entries.
+ * This ensures that all entry times will have the same width, without
+ * imposing a fixed width that may not be appropriate for some locales.
+ * @private
+ */
+HistoryView.prototype.setTimeColumnWidth_ = function() {
+ // Find the maximum width of all the time elements on the page.
+ var times = this.resultDiv_.querySelectorAll('.entry .time');
+ Array.prototype.forEach.call(times, function(el) {
+ el.style.minWidth = '-webkit-min-content';
+ });
+ var widths = Array.prototype.map.call(times, function(el) {
+ // Add an extra pixel to prevent rounding errors from causing the text to
+ // be ellipsized at certain zoom levels (see crbug.com/329779).
+ return el.clientWidth + 1;
+ });
+ Array.prototype.forEach.call(times, function(el) {
+ el.style.minWidth = '';
+ });
+ var maxWidth = widths.length ? Math.max.apply(null, widths) : 0;
+
+ // Add a dynamic stylesheet to the page (or replace the existing one), to
+ // ensure that all entry times have the same width.
+ var styleEl = $('timeColumnStyle');
+ if (!styleEl) {
+ styleEl = document.head.appendChild(document.createElement('style'));
+ styleEl.id = 'timeColumnStyle';
+ }
+ styleEl.textContent = '.entry .time { min-width: ' + maxWidth + 'px; }';
+};
+
+/**
+ * Toggles an element in the grouped history.
+ * @param {Event} e The event with element |e.target| which was clicked on.
+ * @private
+ */
+HistoryView.prototype.toggleGroupedVisits_ = function(e) {
+ var entry = findAncestorByClass(/** @type {Element} */(e.target),
+ 'site-entry');
+ var innerResultList = entry.querySelector('.site-results');
+
+ if (entry.classList.contains('expand')) {
+ innerResultList.style.height = 0;
+ innerResultList.setAttribute('aria-hidden', 'true');
+ } else {
+ innerResultList.setAttribute('aria-hidden', 'false');
+ innerResultList.style.height = 'auto';
+ // -webkit-transition does not work on height:auto elements so first set
+ // the height to auto so that it is computed and then set it to the
+ // computed value in pixels so the transition works properly.
+ var height = innerResultList.clientHeight;
+ innerResultList.style.height = 0;
+ setTimeout(function() {
+ innerResultList.style.height = height + 'px';
+ }, 0);
+ }
+
+ entry.classList.toggle('expand');
+
+ var root = entry.querySelector('.site-domain-wrapper');
+
+ this.focusGrid_.rows.forEach(function(row) {
+ row.makeActive(row.root == root);
+ });
+
+ this.updateFocusGrid_();
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// State object:
+/**
+ * An 'AJAX-history' implementation.
+ * @param {HistoryModel} model The model we're representing.
+ * @param {HistoryView} view The view we're representing.
+ * @constructor
+ */
+function PageState(model, view) {
+ // Enforce a singleton.
+ if (PageState.instance) {
+ return PageState.instance;
+ }
+
+ this.model = model;
+ this.view = view;
+
+ if (typeof this.checker_ != 'undefined' && this.checker_) {
+ clearInterval(this.checker_);
+ }
+
+ // TODO(glen): Replace this with a bound method so we don't need
+ // public model and view.
+ this.checker_ = window.setInterval(function() {
+ var hashData = this.getHashData();
+ var page = parseInt(hashData.page, 10);
+ var range = parseInt(hashData.range, 10);
+ var offset = parseInt(hashData.offset, 10);
+ if (hashData.q != this.model.getSearchText() ||
+ page != this.view.getPage() ||
+ range != this.model.rangeInDays ||
+ offset != this.model.offset) {
+ this.view.setPageState(hashData.q, page, range, offset);
+ }
+ }.bind(this), 50);
+}
+
+/**
+ * Holds the singleton instance.
+ */
+PageState.instance = null;
+
+/**
+ * @return {Object} An object containing parameters from our window hash.
+ */
+PageState.prototype.getHashData = function() {
+ var result = {
+ q: '',
+ page: 0,
+ range: 0,
+ offset: 0
+ };
+
+ if (!window.location.hash)
+ return result;
+
+ var hashSplit = window.location.hash.substr(1).split('&');
+ for (var i = 0; i < hashSplit.length; i++) {
+ var pair = hashSplit[i].split('=');
+ if (pair.length > 1)
+ result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' '));
+ }
+
+ return result;
+};
+
+/**
+ * Set the hash to a specified state, this will create an entry in the
+ * session history so the back button cycles through hash states, which
+ * are then picked up by our listener.
+ * @param {string} term The current search string.
+ * @param {number} page The page currently being viewed.
+ * @param {HistoryModel.Range} range The range to view or search over.
+ * @param {number} offset Set the begining of the query to the specific offset.
+ */
+PageState.prototype.setUIState = function(term, page, range, offset) {
+ // Make sure the form looks pretty.
+ $('search-field').value = term;
+ var hash = this.getHashData();
+ if (hash.q != term || hash.page != page || hash.range != range ||
+ hash.offset != offset) {
+ window.location.hash = PageState.getHashString(term, page, range, offset);
+ }
+};
+
+/**
+ * Static method to get the hash string for a specified state
+ * @param {string} term The current search string.
+ * @param {number} page The page currently being viewed.
+ * @param {HistoryModel.Range} range The range to view or search over.
+ * @param {number} offset Set the begining of the query to the specific offset.
+ * @return {string} The string to be used in a hash.
+ */
+PageState.getHashString = function(term, page, range, offset) {
+ // Omit elements that are empty.
+ var newHash = [];
+
+ if (term)
+ newHash.push('q=' + encodeURIComponent(term));
+
+ if (page)
+ newHash.push('page=' + page);
+
+ if (range)
+ newHash.push('range=' + range);
+
+ if (offset)
+ newHash.push('offset=' + offset);
+
+ return newHash.join('&');
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// Document Functions:
+/**
+ * Window onload handler, sets up the page.
+ */
+function load() {
+ FocusOutlineManager.forDocument(document);
+
+ var searchField = $('search-field');
+
+ historyModel = new HistoryModel();
+ historyView = new HistoryView(historyModel);
+ pageState = new PageState(historyModel, historyView);
+
+ // Create default view.
+ var hashData = pageState.getHashData();
+ var page = parseInt(hashData.page, 10) || historyView.getPage();
+ var range = /** @type {HistoryModel.Range} */(parseInt(hashData.range, 10)) ||
+ historyView.getRangeInDays();
+ var offset = parseInt(hashData.offset, 10) || historyView.getOffset();
+ historyView.setPageState(hashData.q, page, range, offset);
+
+ if ($('overlay')) {
+ cr.ui.overlay.setupOverlay($('overlay'));
+ cr.ui.overlay.globalInitialization();
+ }
+
+ var doSearch = function(e) {
+ recordUmaAction('HistoryPage_Search');
+ historyView.setSearch(searchField.value);
+ searchField.blur(); // Dismiss the keyboard.
+ };
+
+ var removeMenu = getRequiredElement('remove-visit');
+ // Decorate remove-visit before disabling/hiding because the values are
+ // overwritten when decorating a MenuItem that has a Command.
+ cr.ui.decorate(removeMenu, MenuItem);
+ removeMenu.disabled = !loadTimeData.getBoolean('allowDeletingHistory');
+ removeMenu.hidden = loadTimeData.getBoolean('hideDeleteVisitUI');
+
+ document.addEventListener('command', handleCommand);
+
+ searchField.addEventListener('search', doSearch);
+ $('search-button').addEventListener('click', doSearch);
+
+ $('more-from-site').addEventListener('activate', function(e) {
+ activeVisit.showMoreFromSite_();
+ activeVisit = null;
+ });
+
+ // Only show the controls if the command line switch is activated or the user
+ // is supervised.
+ if (loadTimeData.getBoolean('groupByDomain')) {
+ $('history-page').classList.add('big-topbar-page');
+ $('filter-controls').hidden = false;
+ }
+ // Hide the top container which has the "Clear browsing data" and "Remove
+ // selected entries" buttons if deleting history is not allowed.
+ if (!loadTimeData.getBoolean('allowDeletingHistory'))
+ $('top-container').hidden = true;
+
+ // Adjust the position of the notification bar when the window size changes.
+ window.addEventListener('resize',
+ historyView.positionNotificationBar.bind(historyView));
+
+ // Move the search box out of the header.
+ var resultsDisplay = $('results-display');
+ resultsDisplay.parentNode.insertBefore($('search-field'), resultsDisplay);
+
+ window.addEventListener(
+ 'resize', historyView.updateClearBrowsingDataButton_);
+
+ // Trigger window resize event when search field is focused to force update
+ // of the clear browsing button, which should disappear when search field
+ // is active. The window is not resized when the virtual keyboard is shown
+ // on iOS.
+ searchField.addEventListener('focus', function() {
+ cr.dispatchSimpleEvent(window, 'resize');
+ });
+
+ // When the search field loses focus, add a delay before updating the
+ // visibility, otherwise the button will flash on the screen before the
+ // keyboard animates away.
+ searchField.addEventListener('blur', function() {
+ setTimeout(historyView.updateClearBrowsingDataButton_, 250);
+ });
+
+ // Move the button to the bottom of the page.
+ $('history-page').appendChild($('clear-browsing-data'));
+
+ function checkKeyboardVisibility() {
+ // Figure out the real height based on the orientation, becauase
+ // screen.width and screen.height don't update after rotation.
+ var screenHeight = window.orientation % 180 ? screen.width : screen.height;
+
+ // Assume that the keyboard is visible if more than 30% of the screen is
+ // taken up by window chrome.
+ var isKeyboardVisible = (window.innerHeight / screenHeight) < 0.7;
+
+ document.body.classList.toggle('ios-keyboard-visible', isKeyboardVisible);
+ }
+ window.addEventListener('orientationchange', checkKeyboardVisibility);
+ window.addEventListener('resize', checkKeyboardVisibility);
+}
+
+/**
+ * Handle all commands in the history page.
+ * @param {!Event} e is a command event.
+ */
+function handleCommand(e) {
+ switch (e.command.id) {
+ case 'remove-visit-command':
+ // Removing visited items needs to be done with a command in order to have
+ // proper focus. This is because the command event is handled after the
+ // menu dialog is no longer visible and focus has returned to the history
+ // items. The activate event is handled when the menu dialog is still
+ // visible and focus is lost.
+ // removeEntryFromHistory_ will update activeVisit to the newly focused
+ // history item.
+ assert(!$('remove-visit').disabled);
+ activeVisit.removeEntryFromHistory_(e);
+ break;
+ }
+}
+
+/**
+ * Updates the filter status labels of a host/URL entry to the current value.
+ * @param {Element} statusElement The div which contains the status labels.
+ * @param {SupervisedUserFilteringBehavior} newStatus The filter status of the
+ * current domain/URL.
+ */
+function updateHostStatus(statusElement, newStatus) {
+ var filteringBehaviorDiv =
+ statusElement.querySelector('.filtering-behavior');
+ // Reset to the base class first, then add modifier classes if needed.
+ filteringBehaviorDiv.className = 'filtering-behavior';
+ if (newStatus == SupervisedUserFilteringBehavior.BLOCK) {
+ filteringBehaviorDiv.textContent =
+ loadTimeData.getString('filterBlocked');
+ filteringBehaviorDiv.classList.add('filter-blocked');
+ } else {
+ filteringBehaviorDiv.textContent = '';
+ }
+}
+
+/**
+ * Click handler for the 'Clear browsing data' dialog.
+ * @param {Event} e The click event.
+ */
+function openClearBrowsingData(e) {
+ recordUmaAction('HistoryPage_InitClearBrowsingData');
+ chrome.send('clearBrowsingData');
+}
+
+/**
+ * Shows the dialog for the user to confirm removal of selected history entries.
+ */
+function showConfirmationOverlay() {
+ $('alertOverlay').classList.add('showing');
+ $('overlay').hidden = false;
+ $('history-page').setAttribute('aria-hidden', 'true');
+
+ // Change focus to the overlay if any other control was focused by keyboard
+ // before. Otherwise, no one should have focus.
+ var focusOverlay = FocusOutlineManager.forDocument(document).visible &&
+ document.activeElement != document.body;
+ if ($('history-page').contains(document.activeElement))
+ document.activeElement.blur();
+
+ if (focusOverlay) {
+ // Wait until the browser knows the button has had a chance to become
+ // visible.
+ window.requestAnimationFrame(function() {
+ var button = cr.ui.overlay.getDefaultButton($('overlay'));
+ if (button)
+ button.focus();
+ });
+ }
+ $('alertOverlay').classList.toggle('focus-on-hide', focusOverlay);
+}
+
+/**
+ * Hides the confirmation overlay used to confirm selected history entries.
+ */
+function hideConfirmationOverlay() {
+ $('alertOverlay').classList.remove('showing');
+ $('overlay').hidden = true;
+ $('history-page').removeAttribute('aria-hidden');
+}
+
+/**
+ * Shows the confirmation alert for history deletions and permits browser tests
+ * to override the dialog.
+ * @param {function()=} okCallback A function to be called when the user presses
+ * the ok button.
+ * @param {function()=} cancelCallback A function to be called when the user
+ * presses the cancel button.
+ */
+function confirmDeletion(okCallback, cancelCallback) {
+ alertOverlay.setValues(
+ loadTimeData.getString('removeSelected'),
+ loadTimeData.getString('deleteWarning'),
+ loadTimeData.getString('deleteConfirm'),
+ loadTimeData.getString('cancel'),
+ okCallback,
+ cancelCallback);
+ showConfirmationOverlay();
+}
+
+/**
+ * Click handler for the 'Remove selected items' button.
+ * Confirms the deletion with the user, and then deletes the selected visits.
+ */
+function removeItems() {
+ recordUmaAction('HistoryPage_RemoveSelected');
+ if (!loadTimeData.getBoolean('allowDeletingHistory'))
+ return;
+
+ var checked = $('results-display').querySelectorAll(
+ '.entry-box input[type=checkbox]:checked:not([disabled])');
+ var disabledItems = [];
+ var toBeRemoved = [];
+
+ for (var i = 0; i < checked.length; i++) {
+ var checkbox = checked[i];
+ var entry = findAncestorByClass(checkbox, 'entry');
+ toBeRemoved.push(entry.visit);
+
+ // Disable the checkbox and put a strikethrough style on the link, so the
+ // user can see what will be deleted.
+ checkbox.disabled = true;
+ entry.visit.titleLink.classList.add('to-be-removed');
+ disabledItems.push(checkbox);
+ var integerId = parseInt(entry.visit.id_, 10);
+ // Record the ID of the entry to signify how many entries are above this
+ // link on the page.
+ recordUmaHistogram('HistoryPage.RemoveEntryPosition',
+ UMA_MAX_BUCKET_VALUE,
+ integerId);
+ if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) {
+ recordUmaHistogram('HistoryPage.RemoveEntryPositionSubset',
+ UMA_MAX_SUBSET_BUCKET_VALUE,
+ integerId);
+ }
+ if (entry.parentNode.className == 'search-results')
+ recordUmaAction('HistoryPage_SearchResultRemove');
+ }
+
+ function onConfirmRemove() {
+ recordUmaAction('HistoryPage_ConfirmRemoveSelected');
+ historyModel.removeVisitsFromHistory(toBeRemoved,
+ historyView.reload.bind(historyView));
+ $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
+ hideConfirmationOverlay();
+ if ($('alertOverlay').classList.contains('focus-on-hide') &&
+ FocusOutlineManager.forDocument(document).visible) {
+ $('search-field').focus();
+ }
+ }
+
+ function onCancelRemove() {
+ recordUmaAction('HistoryPage_CancelRemoveSelected');
+ // Return everything to its previous state.
+ for (var i = 0; i < disabledItems.length; i++) {
+ var checkbox = disabledItems[i];
+ checkbox.disabled = false;
+
+ var entry = findAncestorByClass(checkbox, 'entry');
+ entry.visit.titleLink.classList.remove('to-be-removed');
+ }
+ $('overlay').removeEventListener('cancelOverlay', onCancelRemove);
+ hideConfirmationOverlay();
+ if ($('alertOverlay').classList.contains('focus-on-hide') &&
+ FocusOutlineManager.forDocument(document).visible) {
+ $('remove-selected').focus();
+ }
+ }
+
+ if (checked.length) {
+ confirmDeletion(onConfirmRemove, onCancelRemove);
+ $('overlay').addEventListener('cancelOverlay', onCancelRemove);
+ }
+}
+
+/**
+ * Handler for the 'click' event on a checkbox.
+ * @param {Event} e The click event.
+ */
+function checkboxClicked(e) {
+ handleCheckboxStateChange(/** @type {!HTMLInputElement} */(e.currentTarget),
+ e.shiftKey);
+}
+
+/**
+ * Post-process of checkbox state change. This handles range selection and
+ * updates internal state.
+ * @param {!HTMLInputElement} checkbox Clicked checkbox.
+ * @param {boolean} shiftKey true if shift key is pressed.
+ */
+function handleCheckboxStateChange(checkbox, shiftKey) {
+ updateParentCheckbox(checkbox);
+ var id = Number(checkbox.id.slice('checkbox-'.length));
+ // Handle multi-select if shift was pressed.
+ if (shiftKey && (selectionAnchor != -1)) {
+ var checked = checkbox.checked;
+ // Set all checkboxes from the anchor up to the clicked checkbox to the
+ // state of the clicked one.
+ var begin = Math.min(id, selectionAnchor);
+ var end = Math.max(id, selectionAnchor);
+ for (var i = begin; i <= end; i++) {
+ var ithCheckbox = document.querySelector('#checkbox-' + i);
+ if (ithCheckbox) {
+ ithCheckbox.checked = checked;
+ updateParentCheckbox(ithCheckbox);
+ }
+ }
+ }
+ selectionAnchor = id;
+
+ historyView.updateSelectionEditButtons();
+}
+
+/**
+ * Handler for the 'click' event on a domain checkbox. Checkes or unchecks the
+ * checkboxes of the visits to this domain in the respective group.
+ * @param {Event} e The click event.
+ */
+function domainCheckboxClicked(e) {
+ var siteEntry = findAncestorByClass(/** @type {Element} */(e.currentTarget),
+ 'site-entry');
+ var checkboxes =
+ siteEntry.querySelectorAll('.site-results input[type=checkbox]');
+ for (var i = 0; i < checkboxes.length; i++)
+ checkboxes[i].checked = e.currentTarget.checked;
+ historyView.updateSelectionEditButtons();
+ // Stop propagation as clicking the checkbox would otherwise trigger the
+ // group to collapse/expand.
+ e.stopPropagation();
+}
+
+/**
+ * Updates the domain checkbox for this visit checkbox if it has been
+ * unchecked.
+ * @param {Element} checkbox The checkbox that has been clicked.
+ */
+function updateParentCheckbox(checkbox) {
+ if (checkbox.checked)
+ return;
+
+ var entry = findAncestorByClass(checkbox, 'site-entry');
+ if (!entry)
+ return;
+
+ var groupCheckbox = entry.querySelector('.site-domain-wrapper input');
+ if (groupCheckbox)
+ groupCheckbox.checked = false;
+}
+
+/**
+ * Handle click event for entryBoxes.
+ * @param {!Event} event A click event.
+ */
+function entryBoxClick(event) {
+ event = /** @type {!MouseEvent} */(event);
+ // Do nothing if a bookmark star is clicked.
+ if (event.defaultPrevented)
+ return;
+ var element = event.target;
+ // Do nothing if the event happened in an interactive element.
+ for (; element != event.currentTarget; element = element.parentNode) {
+ switch (element.tagName) {
+ case 'A':
+ case 'BUTTON':
+ case 'INPUT':
+ return;
+ }
+ }
+ var checkbox = assertInstanceof($(event.currentTarget.getAttribute('for')),
+ HTMLInputElement);
+ checkbox.checked = !checkbox.checked;
+ handleCheckboxStateChange(checkbox, event.shiftKey);
+ // We don't want to focus on the checkbox.
+ event.preventDefault();
+}
+
+/**
+ * Called when an individual history entry has been removed from the page.
+ * This will only be called when all the elements affected by the deletion
+ * have been removed from the DOM and the animations have completed.
+ */
+function onEntryRemoved() {
+ historyView.onEntryRemoved();
+}
+
+/**
+ * Triggers a fade-out animation, and then removes |node| from the DOM.
+ * @param {Node} node The node to be removed.
+ * @param {Function?} onRemove A function to be called after the node
+ * has been removed from the DOM.
+ * @param {*=} opt_scope An optional scope object to call |onRemove| with.
+ */
+function removeNode(node, onRemove, opt_scope) {
+ node.classList.add('fade-out'); // Trigger CSS fade out animation.
+
+ // Delete the node when the animation is complete.
+ node.addEventListener('webkitTransitionEnd', function(e) {
+ node.parentNode.removeChild(node);
+
+ // In case there is nested deletion happening, prevent this event from
+ // being handled by listeners on ancestor nodes.
+ e.stopPropagation();
+
+ if (onRemove)
+ onRemove.call(opt_scope);
+ });
+}
+
+/**
+ * Builds the DOM elements to show the filtering status of a domain/URL.
+ * @param {SupervisedUserFilteringBehavior} filteringBehavior The filter
+ * behavior for this item.
+ * @return {Element} Returns the DOM elements which show the status.
+ */
+function getFilteringStatusDOM(filteringBehavior) {
+ var filterStatusDiv = createElementWithClassName('div', 'filter-status');
+ var filteringBehaviorDiv =
+ createElementWithClassName('div', 'filtering-behavior');
+ filterStatusDiv.appendChild(filteringBehaviorDiv);
+
+ updateHostStatus(filterStatusDiv, filteringBehavior);
+ return filterStatusDiv;
+}
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Chrome callbacks:
+
+/**
+ * Our history system calls this function with results from searches.
+ * @param {HistoryQuery} info An object containing information about the query.
+ * @param {Array<HistoryEntry>} results A list of results.
+ */
+function historyResult(info, results) {
+ historyModel.addResults(info, results);
+}
+
+/**
+ * Called by the history backend when history removal is successful.
+ */
+function deleteComplete() {
+ historyModel.deleteComplete();
+}
+
+/**
+ * Called by the history backend when history removal is unsuccessful.
+ */
+function deleteFailed() {
+ window.console.log('Delete failed');
+}
+
+/**
+ * Called when the history is deleted by someone else.
+ */
+function historyDeleted() {
+ var anyChecked = document.querySelector('.entry input:checked') != null;
+ // Reload the page, unless the user has any items checked.
+ // TODO(dubroy): We should just reload the page & restore the checked items.
+ if (!anyChecked)
+ historyView.reload();
+}
+
+// Add handlers to HTML elements.
+document.addEventListener('DOMContentLoaded', load);
+
+// This event lets us enable and disable menu items before the menu is shown.
+document.addEventListener('canExecute', function(e) {
+ e.canExecute = true;
+});
diff --git a/ios/chrome/app/resources/history/history_mobile.css b/ios/chrome/app/resources/history/history_mobile.css
new file mode 100644
index 0000000..3b6f9a3
--- /dev/null
+++ b/ios/chrome/app/resources/history/history_mobile.css
@@ -0,0 +1,330 @@
+/* Copyright (c) 2013 The Chromium Authors. All rights reserved.
+ * Use of this source code is governed by a BSD-style license that can be
+ * found in the LICENSE file. */
+
+/* This file contains styles specific to iOS. */
+
+html:not(.focus-outline-visible) :focus {
+ outline: none;
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ color: rgb(76, 76, 76);
+ font-size: initial;
+ height: 100%;
+ margin: 0;
+}
+
+.page {
+ -webkit-flex-flow: column;
+ display: -webkit-flex;
+ height: 100%;
+}
+
+#scrolling-container {
+ -webkit-flex: auto; /* Container should take up extra vertical space. */
+ -webkit-overflow-scrolling: touch;
+ overflow-y: auto;
+}
+
+h1 {
+ font-weight: bold;
+ margin-bottom: 12px;
+}
+
+#top-container,
+#results-display {
+ margin: 0;
+}
+
+#top-container,
+#results-display,
+#results-pagination {
+ max-width: none;
+}
+
+h1,
+h3,
+#notification-bar,
+#search-field,
+.entry-box,
+#loading-spinner {
+ padding-left: 16px;
+ padding-right: 16px;
+}
+
+h3 {
+ background: rgb(245, 245, 245);
+ color: rgb(138, 138, 138);
+ font-size: 14px;
+ height: 30px;
+ line-height: 30px;
+ margin-top: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#search-field {
+ -webkit-padding-start: 64px;
+ background-image:
+ url(../../../../../ui/webui/resources/images/2x/search.png);
+ background-position: 16px center;
+ background-repeat: no-repeat;
+ background-size: 32px;
+ border: 0;
+ display: block;
+ line-height: 1.5;
+ margin-top: 16px;
+ width: 100%;
+}
+
+html[dir='rtl'] #search-field {
+ background-position: right 16px center;
+}
+
+#notification-bar.alone {
+ float: none;
+ font-size: 75%;
+ margin: 0;
+ padding-bottom: 0;
+ padding-top: 0;
+}
+
+#remove-selected,
+#search-button,
+.gap {
+ display: none;
+}
+
+.entry-box {
+ -webkit-padding-end: 0;
+}
+
+button.remove-entry {
+ background: url(../../../../../ui/webui/resources/images/2x/x-thin.png)
+ no-repeat center center;
+ background-size: 13px;
+ border: 0;
+ box-sizing: border-box;
+ height: 100%;
+ min-width: 45px;
+ opacity: 0.7;
+ padding: 0 16px;
+ vertical-align: top;
+ width: 45px;
+}
+
+button.remove-entry:active {
+ opacity: 1.0;
+}
+
+.entry-box {
+ margin-bottom: 0;
+ margin-top: 0;
+ padding-bottom: 0;
+}
+
+h3,
+.entry,
+#search-field {
+ border-bottom: 1px solid rgb(220, 220, 220);
+ border-top: 1px solid rgb(220, 220, 220);
+ margin-bottom: -1px;
+ overflow: hidden;
+}
+
+.entry-box,
+#search-field,
+#results-pagination button {
+ height: 60px;
+}
+
+.entry-box-container {
+ display: block;
+}
+
+input {
+ border-radius: 0;
+}
+
+#clear-browsing-data {
+ /* Style it like a native Android button. */
+ background-color: rgb(221, 221, 221);
+ border: 0;
+ border-radius: 0;
+ border-top: 1px solid rgb(198, 198, 198);
+ box-shadow: none;
+ font-size: 75%;
+ font-weight: bold;
+ height: 46px;
+ margin: 0;
+ min-height: 46px;
+ text-shadow: none;
+ text-transform: uppercase;
+ width: 100%;
+}
+
+.day-results,
+.search-results {
+ margin: 0;
+}
+
+/* Fade out the entry-box, rather than its parent node, so that the dividing
+ line between entries doesn't fade out. */
+.entry.fade-out .entry-box {
+ -webkit-transition: opacity 200ms;
+ opacity: 1;
+}
+
+.entry.fade-out {
+ opacity: 1;
+}
+
+.entry.fade-out .entry-box {
+ opacity: 0;
+}
+
+.entry input[type=checkbox] {
+ display: none;
+}
+
+.entry .visit-entry {
+ -webkit-flex: auto;
+ -webkit-flex-flow: column;
+ -webkit-padding-start: 48px;
+ background-size: 32px;
+ line-height: 1.3;
+}
+
+.entry .visit-entry.blocked-indicator {
+ line-height: 2;
+}
+
+.entry .visit-entry :-webkit-any(a, .domain) {
+ display: block;
+ margin-left: 0;
+ margin-right: 0;
+ min-width: 0;
+ overflow: hidden;
+ padding-left: 0;
+ padding-right: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.entry .visit-entry.blocked-indicator a {
+ display: inline;
+}
+
+.entry .domain {
+ font-size: 14px;
+}
+
+#older-button {
+ -webkit-padding-end: 16px;
+}
+
+#newest-button {
+ -webkit-padding-start: 16px;
+}
+
+#loading-spinner {
+ margin-top: 16px;
+}
+
+/* iOS does not support the latest flexbox syntax, only the 2009 working draft
+ syntax (http://www.w3.org/TR/2009/WD-css3-flexbox-20090723/). */
+.entry-box,
+.site-domain-wrapper {
+ -wekbit-box-align: center;
+ display: -webkit-box;
+}
+
+.entry .visit-entry {
+ -webkit-box-flex: 1;
+ -webkit-box-orient: vertical;
+ -webkit-box-pack: center;
+ display: -webkit-box;
+}
+
+#scrolling-container {
+ bottom: 46px;
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: 0;
+}
+
+#clear-browsing-data {
+ bottom: 0;
+ position: fixed;
+}
+
+.entry .bookmark-section {
+ display: none;
+}
+
+.entry .time {
+ line-height: 60px;
+ min-width: 90px;
+ text-align: inherit;
+ width: 90px;
+}
+
+@media only screen and (max-width:600px) {
+
+ /* Omit the time on very small screens. */
+ .entry .time {
+ display: none;
+ }
+
+} /* @media only screen and (max-width:600px) */
+
+@media only screen and (min-width:720px) {
+
+ h3,
+ .entry,
+ #search-field {
+ border: 1px solid rgb(220, 220, 220);
+ }
+
+ h3 {
+ margin-top: 30px;
+ }
+
+ #scrolling-container {
+ padding-bottom: 30px;
+ }
+
+ #scrolling-container > * {
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 718px;
+ }
+
+ h1,
+ #notification-bar,
+ #loading-spinner {
+ padding-left: 0;
+ padding-right: 0;
+ }
+
+} /* @media only screen and (max-width:720px) */
+
+.ios-keyboard-visible #clear-browsing-data {
+ display: none;
+}
+
+.ios-keyboard-visible #scrolling-container {
+ /* Should be 0, but that breaks scrolling -- see crbug.com/292715. */
+ bottom: -1px;
+}
+
+#results-header:empty {
+ display: none;
+}
diff --git a/ios/chrome/app/resources/history/other_devices.js b/ios/chrome/app/resources/history/other_devices.js
new file mode 100644
index 0000000..8f46192
--- /dev/null
+++ b/ios/chrome/app/resources/history/other_devices.js
@@ -0,0 +1,573 @@
+// Copyright (c) 2013 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview The section of the history page that shows tabs from sessions
+ on other devices.
+ */
+
+///////////////////////////////////////////////////////////////////////////////
+// Globals:
+/** @const */ var MAX_NUM_COLUMNS = 3;
+/** @const */ var NB_ENTRIES_FIRST_ROW_COLUMN = 6;
+/** @const */ var NB_ENTRIES_OTHER_ROWS_COLUMN = 0;
+
+// Histogram buckets for UMA tracking of menu usage.
+/** @const */ var HISTOGRAM_EVENT = {
+ INITIALIZED: 0,
+ SHOW_MENU: 1,
+ LINK_CLICKED: 2,
+ LINK_RIGHT_CLICKED: 3,
+ SESSION_NAME_RIGHT_CLICKED: 4,
+ SHOW_SESSION_MENU: 5,
+ COLLAPSE_SESSION: 6,
+ EXPAND_SESSION: 7,
+ OPEN_ALL: 8,
+ HAS_FOREIGN_DATA: 9,
+ LIMIT: 10 // Should always be the last one.
+};
+
+/**
+ * Record an event in the UMA histogram.
+ * @param {number} eventId The id of the event to be recorded.
+ * @private
+ */
+function recordUmaEvent_(eventId) {
+ chrome.send('metricsHandler:recordInHistogram',
+ ['HistoryPage.OtherDevicesMenu', eventId, HISTOGRAM_EVENT.LIMIT]);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// DeviceContextMenuController:
+
+/**
+ * Controller for the context menu for device names in the list of sessions.
+ * @constructor
+ */
+function DeviceContextMenuController() {
+ this.__proto__ = DeviceContextMenuController.prototype;
+ this.initialize();
+}
+cr.addSingletonGetter(DeviceContextMenuController);
+
+// DeviceContextMenuController, Public: ---------------------------------------
+
+/**
+ * Initialize the context menu for device names in the list of sessions.
+ */
+DeviceContextMenuController.prototype.initialize = function() {
+ var menu = new cr.ui.Menu;
+ cr.ui.decorate(menu, cr.ui.Menu);
+ this.menu = menu;
+ this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText');
+ this.collapseItem_.addEventListener('activate',
+ this.onCollapseOrExpand_.bind(this));
+ this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText');
+ this.expandItem_.addEventListener('activate',
+ this.onCollapseOrExpand_.bind(this));
+ this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText');
+ this.openAllItem_.addEventListener('activate',
+ this.onOpenAll_.bind(this));
+};
+
+/**
+ * Set the session data for the session the context menu was invoked on.
+ * This should never be called when the menu is visible.
+ * @param {Object} session The model object for the session.
+ */
+DeviceContextMenuController.prototype.setSession = function(session) {
+ this.session_ = session;
+ this.updateMenuItems_();
+};
+
+// DeviceContextMenuController, Private: --------------------------------------
+
+/**
+ * Appends a menu item to |this.menu|.
+ * @param {string} textId The ID for the localized string that acts as
+ * the item's label.
+ * @return {Element} The button used for a given menu option.
+ * @private
+ */
+DeviceContextMenuController.prototype.appendMenuItem_ = function(textId) {
+ var button = document.createElement('button');
+ this.menu.appendChild(button);
+ cr.ui.decorate(button, cr.ui.MenuItem);
+ button.textContent = loadTimeData.getString(textId);
+ return button;
+};
+
+/**
+ * Handler for the 'Collapse' and 'Expand' menu items.
+ * @param {Event} e The activation event.
+ * @private
+ */
+DeviceContextMenuController.prototype.onCollapseOrExpand_ = function(e) {
+ this.session_.collapsed = !this.session_.collapsed;
+ this.updateMenuItems_();
+ chrome.send('setForeignSessionCollapsed',
+ [this.session_.tag, this.session_.collapsed]);
+ chrome.send('getForeignSessions'); // Refresh the list.
+
+ var eventId = this.session_.collapsed ?
+ HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION;
+ recordUmaEvent_(eventId);
+};
+
+/**
+ * Handler for the 'Open all' menu item.
+ * @param {Event} e The activation event.
+ * @private
+ */
+DeviceContextMenuController.prototype.onOpenAll_ = function(e) {
+ chrome.send('openForeignSession', [this.session_.tag]);
+ recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL);
+};
+
+/**
+ * Set the visibility of the Expand/Collapse menu items based on the state
+ * of the session that this menu is currently associated with.
+ * @private
+ */
+DeviceContextMenuController.prototype.updateMenuItems_ = function() {
+ this.collapseItem_.hidden = this.session_.collapsed;
+ this.expandItem_.hidden = !this.session_.collapsed;
+ this.menu.selectedItem = this.menu.querySelector(':not([hidden])');
+};
+
+
+///////////////////////////////////////////////////////////////////////////////
+// Device:
+
+/**
+ * Class to hold all the information about a device entry and generate a DOM
+ * node for it.
+ * @param {Object} session An object containing the device's session data.
+ * @param {DevicesView} view The view object this entry belongs to.
+ * @constructor
+ */
+function Device(session, view) {
+ this.view_ = view;
+ this.session_ = session;
+ this.searchText_ = view.getSearchText();
+}
+
+// Device, Public: ------------------------------------------------------------
+
+/**
+ * Get the DOM node to display this device.
+ * @param {int} maxNumTabs The maximum number of tabs to display.
+ * @param {int} row The row in which this device is displayed.
+ * @return {Object} A DOM node to draw the device.
+ */
+Device.prototype.getDOMNode = function(maxNumTabs, row) {
+ var deviceDiv = createElementWithClassName('div', 'device');
+ this.row_ = row;
+ if (!this.session_)
+ return deviceDiv;
+
+ // Name heading
+ var heading = document.createElement('h3');
+ var name = heading.appendChild(
+ createElementWithClassName('span', 'device-name'));
+ name.textContent = this.session_.name;
+ heading.sessionData_ = this.session_;
+ deviceDiv.appendChild(heading);
+
+ // Keep track of the drop down that triggered the menu, so we know
+ // which element to apply the command to.
+ var session = this.session_;
+ function handleDropDownFocus(e) {
+ DeviceContextMenuController.getInstance().setSession(session);
+ }
+ heading.addEventListener('contextmenu', handleDropDownFocus);
+
+ var dropDownButton = new cr.ui.ContextMenuButton;
+ dropDownButton.tabIndex = 0;
+ dropDownButton.classList.add('drop-down');
+ dropDownButton.title = loadTimeData.getString('actionMenuDescription');
+ dropDownButton.addEventListener('mousedown', function(event) {
+ handleDropDownFocus(event);
+ // Mousedown handling of cr.ui.MenuButton.handleEvent calls
+ // preventDefault, which prevents blur of the focused element. We need to
+ // do blur manually.
+ document.activeElement.blur();
+ });
+ dropDownButton.addEventListener('focus', handleDropDownFocus);
+ heading.appendChild(dropDownButton);
+
+ var timeSpan = createElementWithClassName('div', 'device-timestamp');
+ timeSpan.textContent = this.session_.modifiedTime;
+ deviceDiv.appendChild(timeSpan);
+
+ cr.ui.contextMenuHandler.setContextMenu(
+ heading, DeviceContextMenuController.getInstance().menu);
+ if (!this.session_.collapsed)
+ deviceDiv.appendChild(this.createSessionContents_(maxNumTabs));
+
+ return deviceDiv;
+};
+
+/**
+ * Marks tabs as hidden or not in our session based on the given searchText.
+ * @param {string} searchText The search text used to filter the content.
+ */
+Device.prototype.setSearchText = function(searchText) {
+ this.searchText_ = searchText.toLowerCase();
+ for (var i = 0; i < this.session_.windows.length; i++) {
+ var win = this.session_.windows[i];
+ var foundMatch = false;
+ for (var j = 0; j < win.tabs.length; j++) {
+ var tab = win.tabs[j];
+ if (tab.title.toLowerCase().indexOf(this.searchText_) != -1) {
+ foundMatch = true;
+ tab.hidden = false;
+ } else {
+ tab.hidden = true;
+ }
+ }
+ win.hidden = !foundMatch;
+ }
+};
+
+// Device, Private ------------------------------------------------------------
+
+/**
+ * Create the DOM tree representing the tabs and windows of this device.
+ * @param {int} maxNumTabs The maximum number of tabs to display.
+ * @return {Element} A single div containing the list of tabs & windows.
+ * @private
+ */
+Device.prototype.createSessionContents_ = function(maxNumTabs) {
+ var contents = createElementWithClassName('ol', 'device-contents');
+
+ var sessionTag = this.session_.tag;
+ var numTabsShown = 0;
+ var numTabsHidden = 0;
+ for (var i = 0; i < this.session_.windows.length; i++) {
+ var win = this.session_.windows[i];
+ if (win.hidden)
+ continue;
+
+ // Show a separator between multiple windows in the same session.
+ if (i > 0 && numTabsShown < maxNumTabs)
+ contents.appendChild(document.createElement('hr'));
+
+ for (var j = 0; j < win.tabs.length; j++) {
+ var tab = win.tabs[j];
+ if (tab.hidden)
+ continue;
+
+ if (numTabsShown < maxNumTabs) {
+ numTabsShown++;
+ var a = createElementWithClassName('a', 'device-tab-entry');
+ a.href = tab.url;
+ a.style.backgroundImage = getFaviconImageSet(tab.url);
+ this.addHighlightedText_(a, tab.title);
+ // Add a tooltip, since it might be ellipsized. The ones that are not
+ // necessary will be removed once added to the document, so we can
+ // compute sizes.
+ a.title = tab.title;
+
+ // We need to use this to not lose the ids as we go through other loop
+ // turns.
+ function makeClickHandler(sessionTag, windowId, tabId) {
+ return function(e) {
+ recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED);
+ chrome.send('openForeignSession', [sessionTag, windowId, tabId,
+ e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
+ e.preventDefault();
+ };
+ };
+ a.addEventListener('click', makeClickHandler(sessionTag,
+ String(win.sessionId),
+ String(tab.sessionId)));
+ var wrapper = createElementWithClassName('div', 'device-tab-wrapper');
+ wrapper.appendChild(a);
+ contents.appendChild(wrapper);
+ } else {
+ numTabsHidden++;
+ }
+ }
+ }
+
+ if (numTabsHidden > 0) {
+ var moreLink = document.createElement('a', 'action-link');
+ moreLink.classList.add('device-show-more-tabs');
+ moreLink.addEventListener('click', this.view_.increaseRowHeight.bind(
+ this.view_, this.row_, numTabsHidden));
+ moreLink.textContent = loadTimeData.getStringF('xMore', numTabsHidden);
+ var moreWrapper = createElementWithClassName('div', 'more-wrapper');
+ moreWrapper.appendChild(moreLink);
+ contents.appendChild(moreWrapper);
+ }
+
+ return contents;
+};
+
+/**
+ * Add child text nodes to a node such that occurrences of this.searchText_ are
+ * highlighted.
+ * @param {Node} node The node under which new text nodes will be made as
+ * children.
+ * @param {string} content Text to be added beneath |node| as one or more
+ * text nodes.
+ * @private
+ */
+Device.prototype.addHighlightedText_ = function(node, content) {
+ var endOfPreviousMatch = 0;
+ if (this.searchText_) {
+ var lowerContent = content.toLowerCase();
+ var searchTextLenght = this.searchText_.length;
+ var newMatch = lowerContent.indexOf(this.searchText_, 0);
+ while (newMatch != -1) {
+ if (newMatch > endOfPreviousMatch) {
+ node.appendChild(document.createTextNode(
+ content.slice(endOfPreviousMatch, newMatch)));
+ }
+ endOfPreviousMatch = newMatch + searchTextLenght;
+ // Mark the highlighted text in bold.
+ var b = document.createElement('b');
+ b.textContent = content.substring(newMatch, endOfPreviousMatch);
+ node.appendChild(b);
+ newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch);
+ }
+ }
+ if (endOfPreviousMatch < content.length) {
+ node.appendChild(document.createTextNode(
+ content.slice(endOfPreviousMatch)));
+ }
+};
+
+///////////////////////////////////////////////////////////////////////////////
+// DevicesView:
+
+/**
+ * Functions and state for populating the page with HTML.
+ * @constructor
+ */
+function DevicesView() {
+ this.devices_ = []; // List of individual devices.
+ this.resultDiv_ = $('other-devices');
+ this.searchText_ = '';
+ this.rowHeights_ = [NB_ENTRIES_FIRST_ROW_COLUMN];
+ this.focusGrids_ = [];
+ this.updateSignInState(loadTimeData.getBoolean('isUserSignedIn'));
+ this.hasSeenForeignData_ = false;
+ recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED);
+}
+
+// DevicesView, public: -------------------------------------------------------
+
+/**
+ * Updates our sign in state by clearing the view is not signed in or sending
+ * a request to get the data to display otherwise.
+ * @param {boolean} signedIn Whether the user is signed in or not.
+ */
+DevicesView.prototype.updateSignInState = function(signedIn) {
+ if (signedIn)
+ chrome.send('getForeignSessions');
+ else
+ this.clearDOM();
+};
+
+/**
+ * Resets the view sessions.
+ * @param {Object} sessionList The sessions to add.
+ */
+DevicesView.prototype.setSessionList = function(sessionList) {
+ this.devices_ = [];
+ for (var i = 0; i < sessionList.length; i++)
+ this.devices_.push(new Device(sessionList[i], this));
+ this.displayResults_();
+
+ // This metric should only be emitted if we see foreign data, and it should
+ // only be emitted once per page refresh. Flip flag to remember because this
+ // method is called upon any update.
+ if (!this.hasSeenForeignData_ && sessionList.length > 0) {
+ this.hasSeenForeignData_ = true;
+ recordUmaEvent_(HISTOGRAM_EVENT.HAS_FOREIGN_DATA);
+ }
+};
+
+
+/**
+ * Sets the current search text.
+ * @param {string} searchText The text to search.
+ */
+DevicesView.prototype.setSearchText = function(searchText) {
+ if (this.searchText_ != searchText) {
+ this.searchText_ = searchText;
+ for (var i = 0; i < this.devices_.length; i++)
+ this.devices_[i].setSearchText(searchText);
+ this.displayResults_();
+ }
+};
+
+/**
+ * @return {string} The current search text.
+ */
+DevicesView.prototype.getSearchText = function() {
+ return this.searchText_;
+};
+
+/**
+ * Clears the DOM content of the view.
+ */
+DevicesView.prototype.clearDOM = function() {
+ while (this.resultDiv_.hasChildNodes()) {
+ this.resultDiv_.removeChild(this.resultDiv_.lastChild);
+ }
+};
+
+/**
+ * Increase the height of a row by the given amount.
+ * @param {int} row The row number.
+ * @param {int} height The extra height to add to the givent row.
+ */
+DevicesView.prototype.increaseRowHeight = function(row, height) {
+ for (var i = this.rowHeights_.length; i <= row; i++)
+ this.rowHeights_.push(NB_ENTRIES_OTHER_ROWS_COLUMN);
+ this.rowHeights_[row] += height;
+ this.displayResults_();
+};
+
+// DevicesView, Private -------------------------------------------------------
+
+/**
+ * @param {!Element} root
+ * @param {?Node} boundary
+ * @constructor
+ * @extends {cr.ui.FocusRow}
+ */
+function DevicesViewFocusRow(root, boundary) {
+ cr.ui.FocusRow.call(this, root, boundary);
+ assert(this.addItem('menu-button', 'button.drop-down') ||
+ this.addItem('device-tab', '.device-tab-entry') ||
+ this.addItem('more-tabs', '.device-show-more-tabs'));
+}
+
+DevicesViewFocusRow.prototype = {__proto__: cr.ui.FocusRow.prototype};
+
+/**
+ * Update the page with results.
+ * @private
+ */
+DevicesView.prototype.displayResults_ = function() {
+ this.clearDOM();
+ var resultsFragment = document.createDocumentFragment();
+ if (this.devices_.length == 0)
+ return;
+
+ // We'll increase to 0 as we create the first row.
+ var rowIndex = -1;
+ // We need to access the last row and device when we get out of the loop.
+ var currentRowElement;
+ // This is only set when changing rows, yet used on all device columns.
+ var maxNumTabs;
+ for (var i = 0; i < this.devices_.length; i++) {
+ var device = this.devices_[i];
+ // Should we start a new row?
+ if (i % MAX_NUM_COLUMNS == 0) {
+ if (currentRowElement)
+ resultsFragment.appendChild(currentRowElement);
+ currentRowElement = createElementWithClassName('div', 'device-row');
+ rowIndex++;
+ if (rowIndex < this.rowHeights_.length)
+ maxNumTabs = this.rowHeights_[rowIndex];
+ else
+ maxNumTabs = 0;
+ }
+
+ currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex));
+ }
+ if (currentRowElement)
+ resultsFragment.appendChild(currentRowElement);
+
+ this.resultDiv_.appendChild(resultsFragment);
+ // Remove the tootltip on all lines that don't need it. It's easier to
+ // remove them here, after adding them all above, since we have the data
+ // handy above, but we don't have the width yet. Whereas here, we have the
+ // width, and the nodeValue could contain sub nodes for highlighting, which
+ // makes it harder to extract the text data here.
+ tabs = document.getElementsByClassName('device-tab-entry');
+ for (var i = 0; i < tabs.length; i++) {
+ if (tabs[i].scrollWidth <= tabs[i].clientWidth)
+ tabs[i].title = '';
+ }
+
+ this.resultDiv_.appendChild(
+ createElementWithClassName('div', 'other-devices-bottom'));
+
+ this.focusGrids_.forEach(function(grid) { grid.destroy(); });
+ this.focusGrids_.length = 0;
+
+ var devices = this.resultDiv_.querySelectorAll('.device-contents');
+ for (var i = 0; i < devices.length; ++i) {
+ var rows = devices[i].querySelectorAll(
+ 'h3, .device-tab-wrapper, .more-wrapper');
+ if (!rows.length)
+ continue;
+
+ var grid = new cr.ui.FocusGrid();
+ for (var j = 0; j < rows.length; ++j) {
+ grid.addRow(new DevicesViewFocusRow(rows[j], devices[i]));
+ }
+ grid.ensureRowActive();
+ this.focusGrids_.push(grid);
+ }
+};
+
+/**
+ * Sets the menu model data. An empty list means that either there are no
+ * foreign sessions, or tab sync is disabled for this profile.
+ * |isTabSyncEnabled| makes it possible to distinguish between the cases.
+ *
+ * @param {Array} sessionList Array of objects describing the sessions
+ * from other devices.
+ * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile?
+ */
+function setForeignSessions(sessionList, isTabSyncEnabled) {
+ // The other devices is shown iff tab sync is enabled.
+ if (isTabSyncEnabled)
+ devicesView.setSessionList(sessionList);
+ else
+ devicesView.clearDOM();
+}
+
+/**
+ * Called when initialized or the user's signed in state changes,
+ * @param {boolean} isUserSignedIn Is the user currently signed in?
+ */
+function updateSignInState(isUserSignedIn) {
+ if (devicesView)
+ devicesView.updateSignInState(isUserSignedIn);
+}
+
+///////////////////////////////////////////////////////////////////////////////
+// Document Functions:
+/**
+ * Window onload handler, sets up the other devices view.
+ */
+function load() {
+ if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled'))
+ return;
+
+ devicesView = new DevicesView();
+
+ // Create the context menu that appears when the user right clicks
+ // on a device name or hit click on the button besides the device name
+ document.body.appendChild(DeviceContextMenuController.getInstance().menu);
+
+ var doSearch = function(e) {
+ devicesView.setSearchText($('search-field').value);
+ };
+ $('search-field').addEventListener('search', doSearch);
+ $('search-button').addEventListener('click', doSearch);
+
+ chrome.send('otherDevicesInitialized');
+}
+
+// Add handlers to HTML elements.
+document.addEventListener('DOMContentLoaded', load);
diff --git a/ios/chrome/app/resources/ios_resources.grd b/ios/chrome/app/resources/ios_resources.grd
index 71c071a..b52f9c9 100644
--- a/ios/chrome/app/resources/ios_resources.grd
+++ b/ios/chrome/app/resources/ios_resources.grd
@@ -14,6 +14,9 @@
<structure name="IDR_IOS_INCOGNITO_TAB_HTML" file="ntp/incognito_tab.html" flattenhtml="true" type="chrome_html" />
</structures>
<includes>
+ <include name="IDR_IOS_HISTORY_HTML" file="history/history.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" />
+ <include name="IDR_IOS_HISTORY_JS" file="history/history.js" flattenhtml="true" type="BINDATA" />
+ <include name="IDR_IOS_HISTORY_OTHER_DEVICES_JS" file="history/other_devices.js" flattenhtml="true" type="BINDATA" />
<include name="IDR_IOS_OMAHA_HTML" file="omaha/omaha.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" />
<include name="IDR_IOS_OMAHA_JS" file="omaha/omaha.js" type="BINDATA" />
</includes>