richard pushed to branch tor-browser-115.7.0esr-13.5-1 at The Tor Project / Applications / Tor Browser
Commits:
- 
e16caa30
by Henry Wilkes at 2024-01-29T18:34:30+00:00
- 
6fe742ee
by Henry Wilkes at 2024-01-29T18:34:30+00:00
- 
7abc2cc1
by Henry Wilkes at 2024-01-29T18:34:30+00:00
- 
fd4565d1
by Henry Wilkes at 2024-01-29T18:34:30+00:00
12 changed files:
- browser/components/preferences/preferences.xhtml
- + browser/components/torpreferences/content/bridgemoji/BridgeEmoji.js
- browser/components/torpreferences/content/connectionPane.js
- browser/components/torpreferences/content/connectionPane.xhtml
- browser/components/torpreferences/content/provideBridgeDialog.js
- browser/components/torpreferences/content/provideBridgeDialog.xhtml
- browser/components/torpreferences/content/torPreferences.css
- browser/components/torpreferences/jar.mn
- browser/locales/en-US/browser/tor-browser.ftl
- toolkit/modules/TorSettings.sys.mjs
- toolkit/modules/TorStrings.sys.mjs
- toolkit/torbutton/chrome/locale/en-US/settings.properties
Changes:
| ... | ... | @@ -70,6 +70,7 @@ | 
| 70 | 70 |    <script type="module" src="chrome://global/content/elements/moz-support-link.mjs"/>
 | 
| 71 | 71 |    <script src="chrome://browser/content/migration/migration-wizard.mjs" type="module"></script>
 | 
| 72 | 72 |    <script type="module" src="chrome://global/content/elements/moz-toggle.mjs"/>
 | 
| 73 | +  <script src="chrome://browser/content/torpreferences/bridgemoji/BridgeEmoji.js"/>
 | |
| 73 | 74 |  </head>
 | 
| 74 | 75 | |
| 75 | 76 |  <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
 | 
| 1 | +"use strict";
 | |
| 2 | + | |
| 3 | +{
 | |
| 4 | +  /**
 | |
| 5 | +   * Element to display a single bridge emoji, with a localized name.
 | |
| 6 | +   */
 | |
| 7 | +  class BridgeEmoji extends HTMLElement {
 | |
| 8 | +    static #activeInstances = new Set();
 | |
| 9 | +    static #observer(subject, topic, data) {
 | |
| 10 | +      if (topic === "intl:app-locales-changed") {
 | |
| 11 | +        BridgeEmoji.#updateEmojiLangCode();
 | |
| 12 | +      }
 | |
| 13 | +    }
 | |
| 14 | + | |
| 15 | +    static #addActiveInstance(inst) {
 | |
| 16 | +      if (this.#activeInstances.size === 0) {
 | |
| 17 | +        Services.obs.addObserver(this.#observer, "intl:app-locales-changed");
 | |
| 18 | +        this.#updateEmojiLangCode();
 | |
| 19 | +      }
 | |
| 20 | +      this.#activeInstances.add(inst);
 | |
| 21 | +    }
 | |
| 22 | + | |
| 23 | +    static #removeActiveInstance(inst) {
 | |
| 24 | +      this.#activeInstances.delete(inst);
 | |
| 25 | +      if (this.#activeInstances.size === 0) {
 | |
| 26 | +        Services.obs.removeObserver(this.#observer, "intl:app-locales-changed");
 | |
| 27 | +      }
 | |
| 28 | +    }
 | |
| 29 | + | |
| 30 | +    /**
 | |
| 31 | +     * The language code for emoji annotations.
 | |
| 32 | +     *
 | |
| 33 | +     * null if unset.
 | |
| 34 | +     *
 | |
| 35 | +     * @type {string?}
 | |
| 36 | +     */
 | |
| 37 | +    static #emojiLangCode = null;
 | |
| 38 | +    /**
 | |
| 39 | +     * A promise that resolves to two JSON structures for bridge-emojis.json and
 | |
| 40 | +     * annotations.json, respectively.
 | |
| 41 | +     *
 | |
| 42 | +     * @type {Promise}
 | |
| 43 | +     */
 | |
| 44 | +    static #emojiPromise = Promise.all([
 | |
| 45 | +      fetch(
 | |
| 46 | +        "chrome://browser/content/torpreferences/bridgemoji/bridge-emojis.json"
 | |
| 47 | +      ).then(response => response.json()),
 | |
| 48 | +      fetch(
 | |
| 49 | +        "chrome://browser/content/torpreferences/bridgemoji/annotations.json"
 | |
| 50 | +      ).then(response => response.json()),
 | |
| 51 | +    ]);
 | |
| 52 | + | |
| 53 | +    static #unknownStringPromise = null;
 | |
| 54 | + | |
| 55 | +    /**
 | |
| 56 | +     * Update #emojiLangCode.
 | |
| 57 | +     */
 | |
| 58 | +    static async #updateEmojiLangCode() {
 | |
| 59 | +      let langCode;
 | |
| 60 | +      const emojiAnnotations = (await BridgeEmoji.#emojiPromise)[1];
 | |
| 61 | +      // Find the first desired locale we have annotations for.
 | |
| 62 | +      // Add "en" as a fallback.
 | |
| 63 | +      for (const bcp47 of [...Services.locale.appLocalesAsBCP47, "en"]) {
 | |
| 64 | +        langCode = bcp47;
 | |
| 65 | +        if (langCode in emojiAnnotations) {
 | |
| 66 | +          break;
 | |
| 67 | +        }
 | |
| 68 | +        // Remove everything after the dash, if there is one.
 | |
| 69 | +        langCode = bcp47.replace(/-.*/, "");
 | |
| 70 | +        if (langCode in emojiAnnotations) {
 | |
| 71 | +          break;
 | |
| 72 | +        }
 | |
| 73 | +      }
 | |
| 74 | +      if (langCode !== this.#emojiLangCode) {
 | |
| 75 | +        this.#emojiLangCode = langCode;
 | |
| 76 | +        this.#unknownStringPromise = document.l10n.formatValue(
 | |
| 77 | +          "tor-bridges-emoji-unknown"
 | |
| 78 | +        );
 | |
| 79 | +        for (const inst of this.#activeInstances) {
 | |
| 80 | +          inst.update();
 | |
| 81 | +        }
 | |
| 82 | +      }
 | |
| 83 | +    }
 | |
| 84 | + | |
| 85 | +    /**
 | |
| 86 | +     * Update the bridge emoji to show their corresponding emoji with an
 | |
| 87 | +     * annotation that matches the current locale.
 | |
| 88 | +     */
 | |
