[tor-commits] [Git][tpo/applications/tor-browser][tor-browser-115.7.0esr-13.5-1] 4 commits: fixup! Bug 31286: Implementation of bridge, proxy, and firewall settings in...

richard (@richard) git at gitlab.torproject.org
Mon Jan 29 18:34:58 UTC 2024



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
fixup! Bug 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection

Bug 41913: Add basic validation to user provided bridge dialog, and a
confirmation step.

- - - - -
6fe742ee by Henry Wilkes at 2024-01-29T18:34:30+00:00
fixup! Tor Browser strings

Bug 41913: Add strings for user provided bridge dialog.

- - - - -
7abc2cc1 by Henry Wilkes at 2024-01-29T18:34:30+00:00
fixup! Bug 40597: Implement TorSettings module

Bug 41913: Add public validateBridgeLines method.

- - - - -
fd4565d1 by Henry Wilkes at 2024-01-29T18:34:30+00:00
fixup! Add TorStrings module for localization

Bug 41913: Remove old strings for user provided bridge addresses.

- - - - -


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:

=====================================
browser/components/preferences/preferences.xhtml
=====================================
@@ -70,6 +70,7 @@
   <script type="module" src="chrome://global/content/elements/moz-support-link.mjs"/>
   <script src="chrome://browser/content/migration/migration-wizard.mjs" type="module"></script>
   <script type="module" src="chrome://global/content/elements/moz-toggle.mjs"/>
+  <script src="chrome://browser/content/torpreferences/bridgemoji/BridgeEmoji.js"/>
 </head>
 
 <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"


=====================================
browser/components/torpreferences/content/bridgemoji/BridgeEmoji.js
=====================================
@@ -0,0 +1,199 @@
+"use strict";
+
+{
+  /**
+   * Element to display a single bridge emoji, with a localized name.
+   */
+  class BridgeEmoji extends HTMLElement {
+    static #activeInstances = new Set();
+    static #observer(subject, topic, data) {
+      if (topic === "intl:app-locales-changed") {
+        BridgeEmoji.#updateEmojiLangCode();
+      }
+    }
+
+    static #addActiveInstance(inst) {
+      if (this.#activeInstances.size === 0) {
+        Services.obs.addObserver(this.#observer, "intl:app-locales-changed");
+        this.#updateEmojiLangCode();
+      }
+      this.#activeInstances.add(inst);
+    }
+
+    static #removeActiveInstance(inst) {
+      this.#activeInstances.delete(inst);
+      if (this.#activeInstances.size === 0) {
+        Services.obs.removeObserver(this.#observer, "intl:app-locales-changed");
+      }
+    }
+
+    /**
+     * The language code for emoji annotations.
+     *
+     * null if unset.
+     *
+     * @type {string?}
+     */
+    static #emojiLangCode = null;
+    /**
+     * A promise that resolves to two JSON structures for bridge-emojis.json and
+     * annotations.json, respectively.
+     *
+     * @type {Promise}
+     */
+    static #emojiPromise = Promise.all([
+      fetch(
+        "chrome://browser/content/torpreferences/bridgemoji/bridge-emojis.json"
+      ).then(response => response.json()),
+      fetch(
+        "chrome://browser/content/torpreferences/bridgemoji/annotations.json"
+      ).then(response => response.json()),
+    ]);
+
+    static #unknownStringPromise = null;
+
+    /**
+     * Update #emojiLangCode.
+     */
+    static async #updateEmojiLangCode() {
+      let langCode;
+      const emojiAnnotations = (await BridgeEmoji.#emojiPromise)[1];
+      // Find the first desired locale we have annotations for.
+      // Add "en" as a fallback.
+      for (const bcp47 of [...Services.locale.appLocalesAsBCP47, "en"]) {
+        langCode = bcp47;
+        if (langCode in emojiAnnotations) {
+          break;
+        }
+        // Remove everything after the dash, if there is one.
+        langCode = bcp47.replace(/-.*/, "");
+        if (langCode in emojiAnnotations) {
+          break;
+        }
+      }
+      if (langCode !== this.#emojiLangCode) {
+        this.#emojiLangCode = langCode;
+        this.#unknownStringPromise = document.l10n.formatValue(
+          "tor-bridges-emoji-unknown"
+        );
+        for (const inst of this.#activeInstances) {
+          inst.update();
+        }
+      }
+    }
+
+    /**
+     * Update the bridge emoji to show their corresponding emoji with an
+     * annotation that matches the current locale.
+     */
+    async update() {
+      if (!this.#active) {
+        return;
+      }
+
+      if (!BridgeEmoji.#emojiLangCode) {
+        // No lang code yet, wait until it is updated.
+        return;
+      }
+
+      const doc = this.ownerDocument;
+      const [unknownString, [emojiList, emojiAnnotations]] = await Promise.all([
+        BridgeEmoji.#unknownStringPromise,
+        BridgeEmoji.#emojiPromise,
+      ]);
+
+      const emoji = emojiList[this.#index];
+      let emojiName;
+      if (!emoji) {
+        // Unexpected.
+        this.#img.removeAttribute("src");
+      } else {
+        const cp = emoji.codePointAt(0).toString(16);
+        this.#img.setAttribute(
+          "src",
+          `chrome://browser/content/torpreferences/bridgemoji/svgs/${cp}.svg`
+        );
+        emojiName = emojiAnnotations[BridgeEmoji.#emojiLangCode][cp];
+      }
+      if (!emojiName) {
+        doc.defaultView.console.error(`No emoji for index ${this.#index}`);
+        emojiName = unknownString;
+      }
+      doc.l10n.setAttributes(this, "tor-bridges-emoji-cell", {
+        emojiName,
+      });
+    }
+
+    /**
+     * The index for this bridge emoji.
+     *
+     * @type {integer?}
+     */
+    #index = null;
+    /**
+     * Whether we are active (i.e. in the DOM).
+     *
+     * @type {boolean}
+     */
+    #active = false;
+    /**
+     * The image element.
+     *
+     * @type {HTMLImgElement?}
+     */
+    #img = null;
+
+    constructor(index) {
+      super();
+      this.#index = index;
+    }
+
+    connectedCallback() {
+      if (!this.#img) {
+        this.#img = this.ownerDocument.createElement("img");
+        this.#img.classList.add("tor-bridges-emoji-icon");
+        this.#img.setAttribute("alt", "");
+        this.appendChild(this.#img);
+      }
+
+      this.#active = true;
+      BridgeEmoji.#addActiveInstance(this);
+      this.update();
+    }
+
+    disconnectedCallback() {
+      this.#active = false;
+      BridgeEmoji.#removeActiveInstance(this);
+    }
+
+    /**
+     * Create four bridge emojis for the given address.
+     *
+     * @param {string} bridgeLine - The bridge address.
+     *
+     * @returns {BridgeEmoji[4]} - The bridge emoji elements.
+     */
+    static createForAddress(bridgeLine) {
+      // JS uses UTF-16. While most of these emojis are surrogate pairs, a few
+      // ones fit one UTF-16 character. So we could not use neither indices,
+      // nor substr, nor some function to split the string.
+      // FNV-1a implementation that is compatible with other languages
+      const prime = 0x01000193;
+      const offset = 0x811c9dc5;
+      let hash = offset;
+      const encoder = new TextEncoder();
+      for (const byte of encoder.encode(bridgeLine)) {
+        hash = Math.imul(hash ^ byte, prime);
+      }
+
+      return [
+        ((hash & 0x7f000000) >> 24) | (hash < 0 ? 0x80 : 0),
+        (hash & 0x00ff0000) >> 16,
+        (hash & 0x0000ff00) >> 8,
+        hash & 0x000000ff,
+      ].map(index => new BridgeEmoji(index));
+    }
+  }
+
+  customElements.define("tor-bridge-emoji", BridgeEmoji);
+}


