tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

setting-control.mjs (13858B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import {
      6  createRef,
      7  html,
      8  ifDefined,
      9  literal,
     10  ref,
     11  staticHtml,
     12  unsafeStatic,
     13 } from "chrome://global/content/vendor/lit.all.mjs";
     14 import {
     15  SettingElement,
     16  spread,
     17 } from "chrome://browser/content/preferences/widgets/setting-element.mjs";
     18 import MozInputFolder from "chrome://global/content/elements/moz-input-folder.mjs";
     19 
     20 /** @import { LitElement, Ref, TemplateResult } from "chrome://global/content/vendor/lit.all.mjs" */
     21 /** @import { SettingElementConfig } from "chrome://browser/content/preferences/widgets/setting-element.mjs" */
     22 /** @import { Setting } from "chrome://global/content/preferences/Setting.mjs" */
     23 
     24 /**
     25 * @typedef {object} SettingNestedConfig
     26 * @property {SettingControlConfig[]} [items] Additional nested SettingControls to render.
     27 * @property {SettingOptionConfig[]} [options]
     28 * Additional nested plain elements to render (may have SettingControls nested within them, though).
     29 */
     30 
     31 /**
     32 * @typedef {object} SettingOptionConfigExtensions
     33 * @property {string} [control]
     34 * The element tag to render, default assumed based on parent control.
     35 * @property {any} [value] A value to set on the option.
     36 * @property {boolean} [disabled] If the option should be disabled.
     37 * @property {boolean} [hidden] If the option should be hidden.
     38 */
     39 
     40 /**
     41 * @typedef {object} SettingControlConfigExtensions
     42 * @property {string} id
     43 * The ID for the Setting, also set in the DOM unless overridden with controlAttrs.id
     44 * @property {string} [control] The element to render, default to "moz-checkbox".
     45 * @property {string} [controllingExtensionInfo]
     46 * ExtensionSettingStore id for checking if a setting is controlled by an extension.
     47 */
     48 
     49 /**
     50 * @typedef {SettingOptionConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingOptionConfig
     51 * @typedef {SettingControlConfigExtensions & SettingElementConfig & SettingNestedConfig} SettingControlConfig
     52 * @typedef {{ control: SettingControl } & HTMLElement} SettingControlChild
     53 */
     54 
     55 /**
     56 * @template T=Event
     57 * @typedef {T & { target: SettingControlChild }} SettingControlEvent
     58 * SettingControlEvent simplifies the types in this file, but causes issues when
     59 * doing more involved work when used in Setting.mjs. When casting the
     60 * `event.target` to a more specific type like MozButton (or even
     61 * HTMLButtonElement) it gets flagged as being too different from SettingControlChild.
     62 */
     63 
     64 /**
     65 * Mapping of parent control tag names to the literal tag name for their
     66 * expected children. eg. "moz-radio-group"->literal`moz-radio`.
     67 */
     68 const KNOWN_OPTIONS = new Map([
     69  ["moz-radio-group", literal`moz-radio`],
     70  ["moz-select", literal`moz-option`],
     71  ["moz-visual-picker", literal`moz-visual-picker-item`],
     72 ]);
     73 
     74 /**
     75 * Mapping of parent control tag names to the expected slot for their children.
     76 * If there's no entry here for a control then it's expected that its children
     77 * should go in the default slot.
     78 *
     79 * @type Map<string, string>
     80 */
     81 const ITEM_SLOT_BY_PARENT = new Map([
     82  ["moz-checkbox", "nested"],
     83  ["moz-input-email", "nested"],
     84  ["moz-input-folder", "nested"],
     85  ["moz-input-number", "nested"],
     86  ["moz-input-password", "nested"],
     87  ["moz-input-search", "nested"],
     88  ["moz-input-tel", "nested"],
     89  ["moz-input-text", "nested"],
     90  ["moz-input-url", "nested"],
     91  ["moz-radio", "nested"],
     92  ["moz-radio-group", "nested"],
     93  // NOTE: moz-select does not support the nested slot.
     94  ["moz-toggle", "nested"],
     95 ]);
     96 
     97 export class SettingNotDefinedError extends Error {
     98  /** @param {string} settingId */
     99  constructor(settingId) {
    100    super(
    101      `No Setting with id "${settingId}". Did you register it with Preferences.addSetting()?`
    102    );
    103    this.name = "SettingNotDefinedError";
    104    this.settingId = settingId;
    105  }
    106 }
    107 
    108 export class SettingControl extends SettingElement {
    109  static SettingNotDefinedError = SettingNotDefinedError;
    110  static properties = {
    111    setting: { type: Object },
    112    config: { type: Object },
    113    value: {},
    114    parentDisabled: { type: Boolean },
    115    tabIndex: { type: Number, reflect: true },
    116    showEnableExtensionMessage: { type: Boolean, state: true },
    117    isDisablingExtension: { type: Boolean, state: true },
    118  };
    119 
    120  /**
    121   * @type {Setting | undefined}
    122   */
    123  #lastSetting;
    124 
    125  constructor() {
    126    super();
    127    /** @type {Ref<LitElement>} */
    128    this.controlRef = createRef();
    129 
    130    /**
    131     * @type {Preferences['getSetting'] | undefined}
    132     */
    133    this.getSetting = undefined;
    134 
    135    /**
    136     * @type {Setting | undefined}
    137     */
    138    this.setting = undefined;
    139 
    140    /**
    141     * @type {SettingControlConfig | undefined}
    142     */
    143    this.config = undefined;
    144 
    145    /**
    146     * @type {boolean | undefined}
    147     */
    148    this.parentDisabled = undefined;
    149 
    150    /**
    151     * @type {boolean}
    152     */
    153    this.showEnableExtensionMessage = false;
    154 
    155    /**
    156     * @type {boolean}
    157     */
    158    this.isDisablingExtension = false;
    159  }
    160 
    161  createRenderRoot() {
    162    return this;
    163  }
    164 
    165  focus() {
    166    this.controlEl.focus();
    167  }
    168 
    169  get controlEl() {
    170    return this.controlRef.value;
    171  }
    172 
    173  async getUpdateComplete() {
    174    let result = await super.getUpdateComplete();
    175    await this.controlEl?.updateComplete;
    176    return result;
    177  }
    178 
    179  onSettingChange = () => {
    180    this.setValue();
    181    this.requestUpdate();
    182  };
    183 
    184  /**
    185   * @type {SettingElement['willUpdate']}
    186   */
    187  willUpdate(changedProperties) {
    188    if (changedProperties.has("setting")) {
    189      if (this.#lastSetting) {
    190        this.#lastSetting.off("change", this.onSettingChange);
    191      }
    192      this.#lastSetting = this.setting;
    193      this.setValue();
    194      this.setting.on("change", this.onSettingChange);
    195    }
    196    if (!this.setting) {
    197      throw new SettingNotDefinedError(this.config.id);
    198    }
    199    let prevHidden = this.hidden;
    200    this.hidden = !this.setting.visible;
    201    if (prevHidden != this.hidden) {
    202      this.dispatchEvent(new Event("visibility-change", { bubbles: true }));
    203    }
    204  }
    205 
    206  updated() {
    207    const control = this.controlRef?.value;
    208    if (!control) {
    209      return;
    210    }
    211 
    212    // Set the value based on the control's API.
    213    if ("checked" in control) {
    214      control.checked = this.value;
    215    } else if ("pressed" in control) {
    216      control.pressed = this.value;
    217    } else if ("value" in control) {
    218      control.value = this.value;
    219    }
    220 
    221    control.requestUpdate?.();
    222  }
    223 
    224  /**
    225   * The default properties that controls and options accept.
    226   * Note: for the disabled property, a setting can either be locked,
    227   * or controlled by an extension but not both.
    228   *
    229   * @override
    230   * @param {SettingElementConfig} config
    231   */
    232  getCommonPropertyMapping(config) {
    233    return {
    234      ...super.getCommonPropertyMapping(config),
    235      ".setting": this.setting,
    236      ".control": this,
    237    };
    238  }
    239 
    240  /**
    241   * The default properties for an option.
    242   *
    243   * @param {SettingOptionConfig} config
    244   */
    245  getOptionPropertyMapping(config) {
    246    return {
    247      ...this.getCommonPropertyMapping(config),
    248      ".value": config.value,
    249      ".disabled": config.disabled,
    250      ".hidden": config.hidden,
    251    };
    252  }
    253 
    254  /**
    255   * The default properties for this control.
    256   *
    257   * @param {SettingControlConfig} config
    258   */
    259  getControlPropertyMapping(config) {
    260    return {
    261      ...this.getCommonPropertyMapping(config),
    262      ".parentDisabled": this.parentDisabled,
    263      "?disabled":
    264        this.setting.disabled ||
    265        this.setting.locked ||
    266        this.isControlledByExtension(),
    267      // Hide moz-message-bar directly to maintain the role=alert functionality.
    268      // This setting-control will be visually hidden in CSS.
    269      ".hidden": config.control == "moz-message-bar" && this.hidden,
    270    };
    271  }
    272 
    273  getValue() {
    274    return this.setting.value;
    275  }
    276 
    277  setValue = () => {
    278    this.value = this.setting.value;
    279  };
    280 
    281  /**
    282   * @param {HTMLElement} el
    283   * @returns {any}
    284   */
    285  controlValue(el) {
    286    let Cls = el.constructor;
    287    if (
    288      "activatedProperty" in Cls &&
    289      Cls.activatedProperty &&
    290      el.localName != "moz-radio"
    291    ) {
    292      return el[/** @type {keyof typeof el} */ (Cls.activatedProperty)];
    293    }
    294    if (el instanceof MozInputFolder) {
    295      return el.folder;
    296    }
    297    return "value" in el ? el.value : null;
    298  }
    299 
    300  /**
    301   * Called by our parent when our input changed.
    302   *
    303   * @param {SettingControlChild} el
    304   */
    305  onChange(el) {
    306    this.setting.userChange(this.controlValue(el));
    307  }
    308 
    309  /**
    310   * Called by our parent when our input is clicked.
    311   *
    312   * @param {MouseEvent} event
    313   */
    314  onClick(event) {
    315    this.setting.userClick(event);
    316  }
    317 
    318  async disableExtension() {
    319    this.isDisablingExtension = true;
    320    this.showEnableExtensionMessage = true;
    321    await this.setting.disableControllingExtension();
    322    this.isDisablingExtension = false;
    323  }
    324 
    325  isControlledByExtension() {
    326    return (
    327      this.setting.controllingExtensionInfo?.id &&
    328      this.setting.controllingExtensionInfo?.name
    329    );
    330  }
    331 
    332  handleEnableExtensionDismiss() {
    333    this.showEnableExtensionMessage = false;
    334  }
    335 
    336  /**
    337   * @param {MouseEvent} event
    338   */
    339  navigateToAddons(event) {
    340    let link = /** @type {HTMLAnchorElement} */ (event.target);
    341    if (link.matches("a[data-l10n-name='addons-link']")) {
    342      event.preventDefault();
    343      // @ts-ignore
    344      let mainWindow = window.browsingContext.topChromeWindow;
    345      mainWindow.BrowserAddonUI.openAddonsMgr("addons://list/extension");
    346    }
    347  }
    348 
    349  get extensionName() {
    350    return this.setting.controllingExtensionInfo.name;
    351  }
    352 
    353  get extensionMessageId() {
    354    return this.setting.controllingExtensionInfo.l10nId;
    355  }
    356 
    357  /**
    358   * Prepare nested item config and settings.
    359   *
    360   * @param {SettingControlConfig | SettingOptionConfig} config
    361   * @returns {TemplateResult[]}
    362   */
    363  itemsTemplate(config) {
    364    if (!config.items) {
    365      return [];
    366    }
    367 
    368    const itemArgs = config.items.map(i => ({
    369      config: i,
    370      setting: this.getSetting(i.id),
    371    }));
    372    let control = config.control || "moz-checkbox";
    373    return itemArgs.map(
    374      item =>
    375        html`<setting-control
    376          .config=${item.config}
    377          .setting=${item.setting}
    378          .getSetting=${this.getSetting}
    379          slot=${ifDefined(
    380            item.config.slot || ITEM_SLOT_BY_PARENT.get(control)
    381          )}
    382        ></setting-control>`
    383    );
    384  }
    385 
    386  /**
    387   * Prepares any children (and any of its children's children) that this element may need.
    388   *
    389   * @param {SettingOptionConfig} config
    390   * @returns {TemplateResult[]}
    391   */
    392  optionsTemplate(config) {
    393    if (!config.options) {
    394      return [];
    395    }
    396    let control = config.control || "moz-checkbox";
    397    return config.options.map(opt => {
    398      let optionTag = opt.control
    399        ? unsafeStatic(opt.control)
    400        : KNOWN_OPTIONS.get(control);
    401      let spreadValues = spread(this.getOptionPropertyMapping(opt));
    402      let children =
    403        "items" in opt ? this.itemsTemplate(opt) : this.optionsTemplate(opt);
    404      if (opt.control == "a" && opt.controlAttrs?.is == "moz-support-link") {
    405        // The `is` attribute must be set when the element is first added to the
    406        // DOM. We need to mark that up manually, since `spread()` uses
    407        // `el.setAttribute()` to set attributes it receives.
    408        return html`<a is="moz-support-link" ${spreadValues}>${children}</a>`;
    409      }
    410      return staticHtml`<${optionTag} ${spreadValues}>${children}</${optionTag}>`;
    411    });
    412  }
    413 
    414  get extensionSupportPage() {
    415    return this.setting.controllingExtensionInfo.supportPage;
    416  }
    417 
    418  render() {
    419    // Allow the Setting to override the static config if necessary.
    420    this.config = this.setting.getControlConfig(this.config);
    421    let { config } = this;
    422    let control = config.control || "moz-checkbox";
    423 
    424    let nestedSettings =
    425      "items" in config
    426        ? this.itemsTemplate(config)
    427        : this.optionsTemplate(config);
    428 
    429    // Get the properties for this element: id, fluent, disabled, etc.
    430    // These will be applied to the control using the spread directive.
    431    let controlProps = this.getControlPropertyMapping(config);
    432 
    433    let tag = unsafeStatic(control);
    434    let messageBar;
    435 
    436    // NOTE: the showEnableMessage message bar should ONLY appear when
    437    // there are no extensions controlling the setting.
    438    if (this.isControlledByExtension()) {
    439      let args = { name: this.extensionName };
    440      let supportPage = this.extensionSupportPage;
    441      messageBar = html`<moz-message-bar
    442        class="extension-controlled-message-bar"
    443        .messageL10nId=${this.extensionMessageId}
    444        .messageL10nArgs=${args}
    445      >
    446        ${supportPage
    447          ? html`<a
    448              is="moz-support-link"
    449              slot="support-link"
    450              support-page=${supportPage}
    451            ></a>`
    452          : ""}
    453        <moz-button
    454          slot="actions"
    455          @click=${this.disableExtension}
    456          ?disabled=${this.isDisablingExtension}
    457          data-l10n-id="disable-extension"
    458        ></moz-button>
    459      </moz-message-bar>`;
    460    } else if (this.showEnableExtensionMessage) {
    461      messageBar = html`<moz-message-bar
    462        class="reenable-extensions-message-bar"
    463        dismissable=""
    464        @message-bar:user-dismissed=${this.handleEnableExtensionDismiss}
    465      >
    466        <span
    467          @click=${this.navigateToAddons}
    468          slot="message"
    469          data-l10n-id="extension-controlled-enable-2"
    470        >
    471          <a data-l10n-name="addons-link" href="#"></a>
    472        </span>
    473      </moz-message-bar>`;
    474    }
    475    return staticHtml`
    476    ${messageBar}
    477    <${tag}
    478      ${spread(controlProps)}
    479      ${ref(this.controlRef)}
    480      tabindex=${ifDefined(this.tabIndex)}
    481    >${nestedSettings}</${tag}>`;
    482  }
    483 }
    484 customElements.define("setting-control", SettingControl);