| 89 | +    async update() {
 | |
| 90 | +      if (!this.#active) {
 | |
| 91 | +        return;
 | |
| 92 | +      }
 | |
| 93 | + | |
| 94 | +      if (!BridgeEmoji.#emojiLangCode) {
 | |
| 95 | +        // No lang code yet, wait until it is updated.
 | |
| 96 | +        return;
 | |
| 97 | +      }
 | |
| 98 | + | |
| 99 | +      const doc = this.ownerDocument;
 | |
| 100 | +      const [unknownString, [emojiList, emojiAnnotations]] = await Promise.all([
 | |
| 101 | +        BridgeEmoji.#unknownStringPromise,
 | |
| 102 | +        BridgeEmoji.#emojiPromise,
 | |
| 103 | +      ]);
 | |
| 104 | + | |
| 105 | +      const emoji = emojiList[this.#index];
 | |
| 106 | +      let emojiName;
 | |
| 107 | +      if (!emoji) {
 | |
| 108 | +        // Unexpected.
 | |
| 109 | +        this.#img.removeAttribute("src");
 | |
| 110 | +      } else {
 | |
| 111 | +        const cp = emoji.codePointAt(0).toString(16);
 | |
| 112 | +        this.#img.setAttribute(
 | |
| 113 | +          "src",
 | |
| 114 | +          `chrome://browser/content/torpreferences/bridgemoji/svgs/${cp}.svg`
 | |
| 115 | +        );
 | |
| 116 | +        emojiName = emojiAnnotations[BridgeEmoji.#emojiLangCode][cp];
 | |
| 117 | +      }
 | |
| 118 | +      if (!emojiName) {
 | |
| 119 | +        doc.defaultView.console.error(`No emoji for index ${this.#index}`);
 | |
| 120 | +        emojiName = unknownString;
 | |
| 121 | +      }
 | |
| 122 | +      doc.l10n.setAttributes(this, "tor-bridges-emoji-cell", {
 | |
| 123 | +        emojiName,
 | |
| 124 | +      });
 | |
| 125 | +    }
 | |
| 126 | + | |
| 127 | +    /**
 | |
| 128 | +     * The index for this bridge emoji.
 | |
| 129 | +     *
 | |
| 130 | +     * @type {integer?}
 | |
| 131 | +     */
 | |
| 132 | +    #index = null;
 | |
| 133 | +    /**
 | |
| 134 | +     * Whether we are active (i.e. in the DOM).
 | |
| 135 | +     *
 | |
| 136 | +     * @type {boolean}
 | |
| 137 | +     */
 | |
| 138 | +    #active = false;
 | |
| 139 | +    /**
 | |
| 140 | +     * The image element.
 | |
| 141 | +     *
 | |
| 142 | +     * @type {HTMLImgElement?}
 | |
| 143 | +     */
 | |
| 144 | +    #img = null;
 | |
| 145 | + | |
| 146 | +    constructor(index) {
 | |
| 147 | +      super();
 | |
| 148 | +      this.#index = index;
 | |
| 149 | +    }
 | |
| 150 | + | |
| 151 | +    connectedCallback() {
 | |
| 152 | +      if (!this.#img) {
 | |
| 153 | +        this.#img = this.ownerDocument.createElement("img");
 | |
| 154 | +        this.#img.classList.add("tor-bridges-emoji-icon");
 | |
| 155 | +        this.#img.setAttribute("alt", "");
 | |
| 156 | +        this.appendChild(this.#img);
 | |
| 157 | +      }
 | |
| 158 | + | |
| 159 | +      this.#active = true;
 | |
| 160 | +      BridgeEmoji.#addActiveInstance(this);
 | |
| 161 | +      this.update();
 | |
| 162 | +    }
 | |
| 163 | + | |
| 164 | +    disconnectedCallback() {
 | |
| 165 | +      this.#active = false;
 | |
| 166 | +      BridgeEmoji.#removeActiveInstance(this);
 | |
| 167 | +    }
 | |
| 168 | + | |
| 169 | +    /**
 | |
| 170 | +     * Create four bridge emojis for the given address.
 | |
| 171 | +     *
 | |
| 172 | +     * @param {string} bridgeLine - The bridge address.
 | |
| 173 | +     *
 | |
| 174 | +     * @returns {BridgeEmoji[4]} - The bridge emoji elements.
 | |
| 175 | +     */
 | |
| 176 | +    static createForAddress(bridgeLine) {
 | |
| 177 | +      // JS uses UTF-16. While most of these emojis are surrogate pairs, a few
 | |
| 178 | +      // ones fit one UTF-16 character. So we could not use neither indices,
 | |
| 179 | +      // nor substr, nor some function to split the string.
 | |
| 180 | +      // FNV-1a implementation that is compatible with other languages
 | |
| 181 | +      const prime = 0x01000193;
 | |
| 182 | +      const offset = 0x811c9dc5;
 | |
| 183 | +      let hash = offset;
 | |
| 184 | +      const encoder = new TextEncoder();
 | |
| 185 | +      for (const byte of encoder.encode(bridgeLine)) {
 | |
| 186 | +        hash = Math.imul(hash ^ byte, prime);
 | |
| 187 | +      }
 | |
| 188 | + | |
| 189 | +      return [
 | |
| 190 | +        ((hash & 0x7f000000) >> 24) | (hash < 0 ? 0x80 : 0),
 | |
| 191 | +        (hash & 0x00ff0000) >> 16,
 | |
| 192 | +        (hash & 0x0000ff00) >> 8,
 | |
| 193 | +        hash & 0x000000ff,
 | |
| 194 | +      ].map(index => new BridgeEmoji(index));
 | |
| 195 | +    }
 | |
| 196 | +  }
 | |
| 197 | + | |
| 198 | +  customElements.define("tor-bridge-emoji", BridgeEmoji);
 | |
| 199 | +} | 
| ... | ... | @@ -299,12 +299,10 @@ const gBridgeGrid = { | 
| 299 | 299 | |
| 300 | 300 |      this._active = true;
 | 
| 301 | 301 | |
| 302 | -    Services.obs.addObserver(this, "intl:app-locales-changed");
 | |
| 303 | 302 |      Services.obs.addObserver(this, TorProviderTopics.BridgeChanged);
 | 
| 304 | 303 | |
| 305 | 304 |      this._grid.classList.add("grid-active");
 | 
| 306 | 305 | |
| 307 | -    this._updateEmojiLangCode();
 | |
| 308 | 306 |      this._updateConnectedBridge();
 | 
| 309 | 307 |    },
 | 
| 310 | 308 | |
| ... | ... | @@ -322,7 +320,6 @@ const gBridgeGrid = { | 
| 322 | 320 | |
| 323 | 321 |      this._grid.classList.remove("grid-active");
 | 
| 324 | 322 | |
| 325 | -    Services.obs.removeObserver(this, "intl:app-locales-changed");
 | |
| 326 | 323 |      Services.obs.removeObserver(this, TorProviderTopics.BridgeChanged);
 | 
| 327 | 324 |    },
 | 
| 328 | 325 | |
| ... | ... | @@ -337,9 +334,6 @@ const gBridgeGrid = { | 
| 337 | 334 |            this._updateRows();
 | 
| 338 | 335 |          }
 | 
| 339 | 336 |          break;
 | 
| 340 | -      case "intl:app-locales-changed":
 | |
| 341 | -        this._updateEmojiLangCode();
 | |
| 342 | -        break;
 | |
| 343 | 337 |        case TorProviderTopics.BridgeChanged:
 | 
| 344 | 338 |          this._updateConnectedBridge();
 | 
| 345 | 339 |          break;
 | 
| ... | ... | @@ -573,97 +567,6 @@ const gBridgeGrid = { | 
| 573 | 567 |      }
 | 
| 574 | 568 |    },
 | 
| 575 | 569 | |
| 576 | -  /**
 | |
| 577 | -   * The language code for emoji annotations.
 | |
| 578 | -   *
 | |
| 579 | -   * null if unset.
 | |
| 580 | -   *
 | |
| 581 | -   * @type {string?}
 | |
| 582 | -   */
 | |
| 583 | -  _emojiLangCode: null,
 | |
| 584 | -  /**
 | |
| 585 | -   * A promise that resolves to two JSON structures for bridge-emojis.json and
 | |
| 586 | -   * annotations.json, respectively.
 | |
| 587 | -   *
 | |
| 588 | -   * @type {Promise}
 | |
| 589 | -   */
 | |
| 590 | -  _emojiPromise: Promise.all([
 | |
| 591 | -    fetch(
 | |
| 592 | -      "chrome://browser/content/torpreferences/bridgemoji/bridge-emojis.json"
 | |
| 593 | -    ).then(response => response.json()),
 | |
| 594 | -    fetch(
 | |
| 595 | -      "chrome://browser/content/torpreferences/bridgemoji/annotations.json"
 | |
| 596 | -    ).then(response => response.json()),
 | |
| 597 | -  ]),
 | |
| 598 | - | |
| 599 | -  /**
 | |
| 600 | -   * Update _emojiLangCode.
 | |
| 601 | -   */
 | |
| 602 | -  async _updateEmojiLangCode() {
 | |
| 603 | -    let langCode;
 | |
| 604 | -    const emojiAnnotations = (await this._emojiPromise)[1];
 | |
| 605 | -    // Find the first desired locale we have annotations for.
 | |
| 606 | -    // Add "en" as a fallback.
 | |
| 607 | -    for (const bcp47 of [...Services.locale.appLocalesAsBCP47, "en"]) {
 | |
| 608 | -      langCode = bcp47;
 | |
| 609 | -      if (langCode in emojiAnnotations) {
 | |
| 610 | -        break;
 | |
| 611 | -      }
 | |
| 612 | -      // Remove everything after the dash, if there is one.
 | |
| 613 | -      langCode = bcp47.replace(/-.*/, "");
 | |
| 614 | -      if (langCode in emojiAnnotations) {
 | |
| 615 | -        break;
 | |
| 616 | -      }
 | |
| 617 | -    }
 | |
| 618 | -    if (langCode !== this._emojiLangCode) {
 | |
| 619 | -      this._emojiLangCode = langCode;
 | |
| 620 | -      for (const row of this._rows) {
 | |
| 621 | -        this._updateRowEmojis(row);
 | |
| 622 | -      }
 | |
| 623 | -    }
 | |
| 624 | -  },
 | |
