Home | History | Annotate | Download | only in history
      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', function(event) {
    190       handleDropDownFocus(event);
    191       // Mousedown handling of cr.ui.MenuButton.handleEvent calls
    192       // preventDefault, which prevents blur of the focused element. We need to
    193       // do blur manually.
    194       document.activeElement.blur();
    195   });
    196   dropDownButton.addEventListener('focus', handleDropDownFocus);
    197   heading.appendChild(dropDownButton);
    198 
    199   var timeSpan = createElementWithClassName('div', 'device-timestamp');
    200   timeSpan.textContent = this.session_.modifiedTime;
    201   heading.appendChild(timeSpan);
    202 
    203   cr.ui.contextMenuHandler.setContextMenu(
    204       heading, DeviceContextMenuController.getInstance().menu);
    205   if (!this.session_.collapsed)
    206     deviceDiv.appendChild(this.createSessionContents_(maxNumTabs));
    207 
    208   return deviceDiv;
    209 };
    210 
    211 /**
    212  * Marks tabs as hidden or not in our session based on the given searchText.
    213  * @param {string} searchText The search text used to filter the content.
    214  */
    215 Device.prototype.setSearchText = function(searchText) {
    216   this.searchText_ = searchText.toLowerCase();
    217   for (var i = 0; i < this.session_.windows.length; i++) {
    218     var win = this.session_.windows[i];
    219     var foundMatch = false;
    220     for (var j = 0; j < win.tabs.length; j++) {
    221       var tab = win.tabs[j];
    222       if (tab.title.toLowerCase().indexOf(this.searchText_) != -1) {
    223         foundMatch = true;
    224         tab.hidden = false;
    225       } else {
    226         tab.hidden = true;
    227       }
    228     }
    229     win.hidden = !foundMatch;
    230   }
    231 };
    232 
    233 // Device, Private ------------------------------------------------------------
    234 
    235 /**
    236  * Create the DOM tree representing the tabs and windows of this device.
    237  * @param {int} maxNumTabs The maximum number of tabs to display.
    238  * @return {Element} A single div containing the list of tabs & windows.
    239  * @private
    240  */
    241 Device.prototype.createSessionContents_ = function(maxNumTabs) {
    242   var contents = createElementWithClassName('div', 'device-contents');
    243 
    244   var sessionTag = this.session_.tag;
    245   var numTabsShown = 0;
    246   var numTabsHidden = 0;
    247   for (var i = 0; i < this.session_.windows.length; i++) {
    248     var win = this.session_.windows[i];
    249     if (win.hidden)
    250       continue;
    251 
    252     // Show a separator between multiple windows in the same session.
    253     if (i > 0 && numTabsShown < maxNumTabs)
    254       contents.appendChild(document.createElement('hr'));
    255 
    256     for (var j = 0; j < win.tabs.length; j++) {
    257       var tab = win.tabs[j];
    258       if (tab.hidden)
    259         continue;
    260 
    261       if (numTabsShown < maxNumTabs) {
    262         numTabsShown++;
    263         var a = createElementWithClassName('a', 'device-tab-entry');
    264         a.href = tab.url;
    265         a.style.backgroundImage = getFaviconImageSet(tab.url);
    266         this.addHighlightedText_(a, tab.title);
    267         // Add a tooltip, since it might be ellipsized. The ones that are not
    268         // necessary will be removed once added to the document, so we can
    269         // compute sizes.
    270         a.title = tab.title;
    271 
    272         // We need to use this to not lose the ids as we go through other loop
    273         // turns.
    274         function makeClickHandler(sessionTag, windowId, tabId) {
    275           return function(e) {
    276             recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED);
    277             chrome.send('openForeignSession', [sessionTag, windowId, tabId,
    278                 e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]);
    279             e.preventDefault();
    280           };
    281         };
    282         a.addEventListener('click', makeClickHandler(sessionTag,
    283                                                      String(win.sessionId),
    284                                                      String(tab.sessionId)));
    285         contents.appendChild(a);
    286       } else {
    287         numTabsHidden++;
    288       }
    289     }
    290   }
    291 
    292   if (numTabsHidden > 0) {
    293     var moreLinkButton = createElementWithClassName('button',
    294         'device-show-more-tabs link-button');
    295     moreLinkButton.addEventListener('click', this.view_.increaseRowHeight.bind(
    296         this.view_, this.row_, numTabsHidden));
    297     var xMore = loadTimeData.getString('xMore');
    298     moreLinkButton.appendChild(
    299         document.createTextNode(xMore.replace('$1', numTabsHidden)));
    300     contents.appendChild(moreLinkButton);
    301   }
    302 
    303   return contents;
    304 };
    305 
    306 /**
    307  * Add child text nodes to a node such that occurrences of this.searchText_ are
    308  * highlighted.
    309  * @param {Node} node The node under which new text nodes will be made as
    310  *     children.
    311  * @param {string} content Text to be added beneath |node| as one or more
    312  *     text nodes.
    313  * @private
    314  */
    315 Device.prototype.addHighlightedText_ = function(node, content) {
    316   var endOfPreviousMatch = 0;
    317   if (this.searchText_) {
    318     var lowerContent = content.toLowerCase();
    319     var searchTextLenght = this.searchText_.length;
    320     var newMatch = lowerContent.indexOf(this.searchText_, 0);
    321     while (newMatch != -1) {
    322       if (newMatch > endOfPreviousMatch) {
    323         node.appendChild(document.createTextNode(
    324             content.slice(endOfPreviousMatch, newMatch)));
    325       }
    326       endOfPreviousMatch = newMatch + searchTextLenght;
    327       // Mark the highlighted text in bold.
    328       var b = document.createElement('b');
    329       b.textContent = content.substring(newMatch, endOfPreviousMatch);
    330       node.appendChild(b);
    331       newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch);
    332     }
    333   }
    334   if (endOfPreviousMatch < content.length) {
    335     node.appendChild(document.createTextNode(
    336         content.slice(endOfPreviousMatch)));
    337   }
    338 };
    339 
    340 ///////////////////////////////////////////////////////////////////////////////
    341 // DevicesView:
    342 
    343 /**
    344  * Functions and state for populating the page with HTML.
    345  * @constructor
    346  */
    347 function DevicesView() {
    348   this.devices_ = [];  // List of individual devices.
    349   this.resultDiv_ = $('other-devices');
    350   this.searchText_ = '';
    351   this.rowHeights_ = [NB_ENTRIES_FIRST_ROW_COLUMN];
    352   this.updateSignInState(loadTimeData.getBoolean('isUserSignedIn'));
    353   recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED);
    354 }
    355 
    356 // DevicesView, public: -------------------------------------------------------
    357 
    358 /**
    359  * Updates our sign in state by clearing the view is not signed in or sending
    360  * a request to get the data to display otherwise.
    361  * @param {boolean} signedIn Whether the user is signed in or not.
    362  */
    363 DevicesView.prototype.updateSignInState = function(signedIn) {
    364   if (signedIn)
    365     chrome.send('getForeignSessions');
    366   else
    367     this.clearDOM();
    368 };
    369 
    370 /**
    371  * Resets the view sessions.
    372  * @param {Object} sessionList The sessions to add.
    373  */
    374 DevicesView.prototype.setSessionList = function(sessionList) {
    375   this.devices_ = [];
    376   for (var i = 0; i < sessionList.length; i++)
    377     this.devices_.push(new Device(sessionList[i], this));
    378   this.displayResults_();
    379 };
    380 
    381 
    382 /**
    383  * Sets the current search text.
    384  * @param {string} searchText The text to search.
    385  */
    386 DevicesView.prototype.setSearchText = function(searchText) {
    387   if (this.searchText_ != searchText) {
    388     this.searchText_ = searchText;
    389     for (var i = 0; i < this.devices_.length; i++)
    390       this.devices_[i].setSearchText(searchText);
    391     this.displayResults_();
    392   }
    393 };
    394 
    395 /**
    396  * @return {string} The current search text.
    397  */
    398 DevicesView.prototype.getSearchText = function() {
    399   return this.searchText_;
    400 };
    401 
    402 /**
    403  * Clears the DOM content of the view.
    404  */
    405 DevicesView.prototype.clearDOM = function() {
    406   while (this.resultDiv_.hasChildNodes()) {
    407     this.resultDiv_.removeChild(this.resultDiv_.lastChild);
    408   }
    409 };
    410 
    411 /**
    412  * Increase the height of a row by the given amount.
    413  * @param {int} row The row number.
    414  * @param {int} height The extra height to add to the givent row.
    415  */
    416 DevicesView.prototype.increaseRowHeight = function(row, height) {
    417   for (var i = this.rowHeights_.length; i <= row; i++)
    418     this.rowHeights_.push(NB_ENTRIES_OTHER_ROWS_COLUMN);
    419   this.rowHeights_[row] += height;
    420   this.displayResults_();
    421 };
    422 
    423 // DevicesView, Private -------------------------------------------------------
    424 
    425 /**
    426  * Update the page with results.
    427  * @private
    428  */
    429 DevicesView.prototype.displayResults_ = function() {
    430   this.clearDOM();
    431   var resultsFragment = document.createDocumentFragment();
    432   if (this.devices_.length == 0)
    433     return;
    434 
    435   // We'll increase to 0 as we create the first row.
    436   var rowIndex = -1;
    437   // We need to access the last row and device when we get out of the loop.
    438   var currentRowElement;
    439   // This is only set when changing rows, yet used on all device columns.
    440   var maxNumTabs;
    441   for (var i = 0; i < this.devices_.length; i++) {
    442     var device = this.devices_[i];
    443     // Should we start a new row?
    444     if (i % MAX_NUM_COLUMNS == 0) {
    445       if (currentRowElement)
    446         resultsFragment.appendChild(currentRowElement);
    447       currentRowElement = createElementWithClassName('div', 'devices-row');
    448       rowIndex++;
    449       if (rowIndex < this.rowHeights_.length)
    450         maxNumTabs = this.rowHeights_[rowIndex];
    451       else
    452         maxNumTabs = 0;
    453     }
    454 
    455     currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex));
    456   }
    457   if (currentRowElement)
    458     resultsFragment.appendChild(currentRowElement);
    459 
    460   this.resultDiv_.appendChild(resultsFragment);
    461   // Remove the tootltip on all lines that don't need it. It's easier to
    462   // remove them here, after adding them all above, since we have the data
    463   // handy above, but we don't have the width yet. Whereas here, we have the
    464   // width, and the nodeValue could contain sub nodes for highlighting, which
    465   // makes it harder to extract the text data here.
    466   tabs = document.getElementsByClassName('device-tab-entry');
    467   for (var i = 0; i < tabs.length; i++) {
    468     if (tabs[i].scrollWidth <= tabs[i].clientWidth)
    469       tabs[i].title = '';
    470   }
    471 
    472   this.resultDiv_.appendChild(
    473       createElementWithClassName('div', 'other-devices-bottom'));
    474 };
    475 
    476 /**
    477  * Sets the menu model data. An empty list means that either there are no
    478  * foreign sessions, or tab sync is disabled for this profile.
    479  * |isTabSyncEnabled| makes it possible to distinguish between the cases.
    480  *
    481  * @param {Array} sessionList Array of objects describing the sessions
    482  *     from other devices.
    483  * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile?
    484  */
    485 function setForeignSessions(sessionList, isTabSyncEnabled) {
    486   // The other devices is shown iff tab sync is enabled.
    487   if (isTabSyncEnabled)
    488     devicesView.setSessionList(sessionList);
    489   else
    490     devicesView.clearDOM();
    491 }
    492 
    493 /**
    494  * Called when this element is initialized, and from the new tab page when
    495  * the user's signed in state changes,
    496  * @param {string} header The first line of text (unused here).
    497  * @param {string} subHeader The second line of text (unused here).
    498  * @param {string} iconURL The url for the login status icon. If this is null
    499  then the login status icon is hidden (unused here).
    500  * @param {boolean} isUserSignedIn Is the user currently signed in?
    501  */
    502 function updateLogin(header, subHeader, iconURL, isUserSignedIn) {
    503   if (devicesView)
    504     devicesView.updateSignInState(isUserSignedIn);
    505 }
    506 
    507 ///////////////////////////////////////////////////////////////////////////////
    508 // Document Functions:
    509 /**
    510  * Window onload handler, sets up the other devices view.
    511  */
    512 function load() {
    513   if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled'))
    514     return;
    515 
    516   // We must use this namespace to reuse the handler code for foreign session
    517   // and login.
    518   cr.define('ntp', function() {
    519     return {
    520       setForeignSessions: setForeignSessions,
    521       updateLogin: updateLogin
    522     };
    523   });
    524 
    525   devicesView = new DevicesView();
    526 
    527   // Create the context menu that appears when the user right clicks
    528   // on a device name or hit click on the button besides the device name
    529   document.body.appendChild(DeviceContextMenuController.getInstance().menu);
    530 
    531   var doSearch = function(e) {
    532     devicesView.setSearchText($('search-field').value);
    533   };
    534   $('search-field').addEventListener('search', doSearch);
    535   $('search-button').addEventListener('click', doSearch);
    536 }
    537 
    538 // Add handlers to HTML elements.
    539 document.addEventListener('DOMContentLoaded', load);
    540