=====================================
browser/components/torpreferences/content/connectionPane.js
=====================================
@@ -299,12 +299,10 @@ const gBridgeGrid = {
 
     this._active = true;
 
-    Services.obs.addObserver(this, "intl:app-locales-changed");
     Services.obs.addObserver(this, TorProviderTopics.BridgeChanged);
 
     this._grid.classList.add("grid-active");
 
-    this._updateEmojiLangCode();
     this._updateConnectedBridge();
   },
 
@@ -322,7 +320,6 @@ const gBridgeGrid = {
 
     this._grid.classList.remove("grid-active");
 
-    Services.obs.removeObserver(this, "intl:app-locales-changed");
     Services.obs.removeObserver(this, TorProviderTopics.BridgeChanged);
   },
 
@@ -337,9 +334,6 @@ const gBridgeGrid = {
           this._updateRows();
         }
         break;
-      case "intl:app-locales-changed":
-        this._updateEmojiLangCode();
-        break;
       case TorProviderTopics.BridgeChanged:
         this._updateConnectedBridge();
         break;
@@ -573,97 +567,6 @@ const gBridgeGrid = {
     }
   },
 
-  /**
-   * The language code for emoji annotations.
-   *
-   * null if unset.
-   *
-   * @type {string?}
-   */
-  _emojiLangCode: null,
-  /**
-   * A promise that resolves to two JSON structures for bridge-emojis.json and
-   * annotations.json, respectively.
-   *
-   * @type {Promise}
-   */
-  _emojiPromise: Promise.all([
-    fetch(
-      "chrome://browser/content/torpreferences/bridgemoji/bridge-emojis.json"
-    ).then(response => response.json()),
-    fetch(
-      "chrome://browser/content/torpreferences/bridgemoji/annotations.json"
-    ).then(response => response.json()),
-  ]),
-
-  /**
-   * Update _emojiLangCode.
-   */
-  async _updateEmojiLangCode() {
-    let langCode;
-    const emojiAnnotations = (await this._emojiPromise)[1];
-    // Find the first desired locale we have annotations for.
-    // Add "en" as a fallback.
-    for (const bcp47 of [...Services.locale.appLocalesAsBCP47, "en"]) {
-      langCode = bcp47;
-      if (langCode in emojiAnnotations) {
-        break;
-      }
-      // Remove everything after the dash, if there is one.
-      langCode = bcp47.replace(/-.*/, "");
-      if (langCode in emojiAnnotations) {
-        break;
-      }
-    }
-    if (langCode !== this._emojiLangCode) {
-      this._emojiLangCode = langCode;
-      for (const row of this._rows) {
-        this._updateRowEmojis(row);
-      }
-    }
-  },
-
-  /**
-   * Update the bridge emojis to show their corresponding emoji with an
-   * annotation that matches the current locale.
-   *
-   * @param {BridgeGridRow} row - The row to update the emojis of.
-   */
-  async _updateRowEmojis(row) {
-    if (!this._emojiLangCode) {
-      // No lang code yet, wait until it is updated.
-      return;
-    }
-
-    const [emojiList, emojiAnnotations] = await this._emojiPromise;
-    const unknownString = await document.l10n.formatValue(
-      "tor-bridges-emoji-unknown"
-    );
-
-    for (const { cell, img, index } of row.emojis) {
-      const emoji = emojiList[index];
-      let emojiName;
-      if (!emoji) {
-        // Unexpected.
-        img.removeAttribute("src");
-      } else {
-        const cp = emoji.codePointAt(0).toString(16);
-        img.setAttribute(
-          "src",
-          `chrome://browser/content/torpreferences/bridgemoji/svgs/${cp}.svg`
-        );
-        emojiName = emojiAnnotations[this._emojiLangCode][cp];
-      }
-      if (!emojiName) {
-        console.error(`No emoji for index ${index}`);
-        emojiName = unknownString;
-      }
-      document.l10n.setAttributes(cell, "tor-bridges-emoji-cell", {
-        emojiName,
-      });
-    }
-  },
-
   /**
    * Create a new row for the grid.
    *
@@ -688,23 +591,14 @@ const gBridgeGrid = {
     };
 
     const emojiBlock = row.element.querySelector(".tor-bridges-emojis-block");
-    row.emojis = makeBridgeId(bridgeLine).map(index => {
-      const cell = document.createElement("span");
-      // Each emoji is its own cell, we rely on the fact that makeBridgeId
-      // always returns four indices.
+    const BridgeEmoji = customElements.get("tor-bridge-emoji");
+    for (const cell of BridgeEmoji.createForAddress(bridgeLine)) {
+      // Each emoji is its own cell, we rely on the fact that createForAddress
+      // always returns four elements.
       cell.setAttribute("role", "gridcell");
       cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell");
-
-      const img = document.createElement("img");
-      img.classList.add("tor-bridges-emoji-icon");
-      // Accessible name will be set on the cell itself.
-      img.setAttribute("alt", "");
-
-      cell.appendChild(img);
-      emojiBlock.appendChild(cell);
-      // Image and text is set in _updateRowEmojis.
-      return { cell, img, index };
-    });
+      emojiBlock.append(cell);
+    }
 
     for (const [columnIndex, element] of row.element
       .querySelectorAll(".tor-bridges-grid-cell")
@@ -735,7 +629,6 @@ const gBridgeGrid = {
     this._initRowMenu(row);
 
     this._updateRowStatus(row);
-    this._updateRowEmojis(row);
     return row;
   },
 
@@ -1870,13 +1763,13 @@ const gBridgeSettings = {
       "chrome://browser/content/torpreferences/provideBridgeDialog.xhtml",
       { mode },
       result => {
-        if (!result.bridgeStrings) {
+        if (!result.bridges?.length) {
           return null;
         }
         return setTorSettings(() => {
           TorSettings.bridges.enabled = true;
           TorSettings.bridges.source = TorBridgeSource.UserProvided;
-          TorSettings.bridges.bridge_strings = result.bridgeStrings;
+          TorSettings.bridges.bridge_strings = result.bridges;
         });
       }
     );
@@ -2292,32 +2185,3 @@ const gConnectionPane = (function () {
   };
   return retval;
 })(); /* gConnectionPane */