| 625 | - | |
| 626 | -  /**
 | |
| 627 | -   * Update the bridge emojis to show their corresponding emoji with an
 | |
| 628 | -   * annotation that matches the current locale.
 | |
| 629 | -   *
 | |
| 630 | -   * @param {BridgeGridRow} row - The row to update the emojis of.
 | |
| 631 | -   */
 | |
| 632 | -  async _updateRowEmojis(row) {
 | |
| 633 | -    if (!this._emojiLangCode) {
 | |
| 634 | -      // No lang code yet, wait until it is updated.
 | |
| 635 | -      return;
 | |
| 636 | -    }
 | |
| 637 | - | |
| 638 | -    const [emojiList, emojiAnnotations] = await this._emojiPromise;
 | |
| 639 | -    const unknownString = await document.l10n.formatValue(
 | |
| 640 | -      "tor-bridges-emoji-unknown"
 | |
| 641 | -    );
 | |
| 642 | - | |
| 643 | -    for (const { cell, img, index } of row.emojis) {
 | |
| 644 | -      const emoji = emojiList[index];
 | |
| 645 | -      let emojiName;
 | |
| 646 | -      if (!emoji) {
 | |
| 647 | -        // Unexpected.
 | |
| 648 | -        img.removeAttribute("src");
 | |
| 649 | -      } else {
 | |
| 650 | -        const cp = emoji.codePointAt(0).toString(16);
 | |
| 651 | -        img.setAttribute(
 | |
| 652 | -          "src",
 | |
| 653 | -          `chrome://browser/content/torpreferences/bridgemoji/svgs/${cp}.svg`
 | |
| 654 | -        );
 | |
| 655 | -        emojiName = emojiAnnotations[this._emojiLangCode][cp];
 | |
| 656 | -      }
 | |
| 657 | -      if (!emojiName) {
 | |
| 658 | -        console.error(`No emoji for index ${index}`);
 | |
| 659 | -        emojiName = unknownString;
 | |
| 660 | -      }
 | |
| 661 | -      document.l10n.setAttributes(cell, "tor-bridges-emoji-cell", {
 | |
| 662 | -        emojiName,
 | |
| 663 | -      });
 | |
| 664 | -    }
 | |
| 665 | -  },
 | |
| 666 | - | |
| 667 | 570 |    /**
 | 
| 668 | 571 |     * Create a new row for the grid.
 | 
| 669 | 572 |     *
 | 
| ... | ... | @@ -688,23 +591,14 @@ const gBridgeGrid = { | 
| 688 | 591 |      };
 | 
| 689 | 592 | |
| 690 | 593 |      const emojiBlock = row.element.querySelector(".tor-bridges-emojis-block");
 | 
| 691 | -    row.emojis = makeBridgeId(bridgeLine).map(index => {
 | |
| 692 | -      const cell = document.createElement("span");
 | |
| 693 | -      // Each emoji is its own cell, we rely on the fact that makeBridgeId
 | |
| 694 | -      // always returns four indices.
 | |
| 594 | +    const BridgeEmoji = customElements.get("tor-bridge-emoji");
 | |
| 595 | +    for (const cell of BridgeEmoji.createForAddress(bridgeLine)) {
 | |
| 596 | +      // Each emoji is its own cell, we rely on the fact that createForAddress
 | |
| 597 | +      // always returns four elements.
 | |
| 695 | 598 |        cell.setAttribute("role", "gridcell");
 | 
| 696 | 599 |        cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell");
 | 
| 697 | - | |
| 698 | -      const img = document.createElement("img");
 | |
| 699 | -      img.classList.add("tor-bridges-emoji-icon");
 | |
| 700 | -      // Accessible name will be set on the cell itself.
 | |
| 701 | -      img.setAttribute("alt", "");
 | |
| 702 | - | |
| 703 | -      cell.appendChild(img);
 | |
| 704 | -      emojiBlock.appendChild(cell);
 | |
| 705 | -      // Image and text is set in _updateRowEmojis.
 | |
| 706 | -      return { cell, img, index };
 | |
| 707 | -    });
 | |
| 600 | +      emojiBlock.append(cell);
 | |
| 601 | +    }
 | |
| 708 | 602 | |
| 709 | 603 |      for (const [columnIndex, element] of row.element
 | 
| 710 | 604 |        .querySelectorAll(".tor-bridges-grid-cell")
 | 
| ... | ... | @@ -735,7 +629,6 @@ const gBridgeGrid = { | 
| 735 | 629 |      this._initRowMenu(row);
 | 
| 736 | 630 | |
| 737 | 631 |      this._updateRowStatus(row);
 | 
| 738 | -    this._updateRowEmojis(row);
 | |
| 739 | 632 |      return row;
 | 
| 740 | 633 |    },
 | 
| 741 | 634 | |
| ... | ... | @@ -1870,13 +1763,13 @@ const gBridgeSettings = { | 
| 1870 | 1763 |        "chrome://browser/content/torpreferences/provideBridgeDialog.xhtml",
 | 
| 1871 | 1764 |        { mode },
 | 
| 1872 | 1765 |        result => {
 | 
| 1873 | -        if (!result.bridgeStrings) {
 | |
| 1766 | +        if (!result.bridges?.length) {
 | |
| 1874 | 1767 |            return null;
 | 
| 1875 | 1768 |          }
 | 
| 1876 | 1769 |          return setTorSettings(() => {
 | 
| 1877 | 1770 |            TorSettings.bridges.enabled = true;
 | 
| 1878 | 1771 |            TorSettings.bridges.source = TorBridgeSource.UserProvided;
 | 
| 1879 | -          TorSettings.bridges.bridge_strings = result.bridgeStrings;
 | |
| 1772 | +          TorSettings.bridges.bridge_strings = result.bridges;
 | |
| 1880 | 1773 |          });
 | 
| 1881 | 1774 |        }
 | 
| 1882 | 1775 |      );
 | 
| ... | ... | @@ -2292,32 +2185,3 @@ const gConnectionPane = (function () { | 
| 2292 | 2185 |    };
 | 
| 2293 | 2186 |    return retval;
 | 
| 2294 | 2187 |  })(); /* gConnectionPane */ | 
| 2295 | - | |
| 2296 | -/**
 | |
| 2297 | - * Convert the given bridgeString into an array of emoji indices between 0 and
 | |
| 2298 | - * 255.
 | |
| 2299 | - *
 | |
| 2300 | - * @param {string} bridgeString - The bridge string.
 | |
| 2301 | - *
 | |
| 2302 | - * @returns {integer[]} - A list of emoji indices between 0 and 255.
 | |
| 2303 | - */
 | |
| 2304 | -function makeBridgeId(bridgeString) {
 | |
| 2305 | -  // JS uses UTF-16. While most of these emojis are surrogate pairs, a few
 | |
| 2306 | -  // ones fit one UTF-16 character. So we could not use neither indices,
 | |
| 2307 | -  // nor substr, nor some function to split the string.
 | |
| 2308 | -  // FNV-1a implementation that is compatible with other languages
 | |
| 2309 | -  const prime = 0x01000193;
 | |
| 2310 | -  const offset = 0x811c9dc5;
 | |
| 2311 | -  let hash = offset;
 | |
| 2312 | -  const encoder = new TextEncoder();
 | |
| 2313 | -  for (const byte of encoder.encode(bridgeString)) {
 | |
| 2314 | -    hash = Math.imul(hash ^ byte, prime);
 | |
| 2315 | -  }
 | |
| 2316 | - | |
| 2317 | -  return [
 | |
| 2318 | -    ((hash & 0x7f000000) >> 24) | (hash < 0 ? 0x80 : 0),
 | |
| 2319 | -    (hash & 0x00ff0000) >> 16,
 | |
| 2320 | -    (hash & 0x0000ff00) >> 8,
 | |
| 2321 | -    hash & 0x000000ff,
 | |
| 2322 | -  ];
 | |
| 2323 | -} | 
| ... | ... | @@ -218,6 +218,7 @@ | 
| 218 | 218 |        </html:div>
 | 
| 219 | 219 |        <html:div
 | 
| 220 | 220 |          id="tor-bridges-grid-display"
 | 
