1// Copyright (c) 2012 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
5cr.define('options.contentSettings', function() {
6  /** @const */ var ControlledSettingIndicator =
7                    options.ControlledSettingIndicator;
8  /** @const */ var InlineEditableItemList = options.InlineEditableItemList;
9  /** @const */ var InlineEditableItem = options.InlineEditableItem;
10  /** @const */ var ArrayDataModel = cr.ui.ArrayDataModel;
11
12  /**
13   * Creates a new exceptions list item.
14   *
15   * @param {string} contentType The type of the list.
16   * @param {string} mode The browser mode, 'otr' or 'normal'.
17   * @param {boolean} enableAskOption Whether to show an 'ask every time'
18   *     option in the select.
19   * @param {Object} exception A dictionary that contains the data of the
20   *     exception.
21   * @constructor
22   * @extends {options.InlineEditableItem}
23   */
24  function ExceptionsListItem(contentType, mode, enableAskOption, exception) {
25    var el = cr.doc.createElement('div');
26    el.mode = mode;
27    el.contentType = contentType;
28    el.enableAskOption = enableAskOption;
29    el.dataItem = exception;
30    el.__proto__ = ExceptionsListItem.prototype;
31    el.decorate();
32
33    return el;
34  }
35
36  ExceptionsListItem.prototype = {
37    __proto__: InlineEditableItem.prototype,
38
39    /**
40     * Called when an element is decorated as a list item.
41     */
42    decorate: function() {
43      InlineEditableItem.prototype.decorate.call(this);
44
45      this.isPlaceholder = !this.pattern;
46      var patternCell = this.createEditableTextCell(this.pattern);
47      patternCell.className = 'exception-pattern';
48      patternCell.classList.add('weakrtl');
49      this.contentElement.appendChild(patternCell);
50      if (this.pattern)
51        this.patternLabel = patternCell.querySelector('.static-text');
52      var input = patternCell.querySelector('input');
53
54      // TODO(stuartmorgan): Create an createEditableSelectCell abstracting
55      // this code.
56      // Setting label for display mode. |pattern| will be null for the 'add new
57      // exception' row.
58      if (this.pattern) {
59        var settingLabel = cr.doc.createElement('span');
60        settingLabel.textContent = this.settingForDisplay();
61        settingLabel.className = 'exception-setting';
62        settingLabel.setAttribute('displaymode', 'static');
63        this.contentElement.appendChild(settingLabel);
64        this.settingLabel = settingLabel;
65      }
66
67      // Setting select element for edit mode.
68      var select = cr.doc.createElement('select');
69      var optionAllow = cr.doc.createElement('option');
70      optionAllow.textContent = loadTimeData.getString('allowException');
71      optionAllow.value = 'allow';
72      select.appendChild(optionAllow);
73
74      if (this.enableAskOption) {
75        var optionAsk = cr.doc.createElement('option');
76        optionAsk.textContent = loadTimeData.getString('askException');
77        optionAsk.value = 'ask';
78        select.appendChild(optionAsk);
79      }
80
81      if (this.contentType == 'cookies') {
82        var optionSession = cr.doc.createElement('option');
83        optionSession.textContent = loadTimeData.getString('sessionException');
84        optionSession.value = 'session';
85        select.appendChild(optionSession);
86      }
87
88      if (this.contentType != 'fullscreen') {
89        var optionBlock = cr.doc.createElement('option');
90        optionBlock.textContent = loadTimeData.getString('blockException');
91        optionBlock.value = 'block';
92        select.appendChild(optionBlock);
93      }
94
95      if (this.isEmbeddingRule()) {
96        this.patternLabel.classList.add('sublabel');
97        this.editable = false;
98      }
99
100      if (this.setting == 'default') {
101        // Items that don't have their own settings (parents of 'embedded on'
102        // items) aren't deletable.
103        this.deletable = false;
104        this.editable = false;
105      }
106
107      if (this.contentType != 'zoomlevels') {
108        this.addEditField(select, this.settingLabel);
109        this.contentElement.appendChild(select);
110      }
111      select.className = 'exception-setting';
112      select.setAttribute('aria-labelledby', 'exception-behavior-column');
113
114      if (this.pattern)
115        select.setAttribute('displaymode', 'edit');
116
117      if (this.contentType == 'media-stream') {
118        this.settingLabel.classList.add('media-audio-setting');
119
120        var videoSettingLabel = cr.doc.createElement('span');
121        videoSettingLabel.textContent = this.videoSettingForDisplay();
122        videoSettingLabel.className = 'exception-setting';
123        videoSettingLabel.classList.add('media-video-setting');
124        videoSettingLabel.setAttribute('displaymode', 'static');
125        this.contentElement.appendChild(videoSettingLabel);
126      }
127
128      if (this.contentType == 'zoomlevels') {
129        this.deletable = true;
130        this.editable = false;
131
132        var zoomLabel = cr.doc.createElement('span');
133        zoomLabel.textContent = this.dataItem.zoom;
134        zoomLabel.className = 'exception-setting';
135        zoomLabel.setAttribute('displaymode', 'static');
136        zoomLabel.setAttribute('aria-labelledby', 'exception-zoom-column');
137        this.contentElement.appendChild(zoomLabel);
138        this.zoomLabel = zoomLabel;
139      }
140
141      // Used to track whether the URL pattern in the input is valid.
142      // This will be true if the browser process has informed us that the
143      // current text in the input is valid. Changing the text resets this to
144      // false, and getting a response from the browser sets it back to true.
145      // It starts off as false for empty string (new exceptions) or true for
146      // already-existing exceptions (which we assume are valid).
147      this.inputValidityKnown = this.pattern;
148      // This one tracks the actual validity of the pattern in the input. This
149      // starts off as true so as not to annoy the user when he adds a new and
150      // empty input.
151      this.inputIsValid = true;
152
153      this.input = input;
154      this.select = select;
155
156      this.updateEditables();
157
158      // Editing notifications, geolocation and media-stream is disabled for
159      // now.
160      if (this.contentType == 'notifications' ||
161          this.contentType == 'location' ||
162          this.contentType == 'media-stream') {
163        this.editable = false;
164      }
165
166      // If the source of the content setting exception is not a user
167      // preference, that source controls the exception and the user cannot edit
168      // or delete it.
169      var controlledBy =
170          this.dataItem.source && this.dataItem.source != 'preference' ?
171              this.dataItem.source : null;
172
173      if (controlledBy) {
174        this.setAttribute('controlled-by', controlledBy);
175        this.deletable = false;
176        this.editable = false;
177      }
178
179      if (controlledBy == 'policy' || controlledBy == 'extension') {
180        this.querySelector('.row-delete-button').hidden = true;
181        var indicator = new ControlledSettingIndicator();
182        indicator.setAttribute('content-exception', this.contentType);
183        // Create a synthetic pref change event decorated as
184        // CoreOptionsHandler::CreateValueForPref() does.
185        var event = new Event(this.contentType);
186        event.value = { controlledBy: controlledBy };
187        indicator.handlePrefChange(event);
188        this.appendChild(indicator);
189      }
190
191      // If the exception comes from a hosted app, display the name and the
192      // icon of the app.
193      if (controlledBy == 'HostedApp') {
194        this.title =
195            loadTimeData.getString('set_by') + ' ' + this.dataItem.appName;
196        var button = this.querySelector('.row-delete-button');
197        // Use the host app's favicon (16px, match bigger size).
198        // See c/b/ui/webui/extensions/extension_icon_source.h
199        // for a description of the chrome://extension-icon URL.
200        button.style.backgroundImage =
201            'url(\'chrome://extension-icon/' + this.dataItem.appId + '/16/1\')';
202      }
203
204      var listItem = this;
205      // Handle events on the editable nodes.
206      input.oninput = function(event) {
207        listItem.inputValidityKnown = false;
208        chrome.send('checkExceptionPatternValidity',
209                    [listItem.contentType, listItem.mode, input.value]);
210      };
211
212      // Listen for edit events.
213      this.addEventListener('canceledit', this.onEditCancelled_);
214      this.addEventListener('commitedit', this.onEditCommitted_);
215    },
216
217    isEmbeddingRule: function() {
218      return this.dataItem.embeddingOrigin &&
219          this.dataItem.embeddingOrigin !== this.dataItem.origin;
220    },
221
222    /**
223     * The pattern (e.g., a URL) for the exception.
224     *
225     * @type {string}
226     */
227    get pattern() {
228      if (!this.isEmbeddingRule())
229        return this.dataItem.origin;
230
231      return loadTimeData.getStringF('embeddedOnHost',
232                                     this.dataItem.embeddingOrigin);
233    },
234    set pattern(pattern) {
235      if (!this.editable)
236        console.error('Tried to change uneditable pattern');
237
238      this.dataItem.displayPattern = pattern;
239    },
240
241    /**
242     * The setting (allow/block) for the exception.
243     *
244     * @type {string}
245     */
246    get setting() {
247      return this.dataItem.setting;
248    },
249    set setting(setting) {
250      this.dataItem.setting = setting;
251    },
252
253    /**
254     * Gets a human-readable setting string.
255     *
256     * @return {string} The display string.
257     */
258    settingForDisplay: function() {
259      return this.getDisplayStringForSetting(this.setting);
260    },
261
262    /**
263     * media video specific function.
264     * Gets a human-readable video setting string.
265     *
266     * @return {string} The display string.
267     */
268    videoSettingForDisplay: function() {
269      return this.getDisplayStringForSetting(this.dataItem.video);
270    },
271
272    /**
273     * Gets a human-readable display string for setting.
274     *
275     * @param {string} setting The setting to be displayed.
276     * @return {string} The display string.
277     */
278    getDisplayStringForSetting: function(setting) {
279      if (setting == 'allow')
280        return loadTimeData.getString('allowException');
281      else if (setting == 'block')
282        return loadTimeData.getString('blockException');
283      else if (setting == 'ask')
284        return loadTimeData.getString('askException');
285      else if (setting == 'session')
286        return loadTimeData.getString('sessionException');
287      else if (setting == 'default')
288        return '';
289
290      console.error('Unknown setting: [' + setting + ']');
291      return '';
292    },
293
294    /**
295     * Update this list item to reflect whether the input is a valid pattern.
296     *
297     * @param {boolean} valid Whether said pattern is valid in the context of a
298     *     content exception setting.
299     */
300    setPatternValid: function(valid) {
301      if (valid || !this.input.value)
302        this.input.setCustomValidity('');
303      else
304        this.input.setCustomValidity(' ');
305      this.inputIsValid = valid;
306      this.inputValidityKnown = true;
307    },
308
309    /**
310     * Set the <input> to its original contents. Used when the user quits
311     * editing.
312     */
313    resetInput: function() {
314      this.input.value = this.pattern;
315    },
316
317    /**
318     * Copy the data model values to the editable nodes.
319     */
320    updateEditables: function() {
321      this.resetInput();
322
323      var settingOption =
324          this.select.querySelector('[value=\'' + this.setting + '\']');
325      if (settingOption)
326        settingOption.selected = true;
327    },
328
329    /** @override */
330    get currentInputIsValid() {
331      return this.inputValidityKnown && this.inputIsValid;
332    },
333
334    /** @override */
335    get hasBeenEdited() {
336      var livePattern = this.input.value;
337      var liveSetting = this.select.value;
338      return livePattern != this.pattern || liveSetting != this.setting;
339    },
340
341    /**
342     * Called when committing an edit.
343     *
344     * @param {Event} e The end event.
345     * @private
346     */
347    onEditCommitted_: function(e) {
348      var newPattern = this.input.value;
349      var newSetting = this.select.value;
350
351      this.finishEdit(newPattern, newSetting);
352    },
353
354    /**
355     * Called when cancelling an edit; resets the control states.
356     *
357     * @param {Event} e The cancel event.
358     * @private
359     */
360    onEditCancelled_: function(e) {
361      this.updateEditables();
362      this.setPatternValid(true);
363    },
364
365    /**
366     * Editing is complete; update the model.
367     *
368     * @param {string} newPattern The pattern that the user entered.
369     * @param {string} newSetting The setting the user chose.
370     */
371    finishEdit: function(newPattern, newSetting) {
372      this.patternLabel.textContent = newPattern;
373      this.settingLabel.textContent = this.settingForDisplay();
374      var oldPattern = this.pattern;
375      this.pattern = newPattern;
376      this.setting = newSetting;
377
378      // TODO(estade): this will need to be updated if geolocation/notifications
379      // become editable.
380      if (oldPattern != newPattern) {
381        chrome.send('removeException',
382                    [this.contentType, this.mode, oldPattern]);
383      }
384
385      chrome.send('setException',
386                  [this.contentType, this.mode, newPattern, newSetting]);
387    },
388  };
389
390  /**
391   * Creates a new list item for the Add New Item row, which doesn't represent
392   * an actual entry in the exceptions list but allows the user to add new
393   * exceptions.
394   *
395   * @param {string} contentType The type of the list.
396   * @param {string} mode The browser mode, 'otr' or 'normal'.
397   * @param {boolean} enableAskOption Whether to show an 'ask every time' option
398   *     in the select.
399   * @constructor
400   * @extends {options.contentSettings.ExceptionsListItem}
401   */
402  function ExceptionsAddRowListItem(contentType, mode, enableAskOption) {
403    var el = cr.doc.createElement('div');
404    el.mode = mode;
405    el.contentType = contentType;
406    el.enableAskOption = enableAskOption;
407    el.dataItem = [];
408    el.__proto__ = ExceptionsAddRowListItem.prototype;
409    el.decorate();
410
411    return el;
412  }
413
414  ExceptionsAddRowListItem.prototype = {
415    __proto__: ExceptionsListItem.prototype,
416
417    decorate: function() {
418      ExceptionsListItem.prototype.decorate.call(this);
419
420      this.input.placeholder =
421          loadTimeData.getString('addNewExceptionInstructions');
422
423      // Do we always want a default of allow?
424      this.setting = 'allow';
425    },
426
427    /**
428     * Clear the <input> and let the placeholder text show again.
429     */
430    resetInput: function() {
431      this.input.value = '';
432    },
433
434    /** @override */
435    get hasBeenEdited() {
436      return this.input.value != '';
437    },
438
439    /**
440     * Editing is complete; update the model. As long as the pattern isn't
441     * empty, we'll just add it.
442     *
443     * @param {string} newPattern The pattern that the user entered.
444     * @param {string} newSetting The setting the user chose.
445     */
446    finishEdit: function(newPattern, newSetting) {
447      this.resetInput();
448      chrome.send('setException',
449                  [this.contentType, this.mode, newPattern, newSetting]);
450    },
451  };
452
453  /**
454   * Creates a new exceptions list.
455   *
456   * @constructor
457   * @extends {options.InlineEditableItemList}
458   */
459  var ExceptionsList = cr.ui.define('list');
460
461  ExceptionsList.prototype = {
462    __proto__: InlineEditableItemList.prototype,
463
464    /**
465     * Called when an element is decorated as a list.
466     */
467    decorate: function() {
468      InlineEditableItemList.prototype.decorate.call(this);
469
470      this.classList.add('settings-list');
471
472      for (var parentNode = this.parentNode; parentNode;
473           parentNode = parentNode.parentNode) {
474        if (parentNode.hasAttribute('contentType')) {
475          this.contentType = parentNode.getAttribute('contentType');
476          break;
477        }
478      }
479
480      this.mode = this.getAttribute('mode');
481
482      // Whether the exceptions in this list allow an 'Ask every time' option.
483      this.enableAskOption = this.contentType == 'plugins';
484
485      this.autoExpands = true;
486      this.reset();
487    },
488
489    /**
490     * Creates an item to go in the list.
491     *
492     * @param {Object} entry The element from the data model for this row.
493     */
494    createItem: function(entry) {
495      if (entry) {
496        return new ExceptionsListItem(this.contentType,
497                                      this.mode,
498                                      this.enableAskOption,
499                                      entry);
500      } else {
501        var addRowItem = new ExceptionsAddRowListItem(this.contentType,
502                                                      this.mode,
503                                                      this.enableAskOption);
504        addRowItem.deletable = false;
505        return addRowItem;
506      }
507    },
508
509    /**
510     * Sets the exceptions in the js model.
511     *
512     * @param {Object} entries A list of dictionaries of values, each dictionary
513     *     represents an exception.
514     */
515    setExceptions: function(entries) {
516      var deleteCount = this.dataModel.length;
517
518      if (this.isEditable()) {
519        // We don't want to remove the Add New Exception row.
520        deleteCount = deleteCount - 1;
521      }
522
523      var args = [0, deleteCount];
524      args.push.apply(args, entries);
525      this.dataModel.splice.apply(this.dataModel, args);
526    },
527
528    /**
529     * The browser has finished checking a pattern for validity. Update the list
530     * item to reflect this.
531     *
532     * @param {string} pattern The pattern.
533     * @param {boolean} valid Whether said pattern is valid in the context of a
534     *     content exception setting.
535     */
536    patternValidityCheckComplete: function(pattern, valid) {
537      var listItems = this.items;
538      for (var i = 0; i < listItems.length; i++) {
539        var listItem = listItems[i];
540        // Don't do anything for messages for the item if it is not the intended
541        // recipient, or if the response is stale (i.e. the input value has
542        // changed since we sent the request to analyze it).
543        if (pattern == listItem.input.value)
544          listItem.setPatternValid(valid);
545      }
546    },
547
548    /**
549     * Returns whether the rows are editable in this list.
550     */
551    isEditable: function() {
552      // Exceptions of the following lists are not editable for now.
553      return !(this.contentType == 'notifications' ||
554               this.contentType == 'location' ||
555               this.contentType == 'fullscreen' ||
556               this.contentType == 'media-stream' ||
557               this.contentType == 'zoomlevels');
558    },
559
560    /**
561     * Removes all exceptions from the js model.
562     */
563    reset: function() {
564      if (this.isEditable()) {
565        // The null creates the Add New Exception row.
566        this.dataModel = new ArrayDataModel([null]);
567      } else {
568        this.dataModel = new ArrayDataModel([]);
569      }
570    },
571
572    /** @override */
573    deleteItemAtIndex: function(index) {
574      var listItem = this.getListItemByIndex(index);
575      if (!listItem.deletable)
576        return;
577
578      var dataItem = listItem.dataItem;
579      chrome.send('removeException', [listItem.contentType,
580                                      listItem.mode,
581                                      dataItem.origin,
582                                      dataItem.embeddingOrigin]);
583    },
584  };
585
586  var Page = cr.ui.pageManager.Page;
587  var PageManager = cr.ui.pageManager.PageManager;
588
589  /**
590   * Encapsulated handling of content settings list subpage.
591   *
592   * @constructor
593   * @extends {cr.ui.pageManager.Page}
594   */
595  function ContentSettingsExceptionsArea() {
596    Page.call(this, 'contentExceptions',
597              loadTimeData.getString('contentSettingsPageTabTitle'),
598              'content-settings-exceptions-area');
599  }
600
601  cr.addSingletonGetter(ContentSettingsExceptionsArea);
602
603  ContentSettingsExceptionsArea.prototype = {
604    __proto__: Page.prototype,
605
606    /** @override */
607    initializePage: function() {
608      Page.prototype.initializePage.call(this);
609
610      var exceptionsLists = this.pageDiv.querySelectorAll('list');
611      for (var i = 0; i < exceptionsLists.length; i++) {
612        options.contentSettings.ExceptionsList.decorate(exceptionsLists[i]);
613      }
614
615      ContentSettingsExceptionsArea.hideOTRLists(false);
616
617      // If the user types in the URL without a hash, show just cookies.
618      this.showList('cookies');
619
620      $('content-settings-exceptions-overlay-confirm').onclick =
621          PageManager.closeOverlay.bind(PageManager);
622    },
623
624    /**
625     * Shows one list and hides all others.
626     *
627     * @param {string} type The content type.
628     */
629    showList: function(type) {
630      // Update the title for the type that was shown.
631      this.title = loadTimeData.getString(type + 'TabTitle');
632
633      var header = this.pageDiv.querySelector('h1');
634      header.textContent = loadTimeData.getString(type + '_header');
635
636      var divs = this.pageDiv.querySelectorAll('div[contentType]');
637      for (var i = 0; i < divs.length; i++) {
638        if (divs[i].getAttribute('contentType') == type)
639          divs[i].hidden = false;
640        else
641          divs[i].hidden = true;
642      }
643
644      var mediaHeader = this.pageDiv.querySelector('.media-header');
645      mediaHeader.hidden = type != 'media-stream';
646
647      $('exception-behavior-column').hidden = type == 'zoomlevels';
648      $('exception-zoom-column').hidden = type != 'zoomlevels';
649    },
650
651    /**
652     * Called after the page has been shown. Show the content type for the
653     * location's hash.
654     */
655    didShowPage: function() {
656      if (this.hash)
657        this.showList(this.hash.slice(1));
658    },
659  };
660
661  /**
662   * Called when the last incognito window is closed.
663   */
664  ContentSettingsExceptionsArea.OTRProfileDestroyed = function() {
665    this.hideOTRLists(true);
666  };
667
668  /**
669   * Hides the incognito exceptions lists and optionally clears them as well.
670   * @param {boolean} clear Whether to clear the lists.
671   */
672  ContentSettingsExceptionsArea.hideOTRLists = function(clear) {
673    var otrLists = document.querySelectorAll('list[mode=otr]');
674
675    for (var i = 0; i < otrLists.length; i++) {
676      otrLists[i].parentNode.hidden = true;
677      if (clear)
678        otrLists[i].reset();
679    }
680  };
681
682  return {
683    ExceptionsListItem: ExceptionsListItem,
684    ExceptionsAddRowListItem: ExceptionsAddRowListItem,
685    ExceptionsList: ExceptionsList,
686    ContentSettingsExceptionsArea: ContentSettingsExceptionsArea,
687  };
688});
689