-
-/**
- * Convert the given bridgeString into an array of emoji indices between 0 and
- * 255.
- *
- * @param {string} bridgeString - The bridge string.
- *
- * @returns {integer[]} - A list of emoji indices between 0 and 255.
- */
-function makeBridgeId(bridgeString) {
-  // JS uses UTF-16. While most of these emojis are surrogate pairs, a few
-  // ones fit one UTF-16 character. So we could not use neither indices,
-  // nor substr, nor some function to split the string.
-  // FNV-1a implementation that is compatible with other languages
-  const prime = 0x01000193;
-  const offset = 0x811c9dc5;
-  let hash = offset;
-  const encoder = new TextEncoder();
-  for (const byte of encoder.encode(bridgeString)) {
-    hash = Math.imul(hash ^ byte, prime);
-  }
-
-  return [
-    ((hash & 0x7f000000) >> 24) | (hash < 0 ? 0x80 : 0),
-    (hash & 0x00ff0000) >> 16,
-    (hash & 0x0000ff00) >> 8,
-    hash & 0x000000ff,
-  ];
-}


=====================================
browser/components/torpreferences/content/connectionPane.xhtml
=====================================
@@ -218,6 +218,7 @@
       </html:div>
       <html:div
         id="tor-bridges-grid-display"
+        class="tor-bridges-grid"
         role="grid"
         aria-labelledby="tor-bridges-current-heading"
       ></html:div>


=====================================
browser/components/torpreferences/content/provideBridgeDialog.js
=====================================
@@ -4,14 +4,17 @@ const { TorStrings } = ChromeUtils.importESModule(
   "resource://gre/modules/TorStrings.sys.mjs"
 );
 