| 221 | +        class="tor-bridges-grid"
 | |
| 221 | 222 |          role="grid"
 | 
| 222 | 223 |          aria-labelledby="tor-bridges-current-heading"
 | 
| 223 | 224 |        ></html:div>
 | 
| ... | ... | @@ -4,14 +4,17 @@ const { TorStrings } = ChromeUtils.importESModule( | 
| 4 | 4 |    "resource://gre/modules/TorStrings.sys.mjs"
 | 
| 5 | 5 |  );
 | 
| 6 | 6 | |
| 7 | -const { TorSettings, TorBridgeSource } = ChromeUtils.importESModule(
 | |
| 8 | -  "resource://gre/modules/TorSettings.sys.mjs"
 | |
| 9 | -);
 | |
| 7 | +const { TorSettings, TorBridgeSource, validateBridgeLines } =
 | |
| 8 | +  ChromeUtils.importESModule("resource://gre/modules/TorSettings.sys.mjs");
 | |
| 10 | 9 | |
| 11 | 10 |  const { TorConnect, TorConnectTopics } = ChromeUtils.importESModule(
 | 
| 12 | 11 |    "resource://gre/modules/TorConnect.sys.mjs"
 | 
| 13 | 12 |  );
 | 
| 14 | 13 | |
| 14 | +const { TorParsers } = ChromeUtils.importESModule(
 | |
| 15 | +  "resource://gre/modules/TorParsers.sys.mjs"
 | |
| 16 | +);
 | |
