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', 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