-const { TorSettings, TorBridgeSource } = ChromeUtils.importESModule(
-  "resource://gre/modules/TorSettings.sys.mjs"
-);
+const { TorSettings, TorBridgeSource, validateBridgeLines } =
+  ChromeUtils.importESModule("resource://gre/modules/TorSettings.sys.mjs");
 
 const { TorConnect, TorConnectTopics } = ChromeUtils.importESModule(
   "resource://gre/modules/TorConnect.sys.mjs"
 );
 
+const { TorParsers } = ChromeUtils.importESModule(
+  "resource://gre/modules/TorParsers.sys.mjs"
+);
+
 const gProvideBridgeDialog = {
   init() {
     this._result = window.arguments[0];
@@ -33,72 +36,264 @@ const gProvideBridgeDialog = {
 
     document.l10n.setAttributes(document.documentElement, titleId);
 
-    const learnMore = document.createXULElement("label");
-    learnMore.className = "learnMore text-link";
-    learnMore.setAttribute("is", "text-link");
-    learnMore.setAttribute("value", TorStrings.settings.learnMore);
-    learnMore.addEventListener("click", () => {
-      window.top.openTrustedLinkIn(
-        TorStrings.settings.learnMoreBridgesURL,
-        "tab"
-      );
-    });
-
-    const pieces = TorStrings.settings.provideBridgeDescription.split("%S");
-    document
-      .getElementById("torPreferences-provideBridge-description")
-      .replaceChildren(pieces[0], learnMore, pieces[1] || "");
+    document.l10n.setAttributes(
+      document.getElementById("user-provide-bridge-textarea-label"),
+      // TODO change string when we can also accept Lox share codes.
+      "user-provide-bridge-dialog-textarea-addresses-label"
+    );
 
-    this._textarea = document.getElementById(
-      "torPreferences-provideBridge-textarea"
+    this._dialog = document.getElementById("user-provide-bridge-dialog");
+    this._acceptButton = this._dialog.getButton("accept");
+    this._textarea = document.getElementById("user-provide-bridge-textarea");
+    this._errorEl = document.getElementById(
+      "user-provide-bridge-error-message"
+    );
+    this._resultDescription = document.getElementById(
+      "user-provide-result-description"
+    );
+    this._bridgeGrid = document.getElementById(
+      "user-provide-bridge-grid-display"
     );
-    this._textarea.setAttribute(
-      "placeholder",
-      TorStrings.settings.provideBridgePlaceholder
+    this._rowTemplate = document.getElementById(
+      "user-provide-bridge-row-template"
     );
 
-    this._textarea.addEventListener("input", () => this.onValueChange());
-    if (TorSettings.bridges.source == TorBridgeSource.UserProvided) {
-      this._textarea.value = TorSettings.bridges.bridge_strings.join("\n");
+    if (mode === "edit") {
+      // Only expected if the bridge source is UseProvided, but verify to be
+      // sure.
+      if (TorSettings.bridges.source == TorBridgeSource.UserProvided) {
+        this._textarea.value = TorSettings.bridges.bridge_strings.join("\n");
+      }
+    } else {
+      // Set placeholder if not editing.
+      document.l10n.setAttributes(
+        this._textarea,
+        // TODO: change string when we can also accept Lox share codes.
+        "user-provide-bridge-dialog-textarea-addresses"
+      );
     }
 
-    const dialog = document.getElementById(
-      "torPreferences-provideBridge-dialog"
-    );
-    dialog.addEventListener("dialogaccept", e => {
-      this._result.accepted = true;
-    });
+    this._textarea.addEventListener("input", () => this.onValueChange());
 
-    this._acceptButton = dialog.getButton("accept");
+    this._dialog.addEventListener("dialogaccept", event =>
+      this.onDialogAccept(event)
+    );
 
     Services.obs.addObserver(this, TorConnectTopics.StateChange);
 
-    this.onValueChange();
-    this.onAcceptStateChange();
+    this.setPage("entry");
+    this.checkValue();
   },
 
   uninit() {
     Services.obs.removeObserver(this, TorConnectTopics.StateChange);
   },
 
+  /**
+   * Set the page to display.
+   *
+   * @param {string} page - The page to show.
+   */
+  setPage(page) {
+    this._page = page;
+    this._dialog.classList.toggle("show-entry-page", page === "entry");
+    this._dialog.classList.toggle("show-result-page", page === "result");
+    if (page === "entry") {
+      this._textarea.focus();
+    } else {
+      // Move focus to the <xul:window> element.
+      // In particular, we do not want to keep the focus on the (same) accept
+      // button (with now different text).
+      document.documentElement.focus();
+    }
+
+    this.updateAcceptDisabled();
+    this.onAcceptStateChange();
+  },
+
+  /**
+   * Callback for whenever the input value changes.
+   */
   onValueChange() {
-    // TODO: Do some proper value parsing and error reporting. See
-    // tor-browser#40552.
-    const value = this._textarea.value.trim();
-    this._acceptButton.disabled = !value;
-    this._result.bridgeStrings = value;
+    this.updateAcceptDisabled();
+    // Reset errors whenever the value changes.
+    this.updateError(null);
   },
 
+  /**
+   * Callback for whenever the accept button may need to change.
+   */
   onAcceptStateChange() {
-    const connect = TorConnect.canBeginBootstrap;
-    this._result.connect = connect;
-
-    this._acceptButton.setAttribute(
-      "label",
-      connect
-        ? TorStrings.settings.bridgeButtonConnect
-        : TorStrings.settings.bridgeButtonAccept
+    if (this._page === "entry") {
+      document.l10n.setAttributes(
+        this._acceptButton,
+        "user-provide-bridge-dialog-next-button"
+      );
+      this._result.connect = false;
+    } else {
+      this._acceptButton.removeAttribute("data-l10n-id");
+      const connect = TorConnect.canBeginBootstrap;
+      this._result.connect = connect;
+
+      this._acceptButton.setAttribute(
+        "label",
+        connect
+          ? TorStrings.settings.bridgeButtonConnect
+          : TorStrings.settings.bridgeButtonAccept
+      );
+    }
+  },
+
+  /**
+   * Callback for whenever the accept button's might need to be disabled.
+   */
+  updateAcceptDisabled() {
+    this._acceptButton.disabled =
+      this._page === "entry" && validateBridgeLines(this._textarea.value).empty;
+  },
+
+  /**
+   * Callback for when the accept button is pressed.
+   *
+   * @param {Event} event - The dialogaccept event.
+   */
+  onDialogAccept(event) {
+    if (this._page === "result") {
+      this._result.accepted = true;
+      // Continue to close the dialog.
+      return;
+    }
+    // Prevent closing the dialog.
+    event.preventDefault();
+
+    const bridges = this.checkValue();
+    if (!bridges.length) {
+      // Not valid
+      return;
+    }
+    this._result.bridges = bridges;
+    this.updateResult();
+    this.setPage("result");
+  },
+
+  /**
+   * The current timeout for updating the error.
+   *
+   * @type {integer?}
+   */
+  _updateErrorTimeout: null,
+
+  /**
+   * Update the displayed error.
+   *
+   * @param {object?} error - The error to show, or null if no error should be
+   *   shown. Should include the "type" property.
+   */
+  updateError(error) {
+    // First clear the existing error.
+    if (this._updateErrorTimeout !== null) {
+      clearTimeout(this._updateErrorTimeout);
+    }
+    this._updateErrorTimeout = null;
+    this._errorEl.removeAttribute("data-l10n-id");
+    this._errorEl.textContent = "";
+    if (error) {
+      this._textarea.setAttribute("aria-invalid", "true");
+    } else {
+      this._textarea.removeAttribute("aria-invalid");
+    }
+    this._textarea.classList.toggle("invalid-input", !!error);
+    this._errorEl.classList.toggle("show-error", !!error);
+
+    if (!error) {
+      return;
+    }
+
+    let errorId;
+    let errorArgs;
+    switch (error.type) {
+      case "invalid-address":
+        errorId = "user-provide-bridge-dialog-address-error";
+        errorArgs = { line: error.line };
+        break;
+    }
+
+    // Wait a small amount of time to actually set the textContent. Otherwise
+    // the screen reader (tested with Orca) may not pick up on the change in
+    // text.
+    this._updateErrorTimeout = setTimeout(() => {
+      document.l10n.setAttributes(this._errorEl, errorId, errorArgs);
+    }, 500);
+  },
+
+  /**
+   * Check the current value in the textarea.
+   *
+   * @returns {string[]} - The bridge addresses, if the entry is valid.
+   */
+  checkValue() {
+    let bridges = [];
+    let error = null;
+    const validation = validateBridgeLines(this._textarea.value);
+    if (!validation.empty) {
+      // If empty, we just disable the button, rather than show an error.
+      if (validation.errorLines.length) {
+        // Report first error.
+        error = {
+          type: "invalid-address",
+          line: validation.errorLines[0],
+        };
+      } else {
+        bridges = validation.validBridges;
+      }
+    }
+    this.updateError(error);
+    return bridges;
+  },
+
+  /**
+   * Update the shown result on the last page.
+   */
+  updateResult() {
+    document.l10n.setAttributes(
+      this._resultDescription,
+      // TODO: Use a different id when added through Lox invite.
+      "user-provide-bridge-dialog-result-addresses"
     );
+
+    this._bridgeGrid.replaceChildren();
+
+    for (const bridgeLine of this._result.bridges) {
+      let details;
+      try {
+        details = TorParsers.parseBridgeLine(bridgeLine);
+      } catch (e) {
+        console.error(`Detected invalid bridge line: ${bridgeLine}`, e);
+      }
+
+      const rowEl = this._rowTemplate.content.children[0].cloneNode(true);
+
+      const emojiBlock = rowEl.querySelector(".tor-bridges-emojis-block");
+      const BridgeEmoji = customElements.get("tor-bridge-emoji");
+      for (const cell of BridgeEmoji.createForAddress(bridgeLine)) {
+        // Each emoji is its own cell, we rely on the fact that createForAddress
+        // always returns four elements.
+        cell.setAttribute("role", "gridcell");
+        cell.classList.add("tor-bridges-grid-cell", "tor-bridges-emoji-cell");
+        emojiBlock.append(cell);
+      }
+
+      // TODO: properly handle "vanilla" bridges?
+      document.l10n.setAttributes(
+        rowEl.querySelector(".tor-bridges-type-cell"),
+        "tor-bridges-type-prefix",
+        { type: details?.transport ?? "vanilla" }
+      );
+
+      rowEl.querySelector(".tor-bridges-address-cell").textContent = bridgeLine;
+
+      this._bridgeGrid.append(rowEl);
+    }
   },
 
   observe(subject, topic, data) {


=====================================
browser/components/torpreferences/content/provideBridgeDialog.xhtml
=====================================
@@ -8,22 +8,77 @@
   xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
   xmlns:html="http://www.w3.org/1999/xhtml"
 >
-  <dialog id="torPreferences-provideBridge-dialog" buttons="accept,cancel">
+  <dialog
+    id="user-provide-bridge-dialog"
+    buttons="accept,cancel"
+    class="show-entry-page"
+  >
     <linkset>
       <html:link rel="localization" href="browser/tor-browser.ftl" />
     </linkset>
 
+    <script src="chrome://browser/content/torpreferences/bridgemoji/BridgeEmoji.js" />
     <script src="chrome://browser/content/torpreferences/provideBridgeDialog.js" />
 
-    <description>
-      <html:div id="torPreferences-provideBridge-description"
-        >​<br />​</html:div
-      >
-    </description>
-    <html:textarea
-      id="torPreferences-provideBridge-textarea"
-      multiline="true"
-      rows="3"
-    />
+    <html:div id="user-provide-bridge-entry-page">
+      <description id="user-provide-bridge-description">
+        <html:span
+          class="tail-with-learn-more"
+          data-l10n-id="user-provide-bridge-dialog-description"
+        ></html:span>
+        <label
+          is="text-link"
+          class="learnMore text-link"
+          href="about:manual#bridges"
+          useoriginprincipal="true"
+          data-l10n-id="user-provide-bridge-dialog-learn-more"
+        />
+      </description>
+      <html:label
+        id="user-provide-bridge-textarea-label"
+        for="user-provide-bridge-textarea"
+      ></html:label>
+      <html:textarea
+        id="user-provide-bridge-textarea"
+        multiline="true"
+        rows="3"
+        aria-describedby="user-provide-bridge-description"
+        aria-errormessage="user-provide-bridge-error-message"
+      />
+      <html:div id="user-provide-bridge-message-area">
+        <html:span
+          id="user-provide-bridge-error-message"
+          aria-live="assertive"
+        ></html:span>
+      </html:div>
+    </html:div>
+    <html:div id="user-provide-bridge-result-page">
+      <description id="user-provide-result-description" />
+      <!-- NOTE: Unlike #tor-bridge-grid-display, this element is not
+         - interactive, and not a tab-stop. So we use the "table" role rather
+         - than "grid".
+         - NOTE: Using a <html:table> would not allow us the same structural
+         - freedom, so we use a generic div and add the semantics manually. -->
+      <html:div
+        id="user-provide-bridge-grid-display"
+        class="tor-bridges-grid"
+        role="table"
+      ></html:div>
+      <html:template id="user-provide-bridge-row-template">
+        <html:div class="tor-bridges-grid-row" role="row">
+          <html:span
+            class="tor-bridges-type-cell tor-bridges-grid-cell"
+            role="gridcell"
+          ></html:span>
+          <html:span class="tor-bridges-emojis-block" role="none"></html:span>
+          <html:span class="tor-bridges-grid-end-block" role="none">
+            <html:span
+              class="tor-bridges-address-cell tor-bridges-grid-cell"
+              role="gridcell"
+            ></html:span>
+          </html:span>
+        </html:div>
+      </html:template>
+    </html:div>
   </dialog>
 </window>


=====================================
browser/components/torpreferences/content/torPreferences.css
=====================================
@@ -270,11 +270,14 @@
   grid-area: description;
 }
 
-#tor-bridges-grid-display {
+.tor-bridges-grid {
   display: grid;
   grid-template-columns: max-content repeat(4, max-content) 1fr;
   --tor-bridges-grid-column-gap: 8px;
   --tor-bridges-grid-column-short-gap: 4px;
+  /* For #tor-bridges-grid-display we want each grid item to have the same
+   * height so that their focus outlines match. */
+  align-items: stretch;
 }
 
 #tor-bridges-grid-display:not(.grid-active) {
@@ -283,11 +286,12 @@
 
 .tor-bridges-grid-row {
   /* We want each row to act as a row of three items in the
-   * #tor-bridges-grid-display grid layout.
+   * .tor-bridges-grid grid layout.
    * We also want a 16px spacing between rows, and 8px spacing between columns,
-   * which are outside the .tor-bridges-grid-cell's border area. So that
-   * clicking these gaps will not focus any item, and their focus outlines do
-   * not overlap.
+   * which are outside the .tor-bridges-grid-cell's border area.
+   *
+   * For #tor-bridges-grid-display this should ensure that clicking these gaps
+   * will not focus any item, and their focus outlines do not overlap.
    * Moreover, we also want each row to show its .tor-bridges-options-cell when
    * the .tor-bridges-grid-row has :hover.
    *
@@ -311,7 +315,8 @@
   padding-block: 8px;
 }
 
-.tor-bridges-grid-cell:focus-visible {
+#tor-bridges-grid-display .tor-bridges-grid-cell:focus-visible {
+  /* #tor-bridges-grid-display has focus management for its cells. */
   outline: var(--in-content-focus-outline);
   outline-offset: var(--in-content-focus-outline-offset);
 }
@@ -662,8 +667,77 @@ groupbox#torPreferences-bridges-group textarea {
 }
 
 /* Provide bridge dialog */
-#torPreferences-provideBridge-textarea {
-  margin-top: 16px;
+
+#user-provide-bridge-dialog:not(.show-entry-page) #user-provide-bridge-entry-page {
+  display: none;
+}
+
+#user-provide-bridge-dialog:not(.show-result-page) #user-provide-bridge-result-page {
+  display: none;
+}
+
+#user-provide-bridge-entry-page {
+  flex: 1 0 auto;
+  display: flex;
+  flex-direction: column;
+}
+
+#user-provide-bridge-description {
+  flex: 0 0 auto;
+}
+
+#user-provide-bridge-textarea-label {
+  margin-block: 16px 6px;
+  flex: 0 0 auto;
+  align-self: start;
+}
+
+#user-provide-bridge-textarea {
+  flex: 1 0 auto;
+  align-self: stretch;
+  line-height: 1.3;
+  margin: 0;
+}
+
+#user-provide-bridge-message-area {
+  flex: 0 0 auto;
+  margin-block: 8px 12px;
+  align-self: end;
+}
+
+#user-provide-bridge-message-area::after {
+  /* Zero width space, to ensure we are always one line high. */
+  content: "\200B";
+}
+
+#user-provide-bridge-textarea.invalid-input {
+  border-color: var(--in-content-danger-button-background);
+  outline-color: var(--in-content-danger-button-background);
+}
+
+#user-provide-bridge-error-message {
+  color: var(--in-content-error-text-color);
+}
+
+#user-provide-bridge-error-message.not(.show-error) {
+  display: none;
+}
+
+#user-provide-bridge-result-page {
+  flex: 1 1 0;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+#user-provide-result-description {
+  flex: 0 0 auto;
+}
+
+#user-provide-bridge-grid-display {
+  flex: 0 1 auto;
+  overflow: auto;
+  margin-block: 8px;
 }
 
 /* Connection settings dialog */