| 17 | + | |
| 15 | 18 |  const gProvideBridgeDialog = {
 | 
| 16 | 19 |    init() {
 | 
| 17 | 20 |      this._result = window.arguments[0];
 | 
| ... | ... | @@ -33,72 +36,264 @@ const gProvideBridgeDialog = { | 
| 33 | 36 | |
| 34 | 37 |      document.l10n.setAttributes(document.documentElement, titleId);
 | 
| 35 | 38 | |
| 36 | -    const learnMore = document.createXULElement("label");
 | |
| 37 | -    learnMore.className = "learnMore text-link";
 | |
| 38 | -    learnMore.setAttribute("is", "text-link");
 | |
| 39 | -    learnMore.setAttribute("value", TorStrings.settings.learnMore);
 | |
| 40 | -    learnMore.addEventListener("click", () => {
 | |
| 41 | -      window.top.openTrustedLinkIn(
 | |
| 42 | -        TorStrings.settings.learnMoreBridgesURL,
 | |
| 43 | -        "tab"
 | |
| 44 | -      );
 | |
| 45 | -    });
 | |
| 46 | - | |
| 47 | -    const pieces = TorStrings.settings.provideBridgeDescription.split("%S");
 | |
| 48 | -    document
 | |
| 49 | -      .getElementById("torPreferences-provideBridge-description")
 | |
| 50 | -      .replaceChildren(pieces[0], learnMore, pieces[1] || "");
 | |
| 39 | +    document.l10n.setAttributes(
 | |
| 40 | +      document.getElementById("user-provide-bridge-textarea-label"),
 | |
| 41 | +      // TODO change string when we can also accept Lox share codes.
 | |
| 42 | +      "user-provide-bridge-dialog-textarea-addresses-label"
 | |
| 43 | +    );
 | |
| 51 | 44 | |
| 52 | -    this._textarea = document.getElementById(
 | |
| 53 | -      "torPreferences-provideBridge-textarea"
 | |
| 45 | +    this._dialog = document.getElementById("user-provide-bridge-dialog");
 | |
| 46 | +    this._acceptButton = this._dialog.getButton("accept");
 | |
| 47 | +    this._textarea = document.getElementById("user-provide-bridge-textarea");
 | |
| 48 | +    this._errorEl = document.getElementById(
 | |
| 49 | +      "user-provide-bridge-error-message"
 | |
| 50 | +    );
 | |
| 51 | +    this._resultDescription = document.getElementById(
 | |
| 52 | +      "user-provide-result-description"
 | |
| 53 | +    );
 | |
| 54 | +    this._bridgeGrid = document.getElementById(
 | |
| 55 | +      "user-provide-bridge-grid-display"
 | |
| 54 | 56 |      );
 | 
| 55 | -    this._textarea.setAttribute(
 | |
| 56 | -      "placeholder",
 | |
| 57 | -      TorStrings.settings.provideBridgePlaceholder
 | |
| 57 | +    this._rowTemplate = document.getElementById(
 | |
| 58 | +      "user-provide-bridge-row-template"
 | |
| 58 | 59 |      );
 | 
| 59 | 60 | |
| 60 | -    this._textarea.addEventListener("input", () => this.onValueChange());
 | |
| 61 | -    if (TorSettings.bridges.source == TorBridgeSource.UserProvided) {
 | |
| 62 | -      this._textarea.value = TorSettings.bridges.bridge_strings.join("\n");
 | |
| 61 | +    if (mode === "edit") {
 | |
| 62 | +      // Only expected if the bridge source is UseProvided, but verify to be
 | |
| 63 | +      // sure.
 | |
| 64 | +      if (TorSettings.bridges.source == TorBridgeSource.UserProvided) {
 | |
| 65 | +        this._textarea.value = TorSettings.bridges.bridge_strings.join("\n");
 | |
| 66 | +      }
 | |
| 67 | +    } else {
 | |
| 68 | +      // Set placeholder if not editing.
 | |
| 69 | +      document.l10n.setAttributes(
 | |
| 70 | +        this._textarea,
 | |
| 71 | +        // TODO: change string when we can also accept Lox share codes.
 | |
| 72 | +        "user-provide-bridge-dialog-textarea-addresses"
 | |
| 73 | +      );
 | |
| 63 | 74 |      }
 | 
| 64 | 75 | |
| 65 | -    const dialog = document.getElementById(
 | |
| 66 | -      "torPreferences-provideBridge-dialog"
 | |
| 67 | -    );
 | |
| 68 | -    dialog.addEventListener("dialogaccept", e => {
 | |
| 69 | -      this._result.accepted = true;
 | |
| 70 | -    });
 | |
| 76 | +    this._textarea.addEventListener("input", () => this.onValueChange());
 | |
| 71 | 77 | |
| 72 | -    this._acceptButton = dialog.getButton("accept");
 | |
| 78 | +    this._dialog.addEventListener("dialogaccept", event =>
 | |
| 79 | +      this.onDialogAccept(event)
 | |
| 80 | +    );
 | |
| 73 | 81 | |
| 74 | 82 |      Services.obs.addObserver(this, TorConnectTopics.StateChange);
 | 
| 75 | 83 | |
| 76 | -    this.onValueChange();
 | |
| 77 | -    this.onAcceptStateChange();
 | |
| 84 | +    this.setPage("entry");
 | |
| 85 | +    this.checkValue();
 | |
| 78 | 86 |    },
 | 
| 79 | 87 | |
| 80 | 88 |    uninit() {
 | 
| 81 | 89 |      Services.obs.removeObserver(this, TorConnectTopics.StateChange);
 | 
| 82 | 90 |    },
 | 
| 83 | 91 | |
| 92 | +  /**
 | |
| 93 | +   * Set the page to display.
 | |
| 94 | +   *
 | |
| 95 | +   * @param {string} page - The page to show.
 | |
| 96 | +   */
 | |
| 97 | +  setPage(page) {
 | |
| 98 | +    this._page = page;
 | |
| 99 | +    this._dialog.classList.toggle("show-entry-page", page === "entry");
 | |
| 100 | +    this._dialog.classList.toggle("show-result-page", page === "result");
 | |
| 101 | +    if (page === "entry") {
 | |
| 102 | +      this._textarea.focus();
 | |
| 103 | +    } else {
 | |
| 104 | +      // Move focus to the <xul:window> element.
 | |
| 105 | +      // In particular, we do not want to keep the focus on the (same) accept
 | |
| 106 | +      // button (with now different text).
 | |
| 107 | +      document.documentElement.focus();
 | |
| 108 | +    }
 | |
| 109 | + | |
| 110 | +    this.updateAcceptDisabled();
 | |
| 111 | +    this.onAcceptStateChange();
 | |
| 112 | +  },
 | |
| 113 | + | |
| 114 | +  /**
 | |
| 115 | +   * Callback for whenever the input value changes.
 | |
| 116 | +   */
 | |
| 84 | 117 |    onValueChange() {
 | 
| 85 | -    // TODO: Do some proper value parsing and error reporting. See
 | |
| 86 | -    // tor-browser#40552.
 | |
| 87 | -    const value = this._textarea.value.trim();
 | |
| 88 | -    this._acceptButton.disabled = !value;
 | |
| 89 | -    this._result.bridgeStrings = value;
 | |
| 118 | +    this.updateAcceptDisabled();
 | |
| 119 | +    // Reset errors whenever the value changes.
 | |
| 120 | +    this.updateError(null);
 | |
| 90 | 121 |    },
 | 
| 91 | 122 | |
| 123 | +  /**
 | |
| 124 | +   * Callback for whenever the accept button may need to change.
 | |
| 125 | +   */
 | |
| 92 | 126 |    onAcceptStateChange() {
 | 
| 93 | -    const connect = TorConnect.canBeginBootstrap;
 | |
| 94 | -    this._result.connect = connect;
 | |
| 95 | - | |
| 96 | -    this._acceptButton.setAttribute(
 | |
| 97 | -      "label",
 | |
| 98 | -      connect
 | |
| 99 | -        ? TorStrings.settings.bridgeButtonConnect
 | |
| 100 | -        : TorStrings.settings.bridgeButtonAccept
 | |
| 127 | +    if (this._page === "entry") {
 | |
| 128 | +      document.l10n.setAttributes(
 | |
| 129 | +        this._acceptButton,
 | |
| 130 | +        "user-provide-bridge-dialog-next-button"
 | |
| 131 | +      );
 | |
| 132 | +      this._result.connect = false;
 | |
| 133 | +    } else {
 | |
| 134 | +      this._acceptButton.removeAttribute("data-l10n-id");
 | |
| 135 | +      const connect = TorConnect.canBeginBootstrap;
 | |
| 136 | +      this._result.connect = connect;
 | |
| 137 | + | |
| 138 | +      this._acceptButton.setAttribute(
 | |
| 139 | +        "label",
 | |
| 140 | +        connect
 | |
| 141 | +          ? TorStrings.settings.bridgeButtonConnect
 | |
| 142 | +          : TorStrings.settings.bridgeButtonAccept
 | |
| 143 | +      );
 | |
| 144 | +    }
 | |
| 145 | +  },
 | |
| 146 | + | |
| 147 | +  /**
 | |
| 148 | +   * Callback for whenever the accept button's might need to be disabled.
 | |
| 149 | +   */
 | |
| 150 | +  updateAcceptDisabled() {
 | |
| 151 | +    this._acceptButton.disabled =
 | |
| 152 | +      this._page === "entry" && validateBridgeLines(this._textarea.value).empty;
 | |
| 153 | +  },
 | |
| 154 | + | |
| 155 | +  /**
 | |
| 156 | +   * Callback for when the accept button is pressed.
 | |
| 157 | +   *
 | |
| 158 | +   * @param {Event} event - The dialogaccept event.
 | |
| 159 | +   */
 | |
| 160 | +  onDialogAccept(event) {
 | |
| 161 | +    if (this._page === "result") {
 | |
| 162 | +      this._result.accepted = true;
 | |
| 163 | +      // Continue to close the dialog.
 | |
| 164 | +      return;
 | |
| 165 | +    }
 | |
| 166 | +    // Prevent closing the dialog.
 | |
| 167 | +    event.preventDefault();
 | |
| 168 | + | |
| 169 | +    const bridges = this.checkValue();
 | |
| 170 | +    if (!bridges.length) {
 | |
| 171 | +      // Not valid
 | |
| 172 | +      return;
 | |
| 173 | +    }
 | |
| 174 | +    this._result.bridges = bridges;
 | |
| 175 | +    this.updateResult();
 | |
| 176 | +    this.setPage("result");
 | |
| 177 | +  },
 | |
| 178 | + | |
| 179 | +  /**
 | |
| 180 | +   * The current timeout for updating the error.
 | |
| 181 | +   *
 | |
| 182 | +   * @type {integer?}
 | |
| 183 | +   */
 | |
| 184 | +  _updateErrorTimeout: null,
 | |
| 185 | + | |
| 186 | +  /**
 | |
| 187 | +   * Update the displayed error.
 | |
| 188 | +   *
 | |
| 189 | +   * @param {object?} error - The error to show, or null if no error should be
 | |
| 190 | +   *   shown. Should include the "type" property.
 | |
| 191 | +   */
 | |
| 192 | +  updateError(error) {
 | |
| 193 | +    // First clear the existing error.
 | |
| 194 | +    if (this._updateErrorTimeout !== null) {
 | |
| 195 | +      clearTimeout(this._updateErrorTimeout);
 | |
| 196 | +    }
 | |
| 197 | +    this._updateErrorTimeout = null;
 | |
| 198 | +    this._errorEl.removeAttribute("data-l10n-id");
 | |
| 199 | +    this._errorEl.textContent = "";
 | |
| 200 | +    if (error) {
 | |
| 201 | +      this._textarea.setAttribute("aria-invalid", "true");
 | |
| 202 | +    } else {
 | |
| 203 | +      this._textarea.removeAttribute("aria-invalid");
 | |
| 204 | +    }
 | |
| 205 | +    this._textarea.classList.toggle("invalid-input", !!error);
 | |
| 206 | +    this._errorEl.classList.toggle("show-error", !!error);
 | |
| 207 | + | |
| 208 | +    if (!error) {
 | |
| 209 | +      return;
 | |
| 210 | +    }
 | |
| 211 | + | |
| 212 | +    let errorId;
 | |
| 213 | +    let errorArgs;
 | |
| 214 | +    switch (error.type) {
 | |
| 215 | +      case "invalid-address":
 | |
| 216 | +        errorId = "user-provide-bridge-dialog-address-error";
 | |
| 217 | +        errorArgs = { line: error.line };
 | |
| 218 | +        break;
 | |
| 219 | +    }
 | |
| 220 | + | |
| 221 | +    // Wait a small amount of time to actually set the textContent. Otherwise
 | |
| 222 | +    // the screen reader (tested with Orca) may not pick up on the change in
 | |
| 223 | +    // text.
 | |
| 224 | +    this._updateErrorTimeout = setTimeout(() => {
 | |
| 225 | +      document.l10n.setAttributes(this._errorEl, errorId, errorArgs);
 | |
| 226 | +    }, 500);
 | |
| 227 | +  },
 | |
| 228 | + | |
| 229 | +  /**
 | |
| 230 | +   * Check the current value in the textarea.
 | |
| 231 | +   *
 | |
| 232 | +   * @returns {string[]} - The bridge addresses, if the entry is valid.
 | |
| 233 | +   */
 | |
| 234 | +  checkValue() {
 | |
| 235 | +    let bridges = [];
 | |
| 236 | +    let error = null;
 | |
| 237 | +    const validation = validateBridgeLines(this._textarea.value);
 | |
| 238 | +    if (!validation.empty) {
 | |
| 239 | +      // If empty, we just disable the button, rather than show an error.
 | |
| 240 | +      if (validation.errorLines.length) {
 | |
| 241 | +        // Report first error.
 | |
| 242 | +        error = {
 | |
| 243 | +          type: "invalid-address",
 | |
| 244 | +          line: validation.errorLines[0],
 | |
| 245 | +        };
 | |
| 246 | +      } else {
 | |
| 247 | +        bridges = validation.validBridges;
 | |
| 248 | +      }
 | |
| 249 | +    }
 | |
| 250 | +    this.updateError(error);
 | |
| 251 | +    return bridges;
 | |
| 252 | +  },
 | |
| 253 | + | |
| 254 | +  /**
 | |
| 255 | +   * Update the shown result on the last page.
 | |
| 256 | +   */
 | |
| 257 | +  updateResult() {
 | |
| 258 | +    document.l10n.setAttributes(
 | |
| 259 | +      this._resultDescription,
 | |
| 260 | +      // TODO: Use a different id when added through Lox invite.
 | |
| 261 | +      "user-provide-bridge-dialog-result-addresses"
 | |
| 101 | 262 |      );
 | 
| 263 | + | |
| 264 | +    this._bridgeGrid.replaceChildren();
 | |
| 265 | + | |
| 266 | +    for (const bridgeLine of this._result.bridges) {
 | |
| 267 | +      let details;
 | |
| 268 | +      try {
 | |
| 269 | +        details = TorParsers.parseBridgeLine(bridgeLine);
 | |
| 270 | +      } catch (e) {
 | |
| 271 | +        console.error(`Detected invalid bridge line: ${bridgeLine}`, e);
 | |
| 272 | +      }
 | |
| 273 | + | |
| 274 | +      const rowEl = this._rowTemplate.content.children[0].cloneNode(true);
 | |
| 275 | + | |
| 276 | +      const emojiBlock = rowEl.querySelector(".tor-bridges-emojis-block");
 | |
| 277 | +      const BridgeEmoji = customElements.get("tor-bridge-emoji");
 | |
| 278 | +      for (const cell of BridgeEmoji.createForAddress(bridgeLine)) {
 | |
| 279 | +        // Each emoji is its own cell, we rely on the fact that createForAddress
 | |
| 280 | +        // always returns four elements.
 | |
| 281 | +        cell.setAttribute("role", "gridcell");
 | |
| 282 | +        cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell");
 | |
| 283 | +        emojiBlock.append(cell);
 | |
| 284 | +      }
 | |
| 285 | + | |
| 286 | +      // TODO: properly handle "vanilla" bridges?
 | |
| 287 | +      document.l10n.setAttributes(
 | |
| 288 | +        rowEl.querySelector(".tor-bridges-type-cell"),
 | |
| 289 | +        "tor-bridges-type-prefix",
 | |
| 290 | +        { type: details?.transport ?? "vanilla" }
 | |
| 291 | +      );
 | |
| 292 | + | |
| 293 | +      rowEl.querySelector(".tor-bridges-address-cell").textContent = bridgeLine;
 | |
| 294 | + | |
| 295 | +      this._bridgeGrid.append(rowEl);
 | |
| 296 | +    }
 | |
| 102 | 297 |    },
 | 
| 103 | 298 | |
| 104 | 299 |    observe(subject, topic, data) {
 | 
| ... | ... | @@ -8,22 +8,77 @@ | 
| 8 | 8 |    xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
 | 
| 9 | 9 |    xmlns:html="http://www.w3.org/1999/xhtml"
 | 
| 10 | 10 |  >
 | 
| 11 | -  <dialog id="torPreferences-provideBridge-dialog" buttons="accept,cancel">
 | |
| 11 | +  <dialog
 | |
| 12 | +    id="user-provide-bridge-dialog"
 | |
| 13 | +    buttons="accept,cancel"
 | |
| 14 | +    class="show-entry-page"
 | |
| 15 | +  >
 | |
| 12 | 16 |      <linkset>
 | 
| 13 | 17 |        <html:link rel="localization" href="browser/tor-browser.ftl" />
 | 
| 14 | 18 |      </linkset>
 | 
| 15 | 19 | |
| 20 | +    <script src="chrome://browser/content/torpreferences/bridgemoji/BridgeEmoji.js" />
 | |
| 16 | 21 |      <script src="chrome://browser/content/torpreferences/provideBridgeDialog.js" />
 | 
| 17 | 22 | |
| 18 | -    <description>
 | |
| 19 | -      <html:div id="torPreferences-provideBridge-description"
 | |
| 20 | -        >​<br />​</html:div
 | |
| 21 | -      >
 | |
| 22 | -    </description>
 | |
| 23 | -    <html:textarea
 | |
| 24 | -      id="torPreferences-provideBridge-textarea"
 | |
| 25 | -      multiline="true"
 | |
| 26 | -      rows="3"
 | |
| 27 | -    />
 | |
| 23 | +    <html:div id="user-provide-bridge-entry-page">
 | |
| 24 | +      <description id="user-provide-bridge-description">
 | |
| 25 | +        <html:span
 | |
| 26 | +          class="tail-with-learn-more"
 | |
| 27 | +          data-l10n-id="user-provide-bridge-dialog-description"
 | |
| 28 | +        ></html:span>
 | |
| 29 | +        <label
 | |
| 30 | +          is="text-link"
 | |
| 31 | +          class="learnMore text-link"
 | |
| 32 | +          href="about:manual#bridges"
 | |
| 33 | +          useoriginprincipal="true"
 | |
| 34 | +          data-l10n-id="user-provide-bridge-dialog-learn-more"
 | |
| 35 | +        />
 | |
| 36 | +      </description>
 | |
| 37 | +      <html:label
 | |
| 38 | +        id="user-provide-bridge-textarea-label"
 | |
| 39 | +        for="user-provide-bridge-textarea"
 | |
| 40 | +      ></html:label>
 | |
| 41 | +      <html:textarea
 | |
| 42 | +        id="user-provide-bridge-textarea"
 | |
| 43 | +        multiline="true"
 | |
| 44 | +        rows="3"
 | |
| 45 | +        aria-describedby="user-provide-bridge-description"
 | |
| 46 | +        aria-errormessage="user-provide-bridge-error-message"
 | |
| 47 | +      />
 | |
| 48 | +      <html:div id="user-provide-bridge-message-area">
 | |
| 49 | +        <html:span
 | |
| 50 | +          id="user-provide-bridge-error-message"
 | |
| 51 | +          aria-live="assertive"
 | |
| 52 | +        ></html:span>
 | |
| 53 | +      </html:div>
 | |
| 54 | +    </html:div>
 | |
| 55 | +    <html:div id="user-provide-bridge-result-page">
 | |
| 56 | +      <description id="user-provide-result-description" />
 | |
| 57 | +      <!-- NOTE: Unlike #tor-bridge-grid-display, this element is not
 | |
| 58 | +         - interactive, and not a tab-stop. So we use the "table" role rather
 | |
| 59 | +         - than "grid".
 | |
| 60 | +         - NOTE: Using a <html:table> would not allow us the same structural
 | |
| 61 | +         - freedom, so we use a generic div and add the semantics manually. -->
 | |
| 62 | +      <html:div
 | |
| 63 | +        id="user-provide-bridge-grid-display"
 | |
| 64 | +        class="tor-bridges-grid"
 | |
| 65 | +        role="table"
 | |
| 66 | +      ></html:div>
 | |
| 67 | +      <html:template id="user-provide-bridge-row-template">
 | |
| 68 | +        <html:div class="tor-bridges-grid-row" role="row">
 | |
| 69 | +          <html:span
 | |
| 70 | +            class="tor-bridges-type-cell tor-bridges-grid-cell"
 | |
| 71 | +            role="gridcell"
 | |
| 72 | +          ></html:span>
 | |
| 73 | +          <html:span class="tor-bridges-emojis-block" role="none"></html:span>
 | |
| 74 | +          <html:span class="tor-bridges-grid-end-block" role="none">
 | |
| 75 | +            <html:span
 | |
| 76 | +              class="tor-bridges-address-cell tor-bridges-grid-cell"
 | |
| 77 | +              role="gridcell"
 | |
| 78 | +            ></html:span>
 | |
| 79 | +          </html:span>
 | |
| 80 | +        </html:div>
 | |
| 81 | +      </html:template>
 | |
| 82 | +    </html:div>
 | |
| 28 | 83 |    </dialog>
 | 
| 29 | 84 |  </window> | 
| ... | ... | @@ -270,11 +270,14 @@ | 
| 270 | 270 |    grid-area: description;
 | 
| 271 | 271 |  }
 | 
| 272 | 272 | |
| 273 | -#tor-bridges-grid-display {
 | |
| 273 | +.tor-bridges-grid {
 | |
| 274 | 274 |    display: grid;
 | 
| 275 | 275 |    grid-template-columns: max-content repeat(4, max-content) 1fr;
 | 
| 276 | 276 |    --tor-bridges-grid-column-gap: 8px;
 | 
| 277 | 277 |    --tor-bridges-grid-column-short-gap: 4px;
 | 
| 278 | +  /* For #tor-bridges-grid-display we want each grid item to have the same
 | |
| 279 | +   * height so that their focus outlines match. */
 | |
| 280 | +  align-items: stretch;
 | |
| 278 | 281 |  }
 | 
| 279 | 282 | |
| 280 | 283 |  #tor-bridges-grid-display:not(.grid-active) {
 | 
| ... | ... | @@ -283,11 +286,12 @@ | 
| 283 | 286 | |
| 284 | 287 |  .tor-bridges-grid-row {
 | 
| 285 | 288 |    /* We want each row to act as a row of three items in the
 | 
| 286 | -   * #tor-bridges-grid-display grid layout.
 | |
| 289 | +   * .tor-bridges-grid grid layout.
 | |
| 287 | 290 |     * We also want a 16px spacing between rows, and 8px spacing between columns,
 | 
| 288 | -   * which are outside the .tor-bridges-grid-cell's border area. So that
 | |
| 289 | -   * clicking these gaps will not focus any item, and their focus outlines do
 | |
| 290 | -   * not overlap.
 | |
| 291 | +   * which are outside the .tor-bridges-grid-cell's border area.
 | |
| 292 | +   *
 | |
| 293 | +   * For #tor-bridges-grid-display this should ensure that clicking these gaps
 | |
| 294 | +   * will not focus any item, and their focus outlines do not overlap.
 | |
| 291 | 295 |     * Moreover, we also want each row to show its .tor-bridges-options-cell when
 | 
| 292 | 296 |     * the .tor-bridges-grid-row has :hover.
 | 
| 293 | 297 |     *
 | 
| ... | ... | @@ -311,7 +315,8 @@ | 
| 311 | 315 |    padding-block: 8px;
 | 
| 312 | 316 |  }
 | 
| 313 | 317 | |
| 314 | -.tor-bridges-grid-cell:focus-visible {
 | |
| 318 | +#tor-bridges-grid-display .tor-bridges-grid-cell:focus-visible {
 | |
| 319 | +  /* #tor-bridges-grid-display has focus management for its cells. */
 | |
| 315 | 320 |    outline: var(--in-content-focus-outline);
 | 
| 316 | 321 |    outline-offset: var(--in-content-focus-outline-offset);
 | 
| 317 | 322 |  }
 | 
| ... | ... | @@ -662,8 +667,77 @@ groupbox#torPreferences-bridges-group textarea { | 
| 662 | 667 |  }
 | 
| 663 | 668 | |
| 664 | 669 |  /* Provide bridge dialog */
 | 
| 665 | -#torPreferences-provideBridge-textarea {
 | |
| 666 | -  margin-top: 16px;
 | |
| 670 | + | |
| 671 | +#user-provide-bridge-dialog:not(.show-entry-page) #user-provide-bridge-entry-page {
 | |
| 672 | +  display: none;
 | |
| 673 | +}
 | |
| 674 | + | |
| 675 | +#user-provide-bridge-dialog:not(.show-result-page) #user-provide-bridge-result-page {
 | |
| 676 | +  display: none;
 | |
| 677 | +}
 | |
| 678 | + | |
| 679 | +#user-provide-bridge-entry-page {
 | |
| 680 | +  flex: 1 0 auto;
 | |
| 681 | +  display: flex;
 | |
| 682 | +  flex-direction: column;
 | |
| 683 | +}
 | |
| 684 | + | |
| 685 | +#user-provide-bridge-description {
 | |
| 686 | +  flex: 0 0 auto;
 | |
| 687 | +}
 | |
| 688 | + | |
| 689 | +#user-provide-bridge-textarea-label {
 | |
| 690 | +  margin-block: 16px 6px;
 | |
| 691 | +  flex: 0 0 auto;
 | |
| 692 | +  align-self: start;
 | |
| 693 | +}
 | |
