• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 */
36function 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 */
52function DeviceContextMenuController() {
53  this.__proto__ = DeviceContextMenuController.prototype;
54  this.initialize();
55}
56cr.addSingletonGetter(DeviceContextMenuController);
57
58// DeviceContextMenuController, Public: ---------------------------------------
59
60/**
61 * Initialize the context menu for device names in the list of sessions.
62 */
63DeviceContextMenuController.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 */
83DeviceContextMenuController.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 */
97DeviceContextMenuController.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 */
110DeviceContextMenuController.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 */
127DeviceContextMenuController.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 */
137DeviceContextMenuController.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 */
153function 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 */
167Device.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 */
215Device.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 */
241Device.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 */
315Device.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 */
347function 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 */
363DevicesView.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 */
374DevicesView.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 */
386DevicesView.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 */
398DevicesView.prototype.getSearchText = function() {
399  return this.searchText_;
400};
401
402/**
403 * Clears the DOM content of the view.
404 */
405DevicesView.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 */
416DevicesView.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 */
429DevicesView.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 */
485function 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 */
502function 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 */
512function 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.
539document.addEventListener('DOMContentLoaded', load);
540