=====================================
browser/components/torpreferences/jar.mn
=====================================
@@ -22,6 +22,7 @@ browser.jar:
     content/browser/torpreferences/connectionPane.xhtml              (content/connectionPane.xhtml)
     content/browser/torpreferences/torPreferences.css                (content/torPreferences.css)
     content/browser/torpreferences/bridge-qr-onion-mask.svg          (content/bridge-qr-onion-mask.svg)
+    content/browser/torpreferences/bridgemoji/BridgeEmoji.js         (content/bridgemoji/BridgeEmoji.js)
     content/browser/torpreferences/bridgemoji/bridge-emojis.json     (content/bridgemoji/bridge-emojis.json)
     content/browser/torpreferences/bridgemoji/annotations.json       (content/bridgemoji/annotations.json)
     content/browser/torpreferences/bridgemoji/svgs/                  (content/bridgemoji/svgs/*.svg)


=====================================
browser/locales/en-US/browser/tor-browser.ftl
=====================================
@@ -176,3 +176,19 @@ user-provide-bridge-dialog-add-title =
 # Used when the user is replacing their existing bridges with new ones.
 user-provide-bridge-dialog-replace-title =
     .title = Replace your bridges
+# Description shown when adding new bridges, replacing existing bridges, or editing existing bridges.
+user-provide-bridge-dialog-description = Use bridges provided by a trusted organisation or someone you know.
+# "Learn more" link shown in the "Add new bridges"/"Replace your bridges" dialog.
+user-provide-bridge-dialog-learn-more = Learn more
+# Short accessible name for the bridge addresses text area.
+user-provide-bridge-dialog-textarea-addresses-label = Bridge addresses
+# Placeholder shown when adding new bridge addresses.
+user-provide-bridge-dialog-textarea-addresses =
+    .placeholder = Paste your bridge addresses here
+# Error shown when one of the address lines is invalid.
+# $line (Number) - The line number for the invalid address.
+user-provide-bridge-dialog-address-error = Incorrectly formatted bridge address on line { $line }.
+
+user-provide-bridge-dialog-result-addresses = The following bridges were entered by you.
+user-provide-bridge-dialog-next-button =
+    .label = Next


=====================================
toolkit/modules/TorSettings.sys.mjs
=====================================
@@ -9,6 +9,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
   TorProviderBuilder: "resource://gre/modules/TorProviderBuilder.sys.mjs",
   TorProviderTopics: "resource://gre/modules/TorProviderBuilder.sys.mjs",
   Lox: "resource://gre/modules/Lox.sys.mjs",
+  TorParsers: "resource://gre/modules/TorParsers.sys.mjs",
 });
 
 ChromeUtils.defineLazyGetter(lazy, "logger", () => {
@@ -103,26 +104,61 @@ export const TorProxyType = Object.freeze({
  * Split a blob of bridge lines into an array with single lines.
  * Lines are delimited by \r\n or \n and each bridge string can also optionally
  * have 'bridge' at the beginning.
- * We split the text by \r\n, we trim the lines, remove the bridge prefix and
- * filter out any remaiing empty item.
+ * We split the text by \r\n, we trim the lines, remove the bridge prefix.
  *
- * @param {string} aBridgeStrings The text with the lines
+ * @param {string} bridgeLines The text with the lines
  * @returns {string[]} An array where each bridge line is an item
  */
-function parseBridgeStrings(aBridgeStrings) {
-  // replace carriage returns ('\r') with new lines ('\n')
-  aBridgeStrings = aBridgeStrings.replace(/\r/g, "\n");
-  // then replace contiguous new lines ('\n') with a single one
-  aBridgeStrings = aBridgeStrings.replace(/[\n]+/g, "\n");
-
+function splitBridgeLines(bridgeLines) {
   // Split on the newline and for each bridge string: trim, remove starting
   // 'bridge' string.
-  // Finally, discard entries that are empty strings; empty strings could occur
-  // if we receive a new line containing only whitespace.
-  const splitStrings = aBridgeStrings.split("\n");
-  return splitStrings
-    .map(val => val.trim().replace(/^bridge\s+/i, ""))
-    .filter(bridgeString => bridgeString !== "");
+  // Replace whitespace with standard " ".
+  // NOTE: We only remove the bridge string part if it is followed by a
+  // non-whitespace.
+  return bridgeLines.split(/\r?\n/).map(val =>
+    val
+      .trim()
+      .replace(/^bridge\s+(\S)/i, "$1")
+      .replace(/\s+/, " ")
+  );
+}
+
+/**
+ * @typedef {Object} BridgeValidationResult
+ *
+ * @property {integer[]} errorLines - The lines that contain errors. Counting
+ *   from 1.
+ * @property {boolean} empty - Whether the given string contains no bridges.
+ * @property {string[]} validBridges - The valid bridge lines found.
+ */
+/**
+ * Validate the given bridge lines.
+ *
+ * @param {string} bridgeLines - The bridge lines to validate, separated by
+ *   newlines.
+ *
+ * @returns {BridgeValidationResult}
+ */
+export function validateBridgeLines(bridgeLines) {
+  let empty = true;
+  const errorLines = [];
+  const validBridges = [];
+  for (const [index, bridge] of splitBridgeLines(bridgeLines).entries()) {
+    if (!bridge) {
+      // Empty line.
+      continue;
+    }
+    empty = false;
+    try {
+      // TODO: Have a more comprehensive validation parser.
+      lazy.TorParsers.parseBridgeLine(bridge);
+    } catch {
+      errorLines.push(index + 1);
+      continue;
+    }
+    validBridges.push(bridge);
+  }
+  return { empty, errorLines, validBridges };
 }
 
 /**
@@ -269,7 +305,8 @@ class TorSettingsImpl {
           if (Array.isArray(val)) {
             return [...val];
           }
-          return parseBridgeStrings(val);
+          // Split the bridge strings, discarding empty.
+          return splitBridgeLines(val).filter(val => val);
         },
         copy: val => [...val],
         equal: (val1, val2) => this.#arrayEqual(val1, val2),


=====================================
toolkit/modules/TorStrings.sys.mjs
=====================================
@@ -139,10 +139,6 @@ const Loader = {
       solveTheCaptcha: "Solve the CAPTCHA to request a bridge.",
       captchaTextboxPlaceholder: "Enter the characters from the image",
       incorrectCaptcha: "The solution is not correct. Please try again.",
-      // Provide bridge dialog
-      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",
-      provideBridgePlaceholder: "type address:port (one per line)",
       // Connection settings dialog
       connectionSettingsDialogTitle: "Connection Settings",
       connectionSettingsDialogHeader:


=====================================
toolkit/torbutton/chrome/locale/en-US/settings.properties
=====================================
@@ -75,10 +75,6 @@ settings.solveTheCaptcha=Solve the CAPTCHA to request a bridge.
 settings.captchaTextboxPlaceholder=Enter the characters from the image
 settings.incorrectCaptcha=The solution is not correct. Please try again.
 
-# Translation note: %S is a Learn more link.
-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
-settings.provideBridgePlaceholder=type address:port (one per line)
-
 # Connection settings dialog
 settings.connectionSettingsDialogTitle=Connection Settings
 settings.connectionSettingsDialogHeader=Configure how Tor Browser connects to the Internet
@@ -126,3 +122,6 @@ settings.bridgeAddManually=Add a Bridge Manually…
 
 # Provide bridge dialog
 settings.provideBridgeTitleAdd=Add a Bridge Manually
+# Translation note: %S is a Learn more link.
+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
+settings.provideBridgePlaceholder=type address:port (one per line)



View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/366c81df9da5b191a3c7459387c4a93e1853e0fc...fd4565d104572e24a4b7de10caaec144cbad962b

-- 
View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/366c81df9da5b191a3c7459387c4a93e1853e0fc...fd4565d104572e24a4b7de10caaec144cbad962b
You're receiving this email because of your account on gitlab.torproject.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.torproject.org/pipermail/tor-commits/attachments/20240129/486eefdc/attachment-0001.htm>


More information about the tor-commits mailing list