| 694 | + | |
| 695 | +#user-provide-bridge-textarea {
 | |
| 696 | +  flex: 1 0 auto;
 | |
| 697 | +  align-self: stretch;
 | |
| 698 | +  line-height: 1.3;
 | |
| 699 | +  margin: 0;
 | |
| 700 | +}
 | |
| 701 | + | |
| 702 | +#user-provide-bridge-message-area {
 | |
| 703 | +  flex: 0 0 auto;
 | |
| 704 | +  margin-block: 8px 12px;
 | |
| 705 | +  align-self: end;
 | |
| 706 | +}
 | |
| 707 | + | |
| 708 | +#user-provide-bridge-message-area::after {
 | |
| 709 | +  /* Zero width space, to ensure we are always one line high. */
 | |
| 710 | +  content: "\200B";
 | |
| 711 | +}
 | |
| 712 | + | |
| 713 | +#user-provide-bridge-textarea.invalid-input {
 | |
| 714 | +  border-color: var(--in-content-danger-button-background);
 | |
| 715 | +  outline-color: var(--in-content-danger-button-background);
 | |
| 716 | +}
 | |
| 717 | + | |
| 718 | +#user-provide-bridge-error-message {
 | |
| 719 | +  color: var(--in-content-error-text-color);
 | |
