1 // Copyright (c) 2013 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 /** 6 * @fileoverview The section of the history page that shows tabs from sessions 7 on other devices. 8 */ 9 10 /////////////////////////////////////////////////////////////////////////////// 11 // Globals: 12 /** @const */ var MAX_NUM_COLUMNS = 3; 13 /** @const */ var NB_ENTRIES_FIRST_ROW_COLUMN = 6; 14 /** @const */ var NB_ENTRIES_OTHER_ROWS_COLUMN = 0; 15 16 // Histogram buckets for UMA tracking of menu usage. 17 // Using the same values as the Other Devices button in the NTP. 18 /** @const */ var HISTOGRAM_EVENT = { 19 INITIALIZED: 0, 20 SHOW_MENU: 1, 21 LINK_CLICKED: 2, 22 LINK_RIGHT_CLICKED: 3, 23 SESSION_NAME_RIGHT_CLICKED: 4, 24 SHOW_SESSION_MENU: 5, 25 COLLAPSE_SESSION: 6, 26 EXPAND_SESSION: 7, 27 OPEN_ALL: 8, 28 LIMIT: 9 // Should always be the last one. 29 }; 30 31 /** 32 * Record an event in the UMA histogram. 33 * @param {number} eventId The id of the event to be recorded. 34 * @private 35 */ 36 function recordUmaEvent_(eventId) { 37 chrome.send('metricsHandler:recordInHistogram', 38 ['HistoryPage.OtherDevicesMenu', eventId, HISTOGRAM_EVENT.LIMIT]); 39 } 40 41 /////////////////////////////////////////////////////////////////////////////// 42 // DeviceContextMenuController: 43 44 /** 45 * Controller for the context menu for device names in the list of sessions. 46 * This class is designed to be used as a singleton. Also copied from existing 47 * other devices button in NTP. 48 * TODO(mad): Should we extract/reuse/share with ntp4/other_sessions.js? 49 * 50 * @constructor 51 */ 52 function DeviceContextMenuController() { 53 this.__proto__ = DeviceContextMenuController.prototype; 54 this.initialize(); 55 } 56 cr.addSingletonGetter(DeviceContextMenuController); 57 58 // DeviceContextMenuController, Public: --------------------------------------- 59 60 /** 61 * Initialize the context menu for device names in the list of sessions. 62 */ 63 DeviceContextMenuController.prototype.initialize = function() { 64 var menu = new cr.ui.Menu; 65 cr.ui.decorate(menu, cr.ui.Menu); 66 this.menu = menu; 67 this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText'); 68 this.collapseItem_.addEventListener('activate', 69 this.onCollapseOrExpand_.bind(this)); 70 this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText'); 71 this.expandItem_.addEventListener('activate', 72 this.onCollapseOrExpand_.bind(this)); 73 this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText'); 74 this.openAllItem_.addEventListener('activate', 75 this.onOpenAll_.bind(this)); 76 }; 77 78 /** 79 * Set the session data for the session the context menu was invoked on. 80 * This should never be called when the menu is visible. 81 * @param {Object} session The model object for the session. 82 */ 83 DeviceContextMenuController.prototype.setSession = function(session) { 84 this.session_ = session; 85 this.updateMenuItems_(); 86 }; 87 88 // DeviceContextMenuController, Private: -------------------------------------- 89 90 /** 91 * Appends a menu item to |this.menu|. 92 * @param {string} textId The ID for the localized string that acts as 93 * the item's label. 94 * @return {Element} The button used for a given menu option. 95 * @private 96 */ 97 DeviceContextMenuController.prototype.appendMenuItem_ = function(textId) { 98 var button = document.createElement('button'); 99 this.menu.appendChild(button); 100 cr.ui.decorate(button, cr.ui.MenuItem); 101 button.textContent = loadTimeData.getString(textId); 102 return button; 103 }; 104 105 /** 106 * Handler for the 'Collapse' and 'Expand' menu items. 107 * @param {Event} e The activation event. 108 * @private 109 */ 110 DeviceContextMenuController.prototype.onCollapseOrExpand_ = function(e) { 111 this.session_.collapsed = !this.session_.collapsed; 112 this.updateMenuItems_(); 113 chrome.send('setForeignSessionCollapsed', 114 [this.session_.tag, this.session_.collapsed]); 115 chrome.send('getForeignSessions'); // Refresh the list. 116 117 var eventId = this.session_.collapsed ? 118 HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION; 119 recordUmaEvent_(eventId); 120 }; 121 122 /** 123 * Handler for the 'Open all' menu item. 124 * @param {Event} e The activation event. 125 * @private 126 */ 127 DeviceContextMenuController.prototype.onOpenAll_ = function(e) { 128 chrome.send('openForeignSession', [this.session_.tag]); 129 recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL); 130 }; 131 132 /** 133 * Set the visibility of the Expand/Collapse menu items based on the state 134 * of the session that this menu is currently associated with. 135 * @private 136 */ 137 DeviceContextMenuController.prototype.updateMenuItems_ = function() { 138 this.collapseItem_.hidden = this.session_.collapsed; 139 this.expandItem_.hidden = !this.session_.collapsed; 140 }; 141 142 143 /////////////////////////////////////////////////////////////////////////////// 144 // Device: 145 146 /** 147 * Class to hold all the information about a device entry and generate a DOM 148 * node for it. 149 * @param {Object} session An object containing the device's session data. 150 * @param {DevicesView} view The view object this entry belongs to. 151 * @constructor 152 */ 153 function Device(session, view) { 154 this.view_ = view; 155 this.session_ = session; 156 this.searchText_ = view.getSearchText(); 157 } 158 159 // Device, Public: ------------------------------------------------------------ 160 161 /** 162 * Get the DOM node to display this device. 163 * @param {int} maxNumTabs The maximum number of tabs to display. 164 * @param {int} row The row in which this device is displayed. 165 * @return {Object} A DOM node to draw the device. 166 */ 167 Device.prototype.getDOMNode = function(maxNumTabs, row) { 168 var deviceDiv = createElementWithClassName('div', 'device'); 169 this.row_ = row; 170 if (!this.session_) 171 return deviceDiv; 172 173 // Name heading 174 var heading = document.createElement('h3'); 175 heading.textContent = this.session_.name; 176 heading.sessionData_ = this.session_; 177 deviceDiv.appendChild(heading); 178 179 // Keep track of the drop down that triggered the menu, so we know 180 // which element to apply the command to. 181 var session = this.session_; 182 function handleDropDownFocus(e) { 183 DeviceContextMenuController.getInstance().setSession(session); 184 } 185 heading.addEventListener('contextmenu', handleDropDownFocus); 186 187 var dropDownButton = new cr.ui.ContextMenuButton; 188 dropDownButton.classList.add('drop-down'); 189 dropDownButton.addEventListener('mousedown', handleDropDownFocus); 190 dropDownButton.addEventListener('focus', handleDropDownFocus); 191 heading.appendChild(dropDownButton); 192 193 var timeSpan = createElementWithClassName('div', 'device-timestamp'); 194 timeSpan.textContent = this.session_.modifiedTime; 195 heading.appendChild(timeSpan); 196 197 cr.ui.contextMenuHandler.setContextMenu( 198 heading, DeviceContextMenuController.getInstance().menu); 199 if (!this.session_.collapsed) 200 deviceDiv.appendChild(this.createSessionContents_(maxNumTabs)); 201 202 return deviceDiv; 203 }; 204 205 /** 206 * Marks tabs as hidden or not in our session based on the given searchText. 207 * @param {string} searchText The search text used to filter the content. 208 */ 209 Device.prototype.setSearchText = function(searchText) { 210 this.searchText_ = searchText.toLowerCase(); 211 for (var i = 0; i < this.session_.windows.length; i++) { 212 var win = this.session_.windows[i]; 213 var foundMatch = false; 214 for (var j = 0; j < win.tabs.length; j++) { 215 var tab = win.tabs[j]; 216 if (tab.title.toLowerCase().indexOf(this.searchText_) != -1) { 217 foundMatch = true; 218 tab.hidden = false; 219 } else { 220 tab.hidden = true; 221 } 222 } 223 win.hidden = !foundMatch; 224 } 225 }; 226 227 // Device, Private ------------------------------------------------------------ 228 229 /** 230 * Create the DOM tree representing the tabs and windows of this device. 231 * @param {int} maxNumTabs The maximum number of tabs to display. 232 * @return {Element} A single div containing the list of tabs & windows. 233 * @private 234 */ 235 Device.prototype.createSessionContents_ = function(maxNumTabs) { 236 var contents = createElementWithClassName('div', 'device-contents'); 237 238 var sessionTag = this.session_.tag; 239 var numTabsShown = 0; 240 var numTabsHidden = 0; 241 for (var i = 0; i < this.session_.windows.length; i++) { 242 var win = this.session_.windows[i]; 243 if (win.hidden) 244 continue; 245 246 // Show a separator between multiple windows in the same session. 247 if (i > 0 && numTabsShown < maxNumTabs) 248 contents.appendChild(document.createElement('hr')); 249 250 for (var j = 0; j < win.tabs.length; j++) { 251 var tab = win.tabs[j]; 252 if (tab.hidden) 253 continue; 254 255 if (numTabsShown < maxNumTabs) { 256 numTabsShown++; 257 var a = createElementWithClassName('a', 'device-tab-entry'); 258 a.href = tab.url; 259 a.style.backgroundImage = getFaviconImageSet(tab.url); 260 this.addHighlightedText_(a, tab.title); 261 // Add a tooltip, since it might be ellipsized. The ones that are not 262 // necessary will be removed once added to the document, so we can 263 // compute sizes. 264 a.title = tab.title; 265 266 // We need to use this to not lose the ids as we go through other loop 267 // turns. 268 function makeClickHandler(sessionTag, windowId, tabId) { 269 return function(e) { 270 recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED); 271 chrome.send('openForeignSession', [sessionTag, windowId, tabId, 272 e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); 273 e.preventDefault(); 274 }; 275 }; 276 a.addEventListener('click', makeClickHandler(sessionTag, 277 String(win.sessionId), 278 String(tab.sessionId))); 279 contents.appendChild(a); 280 } else { 281 numTabsHidden++; 282 } 283 } 284 } 285 286 if (numTabsHidden > 0) { 287 var moreLinkButton = createElementWithClassName('button', 288 'device-show-more-tabs link-button'); 289 moreLinkButton.addEventListener('click', this.view_.increaseRowHeight.bind( 290 this.view_, this.row_, numTabsHidden)); 291 var xMore = loadTimeData.getString('xMore'); 292 moreLinkButton.appendChild( 293 document.createTextNode(xMore.replace('$1', numTabsHidden))); 294 contents.appendChild(moreLinkButton); 295 } 296 297 return contents; 298 }; 299 300 /** 301 * Add child text nodes to a node such that occurrences of this.searchText_ are 302 * highlighted. 303 * @param {Node} node The node under which new text nodes will be made as 304 * children. 305 * @param {string} content Text to be added beneath |node| as one or more 306 * text nodes. 307 * @private 308 */ 309 Device.prototype.addHighlightedText_ = function(node, content) { 310 var endOfPreviousMatch = 0; 311 if (this.searchText_) { 312 var lowerContent = content.toLowerCase(); 313 var searchTextLenght = this.searchText_.length; 314 var newMatch = lowerContent.indexOf(this.searchText_, 0); 315 while (newMatch != -1) { 316 if (newMatch > endOfPreviousMatch) { 317 node.appendChild(document.createTextNode( 318 content.slice(endOfPreviousMatch, newMatch))); 319 } 320 endOfPreviousMatch = newMatch + searchTextLenght; 321 // Mark the highlighted text in bold. 322 var b = document.createElement('b'); 323 b.textContent = content.substring(newMatch, endOfPreviousMatch); 324 node.appendChild(b); 325 newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch); 326 } 327 } 328 if (endOfPreviousMatch < content.length) { 329 node.appendChild(document.createTextNode( 330 content.slice(endOfPreviousMatch))); 331 } 332 }; 333 334 /////////////////////////////////////////////////////////////////////////////// 335 // DevicesView: 336 337 /** 338 * Functions and state for populating the page with HTML. 339 * @constructor 340 */ 341 function DevicesView() { 342 this.devices_ = []; // List of individual devices. 343 this.resultDiv_ = $('other-devices'); 344 this.searchText_ = ''; 345 this.rowHeights_ = [NB_ENTRIES_FIRST_ROW_COLUMN]; 346 this.updateSignInState(loadTimeData.getBoolean('isUserSignedIn')); 347 recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED); 348 } 349 350 // DevicesView, public: ------------------------------------------------------- 351 352 /** 353 * Updates our sign in state by clearing the view is not signed in or sending 354 * a request to get the data to display otherwise. 355 * @param {boolean} signedIn Whether the user is signed in or not. 356 */ 357 DevicesView.prototype.updateSignInState = function(signedIn) { 358 if (signedIn) 359 chrome.send('getForeignSessions'); 360 else 361 this.clearDOM(); 362 }; 363 364 /** 365 * Resets the view sessions. 366 * @param {Object} sessionList The sessions to add. 367 */ 368 DevicesView.prototype.setSessionList = function(sessionList) { 369 this.devices_ = []; 370 for (var i = 0; i < sessionList.length; i++) 371 this.devices_.push(new Device(sessionList[i], this)); 372 this.displayResults_(); 373 }; 374 375 376 /** 377 * Sets the current search text. 378 * @param {string} searchText The text to search. 379 */ 380 DevicesView.prototype.setSearchText = function(searchText) { 381 if (this.searchText_ != searchText) { 382 this.searchText_ = searchText; 383 for (var i = 0; i < this.devices_.length; i++) 384 this.devices_[i].setSearchText(searchText); 385 this.displayResults_(); 386 } 387 }; 388 389 /** 390 * @return {string} The current search text. 391 */ 392 DevicesView.prototype.getSearchText = function() { 393 return this.searchText_; 394 }; 395 396 /** 397 * Clears the DOM content of the view. 398 */ 399 DevicesView.prototype.clearDOM = function() { 400 while (this.resultDiv_.hasChildNodes()) { 401 this.resultDiv_.removeChild(this.resultDiv_.lastChild); 402 } 403 }; 404 405 /** 406 * Increase the height of a row by the given amount. 407 * @param {int} row The row number. 408 * @param {int} height The extra height to add to the givent row. 409 */ 410 DevicesView.prototype.increaseRowHeight = function(row, height) { 411 for (var i = this.rowHeights_.length; i <= row; i++) 412 this.rowHeights_.push(NB_ENTRIES_OTHER_ROWS_COLUMN); 413 this.rowHeights_[row] += height; 414 this.displayResults_(); 415 }; 416 417 // DevicesView, Private ------------------------------------------------------- 418 419 /** 420 * Update the page with results. 421 * @private 422 */ 423 DevicesView.prototype.displayResults_ = function() { 424 this.clearDOM(); 425 var resultsFragment = document.createDocumentFragment(); 426 if (this.devices_.length == 0) 427 return; 428 429 // We'll increase to 0 as we create the first row. 430 var rowIndex = -1; 431 // We need to access the last row and device when we get out of the loop. 432 var currentRowElement; 433 // This is only set when changing rows, yet used on all device columns. 434 var maxNumTabs; 435 for (var i = 0; i < this.devices_.length; i++) { 436 var device = this.devices_[i]; 437 // Should we start a new row? 438 if (i % MAX_NUM_COLUMNS == 0) { 439 if (currentRowElement) 440 resultsFragment.appendChild(currentRowElement); 441 currentRowElement = createElementWithClassName('div', 'devices-row'); 442 rowIndex++; 443 if (rowIndex < this.rowHeights_.length) 444 maxNumTabs = this.rowHeights_[rowIndex]; 445 else 446 maxNumTabs = 0; 447 } 448 449 currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex)); 450 } 451 if (currentRowElement) 452 resultsFragment.appendChild(currentRowElement); 453 454 this.resultDiv_.appendChild(resultsFragment); 455 // Remove the tootltip on all lines that don't need it. It's easier to 456 // remove them here, after adding them all above, since we have the data 457 // handy above, but we don't have the width yet. Whereas here, we have the 458 // width, and the nodeValue could contain sub nodes for highlighting, which 459 // makes it harder to extract the text data here. 460 tabs = document.getElementsByClassName('device-tab-entry'); 461 for (var i = 0; i < tabs.length; i++) { 462 if (tabs[i].scrollWidth <= tabs[i].clientWidth) 463 tabs[i].title = ''; 464 } 465 466 this.resultDiv_.appendChild( 467 createElementWithClassName('div', 'other-devices-bottom')); 468 }; 469 470 /** 471 * Sets the menu model data. An empty list means that either there are no 472 * foreign sessions, or tab sync is disabled for this profile. 473 * |isTabSyncEnabled| makes it possible to distinguish between the cases. 474 * 475 * @param {Array} sessionList Array of objects describing the sessions 476 * from other devices. 477 * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? 478 */ 479 function setForeignSessions(sessionList, isTabSyncEnabled) { 480 // The other devices is shown iff tab sync is enabled. 481 if (isTabSyncEnabled) 482 devicesView.setSessionList(sessionList); 483 else 484 devicesView.clearDOM(); 485 } 486 487 /** 488 * Called when this element is initialized, and from the new tab page when 489 * the user's signed in state changes, 490 * @param {string} header The first line of text (unused here). 491 * @param {string} subHeader The second line of text (unused here). 492 * @param {string} iconURL The url for the login status icon. If this is null 493 then the login status icon is hidden (unused here). 494 * @param {boolean} isUserSignedIn Is the user currently signed in? 495 */ 496 function updateLogin(header, subHeader, iconURL, isUserSignedIn) { 497 if (devicesView) 498 devicesView.updateSignInState(isUserSignedIn); 499 } 500 501 /////////////////////////////////////////////////////////////////////////////// 502 // Document Functions: 503 /** 504 * Window onload handler, sets up the other devices view. 505 */ 506 function load() { 507 if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled')) 508 return; 509 510 // We must use this namespace to reuse the handler code for foreign session 511 // and login. 512 cr.define('ntp', function() { 513 return { 514 setForeignSessions: setForeignSessions, 515 updateLogin: updateLogin 516 }; 517 }); 518 519 devicesView = new DevicesView(); 520 521 // Create the context menu that appears when the user right clicks 522 // on a device name or hit click on the button besides the device name 523 document.body.appendChild(DeviceContextMenuController.getInstance().menu); 524 525 var doSearch = function(e) { 526 devicesView.setSearchText($('search-field').value); 527 }; 528 $('search-field').addEventListener('search', doSearch); 529 $('search-button').addEventListener('click', doSearch); 530 } 531 532 // Add handlers to HTML elements. 533 document.addEventListener('DOMContentLoaded', load); 534