| 720 | +}
 | |
| 721 | + | |
| 722 | +#user-provide-bridge-error-message.not(.show-error) {
 | |
| 723 | +  display: none;
 | |
| 724 | +}
 | |
| 725 | + | |
| 726 | +#user-provide-bridge-result-page {
 | |
| 727 | +  flex: 1 1 0;
 | |
| 728 | +  min-height: 0;
 | |
| 729 | +  display: flex;
 | |
| 730 | +  flex-direction: column;
 | |
| 731 | +}
 | |
| 732 | + | |
| 733 | +#user-provide-result-description {
 | |
| 734 | +  flex: 0 0 auto;
 | |
| 735 | +}
 | |
| 736 | + | |
| 737 | +#user-provide-bridge-grid-display {
 | |
| 738 | +  flex: 0 1 auto;
 | |
| 739 | +  overflow: auto;
 | |
| 740 | +  margin-block: 8px;
 | |
| 667 | 741 |  }
 | 
| 668 | 742 | |
| 669 | 743 |  /* Connection settings dialog */
 | 
| ... | ... | @@ -22,6 +22,7 @@ browser.jar: | 
| 22 | 22 |      content/browser/torpreferences/connectionPane.xhtml              (content/connectionPane.xhtml)
 | 
| 23 | 23 |      content/browser/torpreferences/torPreferences.css                (content/torPreferences.css)
 | 
| 24 | 24 |      content/browser/torpreferences/bridge-qr-onion-mask.svg          (content/bridge-qr-onion-mask.svg)
 | 
| 25 | +    content/browser/torpreferences/bridgemoji/BridgeEmoji.js         (content/bridgemoji/BridgeEmoji.js)
 | |
| 25 | 26 |      content/browser/torpreferences/bridgemoji/bridge-emojis.json     (content/bridgemoji/bridge-emojis.json)
 | 
| 26 | 27 |      content/browser/torpreferences/bridgemoji/annotations.json       (content/bridgemoji/annotations.json)
 | 
| 27 | 28 |      content/browser/torpreferences/bridgemoji/svgs/                  (content/bridgemoji/svgs/*.svg) | 
| ... | ... | @@ -176,3 +176,19 @@ user-provide-bridge-dialog-add-title = | 
| 176 | 176 |  # Used when the user is replacing their existing bridges with new ones.
 | 
| 177 | 177 |  user-provide-bridge-dialog-replace-title =
 | 
| 178 | 178 |      .title = Replace your bridges
 | 
| 179 | +# Description shown when adding new bridges, replacing existing bridges, or editing existing bridges.
 | |
| 180 | +user-provide-bridge-dialog-description = Use bridges provided by a trusted organisation or someone you know.
 | |
| 181 | +# "Learn more" link shown in the "Add new bridges"/"Replace your bridges" dialog.
 | |
| 182 | +user-provide-bridge-dialog-learn-more = Learn more
 | |
| 183 | +# Short accessible name for the bridge addresses text area.
 | |
| 184 | +user-provide-bridge-dialog-textarea-addresses-label = Bridge addresses
 | |
| 185 | +# Placeholder shown when adding new bridge addresses.
 | |
| 186 | +user-provide-bridge-dialog-textarea-addresses =
 | |
| 187 | +    .placeholder = Paste your bridge addresses here
 | |
| 188 | +# Error shown when one of the address lines is invalid.
 | |
| 189 | +# $line (Number) - The line number for the invalid address.
 | |
| 190 | +user-provide-bridge-dialog-address-error = Incorrectly formatted bridge address on line { $line }.
 | |
| 191 | + | |
| 192 | +user-provide-bridge-dialog-result-addresses = The following bridges were entered by you.
 | |
| 193 | +user-provide-bridge-dialog-next-button =
 | |
| 194 | +    .label = Next | 
| ... | ... | @@ -9,6 +9,7 @@ ChromeUtils.defineESModuleGetters(lazy, { | 
| 9 | 9 |    TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs",
 | 
| 10 | 10 |    TorProviderTopics: "resource://gre/modules/TorProviderBuilder.sys.mjs",
 | 
| 11 | 11 |    Lox: "resource://gre/modules/Lox.sys.mjs",
 | 
| 12 | +  TorParsers: "resource://gre/modules/TorParsers.sys.mjs",
 | |
| 12 | 13 |  });
 | 
| 13 | 14 | |
| 14 | 15 |  ChromeUtils.defineLazyGetter(lazy, "logger", () => {
 | 
| ... | ... | @@ -103,26 +104,61 @@ export const TorProxyType = Object.freeze({ | 
| 103 | 104 |   * Split a blob of bridge lines into an array with single lines.
 | 
| 104 | 105 |   * Lines are delimited by \r\n or \n and each bridge string can also optionally
 | 
| 105 | 106 |   * have 'bridge' at the beginning.
 | 
| 106 | - * We split the text by \r\n, we trim the lines, remove the bridge prefix and
 | |
| 107 | - * filter out any remaiing empty item.
 | |
| 107 | + * We split the text by \r\n, we trim the lines, remove the bridge prefix.
 | |
| 108 | 108 |   *
 | 
| 109 | - * @param {string} aBridgeStrings The text with the lines
 | |
| 109 | + * @param {string} bridgeLines The text with the lines
 | |
| 110 | 110 |   * @returns {string[]} An array where each bridge line is an item
 | 
| 111 | 111 |   */
 | 
| 112 | -function parseBridgeStrings(aBridgeStrings) {
 | |
| 113 | -  // replace carriage returns ('\r') with new lines ('\n')
 | |
| 114 | -  aBridgeStrings = aBridgeStrings.replace(/\r/g, "\n");
 | |
| 115 | -  // then replace contiguous new lines ('\n') with a single one
 | |
| 116 | -  aBridgeStrings = aBridgeStrings.replace(/[\n]+/g, "\n");
 | |
| 117 | - | |
| 112 | +function splitBridgeLines(bridgeLines) {
 | |
| 118 | 113 |    // Split on the newline and for each bridge string: trim, remove starting
 | 
| 119 | 114 |    // 'bridge' string.
 | 
| 120 | -  // Finally, discard entries that are empty strings; empty strings could occur
 | |
| 121 | -  // if we receive a new line containing only whitespace.
 | |
| 122 | -  const splitStrings = aBridgeStrings.split("\n");
 | |
| 123 | -  return splitStrings
 | |
| 124 | -    .map(val => val.trim().replace(/^bridge\s+/i, ""))
 | |
| 125 | -    .filter(bridgeString => bridgeString !== "");
 | |
| 115 | +  // Replace whitespace with standard " ".
 | |
| 116 | +  // NOTE: We only remove the bridge string part if it is followed by a
 | |
| 117 | +  // non-whitespace.
 | |
| 118 | +  return bridgeLines.split(/\r?\n/).map(val =>
 | |
| 119 | +    val
 | |
| 120 | +      .trim()
 | |
| 121 | +      .replace(/^bridge\s+(\S)/i, "$1")
 | |
| 122 | +      .replace(/\s+/, " ")
 | |
| 123 | +  );
 | |
| 124 | +}
 | |
| 125 | + | |
| 126 | +/**
 | |
| 127 | + * @typedef {Object} BridgeValidationResult
 | |
| 128 | + *
 | |
| 129 | + * @property {integer[]} errorLines - The lines that contain errors. Counting
 | |
| 130 | + *   from 1.
 | |
| 131 | + * @property {boolean} empty - Whether the given string contains no bridges.
 | |
| 132 | + * @property {string[]} validBridges - The valid bridge lines found.
 | |
| 133 | + */
 | |
| 134 | +/**
 | |
| 135 | + * Validate the given bridge lines.
 | |
| 136 | + *
 | |
| 137 | + * @param {string} bridgeLines - The bridge lines to validate, separated by
 | |
| 138 | + *   newlines.
 | |
| 139 | + *
 | |
| 140 | + * @returns {BridgeValidationResult}
 | |
| 141 | + */
 | |
| 142 | +export function validateBridgeLines(bridgeLines) {
 | |
| 143 | +  let empty = true;
 | |
| 144 | +  const errorLines = [];
 | |
| 145 | +  const validBridges = [];
 | |
| 146 | +  for (const [index, bridge] of splitBridgeLines(bridgeLines).entries()) {
 | |
| 147 | +    if (!bridge) {
 | |
| 148 | +      // Empty line.
 | |
| 149 | +      continue;
 | |
| 150 | +    }
 | |
| 151 | +    empty = false;
 | |
| 152 | +    try {
 | |
| 153 | +      // TODO: Have a more comprehensive validation parser.
 | |
| 154 | +      lazy.TorParsers.parseBridgeLine(bridge);
 | |
| 155 | +    } catch {
 | |
| 156 | +      errorLines.push(index + 1);
 | |
| 157 | +      continue;
 | |
| 158 | +    }
 | |
| 159 | +    validBridges.push(bridge);
 | |
| 160 | +  }
 | |
| 161 | +  return { empty, errorLines, validBridges };
 | |
| 126 | 162 |  }
 | 
| 127 | 163 | |
| 128 | 164 |  /**
 | 
| ... | ... | @@ -269,7 +305,8 @@ class TorSettingsImpl { | 
| 269 | 305 |            if (Array.isArray(val)) {
 | 
| 270 | 306 |              return [...val];
 | 
| 271 | 307 |            }
 | 
| 272 | -          return parseBridgeStrings(val);
 | |
| 308 | +          // Split the bridge strings, discarding empty.
 | |
| 309 | +          return splitBridgeLines(val).filter(val => val);
 | |
| 273 | 310 |          },
 | 
| 274 | 311 |          copy: val => [...val],
 | 
| 275 | 312 |          equal: (val1, val2) => this.#arrayEqual(val1, val2),
 | 
| ... | ... | @@ -139,10 +139,6 @@ const Loader = { | 
| 139 | 139 |        solveTheCaptcha: "Solve the CAPTCHA to request a bridge.",
 | 
| 140 | 140 |        captchaTextboxPlaceholder: "Enter the characters from the image",
 | 
| 141 | 141 |        incorrectCaptcha: "The solution is not correct. Please try again.",
 | 
| 142 | -      // Provide bridge dialog
 | |
| 143 | -      provideBridgeDescription:
 | |
| 144 | -        "Add a bridge provided by a trusted organization or someone you know. If you don’t have a bridge, you can request one from the Tor Project. %S",
 | |
| 145 | -      provideBridgePlaceholder: "type address:port (one per line)",
 | |
| 146 | 142 |        // Connection settings dialog
 | 
| 147 | 143 |        connectionSettingsDialogTitle: "Connection Settings",
 | 
| 148 | 144 |        connectionSettingsDialogHeader:
 | 
| ... | ... | @@ -75,10 +75,6 @@ settings.solveTheCaptcha=Solve the CAPTCHA to request a bridge. | 
| 75 | 75 |  settings.captchaTextboxPlaceholder=Enter the characters from the image
 | 
| 76 | 76 |  settings.incorrectCaptcha=The solution is not correct. Please try again.
 | 
| 77 | 77 | |
| 78 | -# Translation note: %S is a Learn more link.
 | |
| 79 | -settings.provideBridgeDescription=Add a bridge provided by a trusted organization or someone you know. If you don’t have a bridge, you can request one from the Tor Project. %S
 | |
| 80 | -settings.provideBridgePlaceholder=type address:port (one per line)
 | |
| 81 | - | |
| 82 | 78 |  # Connection settings dialog
 | 
| 83 | 79 |  settings.connectionSettingsDialogTitle=Connection Settings
 | 
| 84 | 80 |  settings.connectionSettingsDialogHeader=Configure how Tor Browser connects to the Internet
 | 
| ... | ... | @@ -126,3 +122,6 @@ settings.bridgeAddManually=Add a Bridge Manually… | 
| 126 | 122 | |
| 127 | 123 |  # Provide bridge dialog
 | 
| 128 | 124 |  settings.provideBridgeTitleAdd=Add a Bridge Manually
 | 
| 125 | +# Translation note: %S is a Learn more link.
 | |
| 126 | +settings.provideBridgeDescription=Add a bridge provided by a trusted organization or someone you know. If you don’t have a bridge, you can request one from the Tor Project. %S
 | |
| 127 | +settings.provideBridgePlaceholder=type address:port (one per line) |