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

richard (@richard) git at gitlab.torproject.org
Wed Jan 31 09:11:37 UTC 2024



richard pushed to branch tor-browser-115.7.0esr-13.5-1 at The Tor Project / Applications / Tor Browser


Commits:
277b6464 by Henry Wilkes at 2024-01-30T15:58:12+00:00
fixup! Bug 31286: Implementation of bridge, proxy, and firewall settings in about:preferences#connection

Bug 42036: Add Lox to settings UI.

- - - - -
9209ad02 by Henry Wilkes at 2024-01-30T15:58:12+00:00
fixup! Tor Browser strings

Bug 42036: Add strings for Lox UI.

- - - - -
8069e4ee by Henry Wilkes at 2024-01-30T15:58:13+00:00
fixup! Lox integration

- - - - -


14 changed files:

- browser/components/torpreferences/content/connectionPane.js
- browser/components/torpreferences/content/connectionPane.xhtml
- + browser/components/torpreferences/content/lox-bridge-icon.svg
- + browser/components/torpreferences/content/lox-bridge-pass.svg
- + browser/components/torpreferences/content/lox-complete-ring.svg
- + browser/components/torpreferences/content/lox-invite-icon.svg
- + browser/components/torpreferences/content/lox-progress-ring.svg
- + browser/components/torpreferences/content/lox-success.svg
- 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/components/lox/Lox.sys.mjs


Changes:

=====================================
browser/components/torpreferences/content/connectionPane.js
=====================================
@@ -36,6 +36,60 @@ const { TorStrings } = ChromeUtils.importESModule(
   "resource://gre/modules/TorStrings.sys.mjs"
 );
 
+const { Lox } = ChromeUtils.importESModule(
+  "resource://gre/modules/Lox.sys.mjs"
+);
+
+/*
+ * Fake Lox module:
+
+const Lox = {
+  levelHistory: [0, 1],
+  // levelHistory: [1, 2],
+  // levelHistory: [2, 3],
+  // levelHistory: [3, 4],
+  // levelHistory: [0, 1, 2],
+  // levelHistory: [1, 2, 3],
+  // levelHistory: [4, 3],
+  // levelHistory: [4, 1],
+  // levelHistory: [2, 1],
+  //levelHistory: [2, 3, 4, 1, 2],
+  // Gain some invites and then loose them all. Shouldn't show any change.
+  // levelHistory: [0, 1, 2, 1],
+  // levelHistory: [1, 2, 3, 1],
+  getEventData() {
+    let prevLevel = this.levelHistory[0];
+    const events = [];
+    for (let i = 1; i < this.levelHistory.length; i++) {
+      const level = this.levelHistory[i];
+      events.push({ type: level > prevLevel ? "levelup" : "blockage", newLevel: level });
+      prevLevel = level;
+    }
+    return events;
+  },
+  clearEventData() {
+    this.levelHistory = [];
+  },
+  nextUnlock: { date: "2024-01-31T00:00:00Z", nextLevel: 1 },
+  //nextUnlock: { date: "2024-01-31T00:00:00Z", nextLevel: 2 },
+  //nextUnlock: { date: "2024-01-31T00:00:00Z", nextLevel: 3 },
+  //nextUnlock: { date: "2024-01-31T00:00:00Z", nextLevel: 4 },
+  getNextUnlock() {
+    return this.nextUnlock;
+  },
+  remainingInvites: 3,
+  // remainingInvites: 0,
+  getRemainingInviteCount() {
+    return this.remainingInvites;
+  },
+  invites: [],
+  // invites: ["a", "b"],
+  getInvites() {
+    return this.invites;
+  },
+};
+*/
+
 const InternetStatus = Object.freeze({
   Unknown: 0,
   Online: 1,
@@ -678,15 +732,23 @@ const gBridgeGrid = {
       row.optionsButton.focus();
     });
 
-    row.menu
-      .querySelector(".tor-bridges-options-qr-one-menu-item")
-      .addEventListener("click", () => {
-        const bridgeLine = row.bridgeLine;
-        if (!bridgeLine) {
-          return;
-        }
-        showBridgeQr(bridgeLine);
-      });
+    const qrItem = row.menu.querySelector(
+      ".tor-bridges-options-qr-one-menu-item"
+    );
+    row.menu.addEventListener("showing", () => {
+      qrItem.hidden = !(
+        this._bridgeSource === TorBridgeSource.UserProvided ||
+        this._bridgeSource === TorBridgeSource.BridgeDB
+      );
+    });
+
+    qrItem.addEventListener("click", () => {
+      const bridgeLine = row.bridgeLine;
+      if (!bridgeLine) {
+        return;
+      }
+      showBridgeQr(bridgeLine);
+    });
     row.menu
       .querySelector(".tor-bridges-options-copy-one-menu-item")
       .addEventListener("click", () => {
@@ -734,7 +796,11 @@ const gBridgeGrid = {
    *
    * @type {string[]}
    */
-  _supportedSources: [TorBridgeSource.BridgeDB, TorBridgeSource.UserProvided],
+  _supportedSources: [
+    TorBridgeSource.BridgeDB,
+    TorBridgeSource.UserProvided,
+    TorBridgeSource.Lox,
+  ],
 
   /**
    * Update the grid to show the latest bridge strings.
@@ -1169,6 +1235,335 @@ const gBuiltinBridgesArea = {
   },
 };
 
+/**
+ * Controls the bridge pass area.
+ */
+const gLoxStatus = {
+  /**
+   * The status area.
+   *
+   * @type {Element?}
+   */
+  _area: null,
+  /**
+   * The area for showing the next unlock and invites.
+   *
+   * @type {Element?}
+   */
+  _detailsArea: null,
+  /**
+   * The day counter for the next unlock.
+   *
+   * @type {Element?}
+   */
+  _nextUnlockCounterEl: null,
+  /**
+   * Shows the number of remaining invites.
+   *
+   * @type {Element?}
+   */
+  _remainingInvitesEl: null,
+  /**
+   * The button to show the invites.
+   *
+   * @type {Element?}
+   */
+  _invitesButton: null,
+  /**
+   * The alert for new unlocks.
+   *
+   * @type {Element?}
+   */
+  _unlockAlert: null,
+  /**
+   * The alert title.
+   *
+   * @type {Element?}
+   */
+  _unlockAlertTitle: null,
+  /**
+   * The alert invites item.
+   *
+   * @type {Element?}
+   */
+  _unlockAlertInvitesItem: null,
+  /**
+   * Button for the user to dismiss the alert.
+   *
+   * @type {Element?}
+   */
+  _unlockAlertButton: null,
+
+  /**
+   * Initialize the bridge pass area.
+   */
+  init() {
+    this._area = document.getElementById("tor-bridges-lox-status");
+    this._detailsArea = document.getElementById("tor-bridges-lox-details");
+    this._nextUnlockCounterEl = document.getElementById(
+      "tor-bridges-lox-next-unlock-counter"
+    );
+    this._remainingInvitesEl = document.getElementById(
+      "tor-bridges-lox-remaining-invites"
+    );
+    this._invitesButton = document.getElementById(
+      "tor-bridges-lox-show-invites-button"
+    );
+    this._unlockAlert = document.getElementById("tor-bridges-lox-unlock-alert");
+    this._unlockAlertTitle = document.getElementById(
+      "tor-bridge-unlock-alert-title"
+    );
+    this._unlockAlertInviteItem = document.getElementById(
+      "tor-bridges-lox-unlock-alert-invites"
+    );
+    this._unlockAlertButton = document.getElementById(
+      "tor-bridges-lox-unlock-alert-button"
+    );
+
+    this._invitesButton.addEventListener("click", () => {
+      // TODO: Show invites.
+    });
+    this._unlockAlertButton.addEventListener("click", () => {
+      // TODO: Have a way to ensure that the cleared event data matches the
+      // current _loxId
+      Lox.clearEventData();
+      // TODO: Listen for events from Lox, rather than call _updateUnlocks
+      // directly.
+      this._updateUnlocks();
+    });
+
+    Services.obs.addObserver(this, TorSettingsTopics.SettingsChanged);
+    // TODO: Listen for new events from Lox, when it is supported.
+
+    // NOTE: Before initializedPromise completes, this area is hidden.
+    TorSettings.initializedPromise.then(() => {
+      this._updateLoxId();
+    });
+  },
+
+  /**
+   * Uninitialize the built-in bridges area.
+   */
+  uninit() {
+    Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged);
+  },
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      case TorSettingsTopics.SettingsChanged:
+        const { changes } = subject.wrappedJSObject;
+        if (
+          changes.includes("bridges.source") ||
+          changes.includes("bridges.lox_id")
+        ) {
+          this._updateLoxId();
+        }
+        break;
+    }
+  },
+
+  /**
+   * The Lox id currently shown. Empty if deactivated, and null if
+   * uninitialized.
+   *
+   * @type {string?}
+   */
+  _loxId: null,
+
+  /**
+   * Update the shown bridge pass.
+   */
+  async _updateLoxId() {
+    let loxId =
+      TorSettings.bridges.source === TorBridgeSource.Lox
+        ? TorSettings.bridges.lox_id
+        : "";
+    if (loxId !== this._loxId) {
+      this._loxId = loxId;
+      this._updateUnlocks();
+      this._updateInvites();
+    }
+  },
+
+  /**
+   * Update the display of the current or next unlock.
+   */
+  async _updateUnlocks() {
+    // Cache the loxId before we await.
+    const loxId = this._loxId;
+
+    if (!loxId) {
+      // NOTE: This area should already be hidden by the change in Lox source,
+      // but we clean up for the next non-empty id.
+      this._area.classList.remove("show-unlock-alert");
+      this._area.classList.remove("show-next-unlock");
+      return;
+    }
+
+    let pendingEvents;
+    let nextUnlock;
+    let numInvites;
+    // Fetch the latest events or details about the next unlock.
+    try {
+      nextUnlock = await Lox.getNextUnlock();
+      pendingEvents = Lox.getEventData();
+      numInvites = Lox.getRemainingInviteCount();
+    } catch (e) {
+      console.error("Failed get get lox updates", e);
+      return;
+    }
+
+    if (loxId !== this._loxId) {
+      // Replaced during await.
+      return;
+    }
+
+    // Grab focus state before changing visibility.
+    const alertHadFocus = this._unlockAlert.contains(document.activeElement);
+    const detailsHadFocus = this._detailsArea.contains(document.activeElement);
+
+    const showAlert = !!pendingEvents.length;
+    this._area.classList.toggle("show-unlock-alert", showAlert);
+    this._area.classList.toggle("show-next-unlock", !showAlert);
+
+    if (showAlert) {
+      // At level 0 and level 1, we do not have any invites.
+      // If the user starts and ends on level 0 or 1, then overall they would
+      // have had no change in their invites. So we do not want to show their
+      // latest updates.
+      // NOTE: If the user starts at level > 1 and ends with level 1 (levelling
+      // down to level 0 should not be possible), then we *do* want to show the
+      // user that they now have "0" invites.
+      // NOTE: pendingEvents are time-ordered, with the most recent event
+      // *last*.
+      const firstEvent = pendingEvents[0];
+      // NOTE: We cannot get a blockage event when the user starts at level 1 or
+      // 0.
+      const startingAtLowLevel =
+        firstEvent.type === "levelup" && firstEvent.newLevel <= 2;
+      const lastEvent = pendingEvents[pendingEvents.length - 1];
+      const endingAtLowLevel = lastEvent.newLevel <= 1;
+
+      const showInvites = !(startingAtLowLevel && endingAtLowLevel);
+
+      let blockage = false;
+      let levelUp = false;
+      let bridgeGain = false;
+      // Go through events, in the order that they occurred.
+      for (const loxEvent of pendingEvents) {
+        if (loxEvent.type === "levelup") {
+          levelUp = true;
+          if (loxEvent.newLevel === 1) {
+            // Gain 2 bridges from level 0 to 1.
+            bridgeGain = true;
+          }
+        } else {
+          blockage = true;
+        }
+      }
+      let alertTitleId;
+      if (levelUp && !blockage) {
+        alertTitleId = "tor-bridges-lox-upgrade";
+      } else {
+        // Show as blocked bridges replaced.
+        // Even if we have a mixture of level ups as well.
+        alertTitleId = "tor-bridges-lox-blocked";
+      }
+      document.l10n.setAttributes(this._unlockAlertTitle, alertTitleId);
+      document.l10n.setAttributes(
+        this._unlockAlertInviteItem,
+        "tor-bridges-lox-new-invites",
+        { numInvites }
+      );
+      this._unlockAlert.classList.toggle(
+        "lox-unlock-upgrade",
+        levelUp && !blockage
+      );
+      this._unlockAlert.classList.toggle("lox-unlock-new-bridges", blockage);
+      this._unlockAlert.classList.toggle("lox-unlock-gain-bridges", bridgeGain);
+      this._unlockAlert.classList.toggle("lox-unlock-invites", showInvites);
+    } else {
+      // Show next unlock.
+      // Number of days until the next unlock, rounded up.
+      const numDays = Math.max(
+        1,
+        Math.ceil(
+          (new Date(nextUnlock.date).getTime() - Date.now()) /
+            (24 * 60 * 60 * 1000)
+        )
+      );
+      document.l10n.setAttributes(
+        this._nextUnlockCounterEl,
+        "tor-bridges-lox-days-until-unlock",
+        { numDays }
+      );
+
+      // Gain 2 bridges from level 0 to 1. After that gain invites.
+      const bridgeGain = nextUnlock.nextLevel === 1;
+      const firstInvites = nextUnlock.nextLevel === 2;
+      const moreInvites = nextUnlock.nextLevel > 2;
+
+      this._detailsArea.classList.toggle("lox-next-gain-bridges", bridgeGain);
+      this._detailsArea.classList.toggle(
+        "lox-next-first-invites",
+        firstInvites
+      );
+      this._detailsArea.classList.toggle("lox-next-more-invites", moreInvites);
+    }
+
+    if (alertHadFocus && !showAlert) {
+      // Has become hidden.
+      this._nextUnlockCounterEl.focus();
+    } else if (detailsHadFocus && showAlert) {
+      this._unlockAlertButton.focus();
+    }
+  },
+
+  /**
+   * Update the invites area.
+   */
+  _updateInvites() {
+    if (!this._loxId) {
+      return;
+    }
+
+    let remainingInvites;
+    let existingInvites;
+    // Fetch the latest events or details about the next unlock.
+    try {
+      remainingInvites = Lox.getRemainingInviteCount();
+      existingInvites = Lox.getInvites().length;
+    } catch (e) {
+      console.error("Failed get get remaining invites", e);
+      return;
+    }
+
+    const hasInvites = !!existingInvites || !!remainingInvites;
+
+    if (!hasInvites) {
+      if (
+        this._remainingInvitesEl.contains(document.activeElement) ||
+        this._invitesButton.contains(document.activeElement)
+      ) {
+        // About to loose focus.
+        // Unexpected for the lox level to loose all invites.
+        // Move to the top of the details area, which should be visible if we
+        // just had focus.
+        this._nextUnlockCounterEl.focus();
+      }
+    }
+    // Hide the invite elements if we have no historic invites or a way of
+    // creating new ones.
+    this._detailsArea.classList.toggle("lox-has-invites", hasInvites);
+
+    document.l10n.setAttributes(
+      this._remainingInvitesEl,
+      "tor-bridges-lox-remaining-invites",
+      { numInvites: remainingInvites }
+    );
+  },
+};
+
 /**
  * Controls the bridge settings.
  */
@@ -1295,6 +1690,7 @@ const gBridgeSettings = {
 
     gBridgeGrid.init();
     gBuiltinBridgesArea.init();
+    gLoxStatus.init();
 
     this._initBridgesMenu();
     this._initShareArea();
@@ -1315,6 +1711,7 @@ const gBridgeSettings = {
   uninit() {
     gBridgeGrid.uninit();
     gBuiltinBridgesArea.uninit();
+    gLoxStatus.uninit();
 
     Services.obs.removeObserver(this, TorSettingsTopics.SettingsChanged);
   },
@@ -1387,6 +1784,10 @@ const gBridgeSettings = {
       "source-requested",
       bridgeSource === TorBridgeSource.BridgeDB
     );
+    this._bridgesEl.classList.toggle(
+      "source-lox",
+      bridgeSource === TorBridgeSource.Lox
+    );
 
     // Force the menu to close whenever the source changes.
     // NOTE: If the menu had focus then hadFocus will be true, and focus will be
@@ -1615,7 +2016,10 @@ const gBridgeSettings = {
 
     this._bridgesMenu.addEventListener("showing", () => {
       const canCopy = this._bridgeSource !== TorBridgeSource.BuiltIn;
-      qrItem.hidden = !this._canQRBridges || !canCopy;
+      const canShare =
+        this._bridgeSource === TorBridgeSource.UserProvided ||
+        this._bridgeSource === TorBridgeSource.BridgeDB;
+      qrItem.hidden = !canShare || !this._canQRBridges;
       copyItem.hidden = !canCopy;
       editItem.hidden = this._bridgeSource !== TorBridgeSource.UserProvided;
     });
@@ -1763,13 +2167,19 @@ const gBridgeSettings = {
       "chrome://browser/content/torpreferences/provideBridgeDialog.xhtml",
       { mode },
       result => {
-        if (!result.bridges?.length) {
+        const loxId = result.loxId;
+        if (!loxId && !result.addresses?.length) {
           return null;
         }
         return setTorSettings(() => {
           TorSettings.bridges.enabled = true;
-          TorSettings.bridges.source = TorBridgeSource.UserProvided;
-          TorSettings.bridges.bridge_strings = result.bridges;
+          if (loxId) {
+            TorSettings.bridges.source = TorBridgeSource.Lox;
+            TorSettings.bridges.lox_id = loxId;
+          } else {
+            TorSettings.bridges.source = TorBridgeSource.UserProvided;
+            TorSettings.bridges.bridge_strings = result.addresses;
+          }
         });
       }
     );


=====================================
browser/components/torpreferences/content/connectionPane.xhtml
=====================================
@@ -172,6 +172,10 @@
           id="tor-bridges-requested-label"
           data-l10n-id="tor-bridges-source-requested"
         ></html:span>
+        <html:span id="tor-bridges-lox-label">
+          <html:img id="tor-bridges-lox-label-icon" alt="" />
+          <html:span data-l10n-id="tor-bridges-source-lox"></html:span>
+        </html:span>
         <html:button
           id="tor-bridges-all-options-button"
           class="tor-bridges-options-button"
@@ -275,7 +279,7 @@
           </html:span>
         </html:div>
       </html:template>
-      <html:div id="tor-bridges-share">
+      <html:div id="tor-bridges-share" class="tor-bridges-details-box">
         <html:h3
           id="tor-bridges-share-heading"
           data-l10n-id="tor-bridges-share-heading"
@@ -293,6 +297,77 @@
           data-l10n-id="tor-bridges-qr-addresses-button"
         ></html:button>
       </html:div>
+      <html:div id="tor-bridges-lox-status">
+        <html:div data-l10n-id="tor-bridges-lox-description"></html:div>
+        <html:div
+          id="tor-bridges-lox-details"
+          class="tor-bridges-details-box tor-bridges-lox-box"
+        >
+          <html:img alt="" class="tor-bridges-lox-image-inner" />
+          <html:img alt="" class="tor-bridges-lox-image-outer" />
+          <html:div
+            id="tor-bridges-lox-next-unlock-counter"
+            class="tor-bridges-lox-intro"
+            tabindex="-1"
+          ></html:div>
+          <html:ul class="tor-bridges-lox-list">
+            <html:li
+              id="tor-bridges-lox-next-unlock-gain-bridges"
+              class="tor-bridges-lox-list-item tor-bridges-lox-list-item-bridge"
+              data-l10n-id="tor-bridges-lox-unlock-two-bridges"
+            ></html:li>
+            <html:li
+              id="tor-bridges-lox-next-unlock-first-invites"
+              class="tor-bridges-lox-list-item tor-bridges-lox-list-item-invite"
+              data-l10n-id="tor-bridges-lox-unlock-first-invites"
+            ></html:li>
+            <html:li
+              id="tor-bridges-lox-next-unlock-more-invites"
+              class="tor-bridges-lox-list-item tor-bridges-lox-list-item-invite"
+              data-l10n-id="tor-bridges-lox-unlock-more-invites"
+            ></html:li>
+          </html:ul>
+          <html:div id="tor-bridges-lox-remaining-invites"></html:div>
+          <html:button
+            id="tor-bridges-lox-show-invites-button"
+            class="tor-bridges-lox-button"
+            data-l10n-id="tor-bridges-lox-show-invites-button"
+          ></html:button>
+        </html:div>
+        <html:div
+          id="tor-bridges-lox-unlock-alert"
+          role="alert"
+          class="tor-bridges-details-box tor-bridges-lox-box"
+        >
+          <html:img alt="" class="tor-bridges-lox-image-inner" />
+          <html:img alt="" class="tor-bridges-lox-image-outer" />
+          <html:div
+            id="tor-bridge-unlock-alert-title"
+            class="tor-bridges-lox-intro"
+          ></html:div>
+          <html:ul class="tor-bridges-lox-list">
+            <html:li
+              id="tor-bridges-lox-unlock-alert-gain-bridges"
+              class="tor-bridges-lox-list-item tor-bridges-lox-list-item-bridge"
+              data-l10n-id="tor-bridges-lox-gained-two-bridges"
+            ></html:li>
+            <html:li
+              id="tor-bridges-lox-unlock-alert-new-bridges"
+              class="tor-bridges-lox-list-item tor-bridges-lox-list-item-bridge"
+              data-l10n-id="tor-bridges-lox-new-bridges"
+            ></html:li>
+            <html:li
+              id="tor-bridges-lox-unlock-alert-invites"
+              class="tor-bridges-lox-list-item tor-bridges-lox-list-item-invite"
+            ></html:li>
+          </html:ul>
+          <html:button
+            id="tor-bridges-lox-unlock-alert-button"
+            class="tor-bridges-lox-button"
+            data-l10n-id="tor-bridges-lox-got-it-button"
+          ></html:button>
+        </html:div>
+      </html:div>
     </html:div>
     <html:h2 id="tor-bridges-change-heading"></html:h2>
     <hbox align="center">


=====================================
browser/components/torpreferences/content/lox-bridge-icon.svg
=====================================
@@ -0,0 +1,6 @@
+<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="0.5" y="0.5" width="17" height="17" rx="1.5" stroke="context-stroke"/>
+<path d="M14.625 11.625C14.625 8.5184 12.1066 6 9 6C5.8934 6 3.375 8.5184 3.375 11.625V12.5596H4.25391V11.625C4.25391 9.0038 6.3788 6.87891 9 6.87891C11.6212 6.87891 13.7461 9.0038 13.7461 11.625V12.5596H14.625V11.625Z" fill="context-fill"/>
+<path d="M12.9375 11.625C12.9375 9.45037 11.1746 7.6875 9 7.6875C6.82537 7.6875 5.0625 9.45037 5.0625 11.625L5.06242 12.5596H5.94132L5.9414 11.625C5.9414 9.93578 7.31078 8.5664 9 8.5664C10.6892 8.5664 12.0586 9.93578 12.0586 11.625L12.0587 12.5596H12.9376L12.9375 11.625Z" fill="context-fill"/>
+<path d="M9 9.375C10.2426 9.375 11.25 10.3824 11.25 11.625L11.2502 12.5596H10.3713L10.3711 11.625C10.3711 10.8678 9.75724 10.2539 9 10.2539C8.24277 10.2539 7.62891 10.8678 7.62891 11.625L7.62873 12.5596H6.74982V11.625C6.74982 10.3824 7.75736 9.375 9 9.375Z" fill="context-fill"/>
+</svg>


=====================================
browser/components/torpreferences/content/lox-bridge-pass.svg
=====================================
@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path fill-rule="evenodd" clip-rule="evenodd" d="M0.825958 10.1991C0.191356 9.56445 0.191356 8.53556 0.825958 7.90095L7.89702 0.829887C8.53163 0.195285 9.56052 0.195285 10.1951 0.829887L11.3198 1.95455C11.5751 2.2099 11.6412 2.59873 11.4846 2.92409C11.0031 3.9241 12.0507 4.97168 13.0507 4.4902C13.376 4.33354 13.7649 4.39962 14.0202 4.65497L15.1449 5.77963C15.7795 6.41424 15.7795 7.44313 15.1449 8.07773L8.0738 15.1488C7.4392 15.7834 6.41031 15.7834 5.7757 15.1488L4.65104 14.0241C4.39569 13.7688 4.32961 13.38 4.48627 13.0546C4.96775 12.0546 3.92017 11.007 2.92016 11.4885C2.5948 11.6451 2.20597 11.5791 1.95062 11.3237L0.825958 10.1991ZM1.70984 8.78484C1.56339 8.93128 1.56339 9.16872 1.70984 9.31517L2.64555 10.2509C4.53493 9.58573 6.38902 11.4398 5.72388 13.3292L6.65959 14.2649C6.80603 14.4114 7.04347 14.4114 7.18992 14.2649L14.261 7.19385C14.4074 7.0474 14.4074 6.80996 14.261 6.66352L13.3253 5.7278C11.4359 6.39295 9.5818 4.53886 10.247 2.64948L9.31124 1.71377C9.16479 1.56732 8.92735 1.56732 8.78091 1.71377L1.70984 8.78484Z" fill="context-fill"/>
+  <path d="M8.87637 9.78539L9.76025 8.90151L10.6441 9.78539L9.76025 10.6693L8.87637 9.78539Z" fill="context-fill"/>
+  <path d="M7.1086 8.01763L7.99249 7.13374L8.87637 8.01763L7.99249 8.90151L7.1086 8.01763Z" fill="context-fill"/>
+  <path d="M5.34084 6.24986L6.22472 5.36598L7.1086 6.24986L6.22472 7.13374L5.34084 6.24986Z" fill="context-fill"/>
+</svg>


=====================================
browser/components/torpreferences/content/lox-complete-ring.svg
=====================================
@@ -0,0 +1,12 @@
+<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
+<defs>
+  <filter id="ring-blur" x="0" y="0" width="44" height="44" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+    <feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.5 0"/>
+    <feGaussianBlur stdDeviation="2"/>
+    <feBlend mode="normal" in="SourceGraphic"/>
+  </filter>
+</defs>
+<g filter="url(#ring-blur)">
+<path d="M40 22C40 31.9411 31.9411 40 22 40C12.0589 40 4 31.9411 4 22C4 12.0589 12.0589 4 22 4C31.9411 4 40 12.0589 40 22ZM6.25 22C6.25 30.6985 13.3015 37.75 22 37.75C30.6985 37.75 37.75 30.6985 37.75 22C37.75 13.3015 30.6985 6.25 22 6.25C13.3015 6.25 6.25 13.3015 6.25 22Z" fill="context-fill"/>
+</g>
+</svg>


=====================================
browser/components/torpreferences/content/lox-invite-icon.svg
=====================================
@@ -0,0 +1,4 @@
+<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="0.5" y="0.5" width="17" height="17" rx="1.5" stroke="context-stroke"/>
+<path d="M9 9C8.45 9 7.97917 8.80417 7.5875 8.4125C7.19583 8.02083 7 7.55 7 7C7 6.45 7.19583 5.97917 7.5875 5.5875C7.97917 5.19583 8.45 5 9 5C9.55 5 10.0208 5.19583 10.4125 5.5875C10.8042 5.97917 11 6.45 11 7C11 7.55 10.8042 8.02083 10.4125 8.4125C10.0208 8.80417 9.55 9 9 9ZM5 12V11.6C5 11.3167 5.07292 11.0563 5.21875 10.8187C5.36458 10.5812 5.55833 10.4 5.8 10.275C6.31667 10.0167 6.84167 9.82292 7.375 9.69375C7.90833 9.56458 8.45 9.5 9 9.5C9.55 9.5 10.0917 9.56458 10.625 9.69375C11.1583 9.82292 11.6833 10.0167 12.2 10.275C12.4417 10.4 12.6354 10.5812 12.7812 10.8187C12.9271 11.0563 13 11.3167 13 11.6V12C13 12.275 12.9021 12.5104 12.7063 12.7063C12.5104 12.9021 12.275 13 12 13H6C5.725 13 5.48958 12.9021 5.29375 12.7063C5.09792 12.5104 5 12.275 5 12ZM6 12H12V11.6C12 11.5083 11.9771 11.425 11.9313 11.35C11.8854 11.275 11.825 11.2167 11.75 11.175C11.3 10.95 10.8458 10.7813 10.3875 10.6688C9.92917 10.5563 9.46667 10.5 9 10.5C8.53333 10.5 8.07083 10.5563 7.6125 10.6688C7.15417 10.7813 6.7 10.95 6.25 11.175C6.175 11.2167 6.11458 11.275 6.06875 11.35C6.02292 11.425 6 11.5083 6 11.6V12ZM9 8C9.275 8 9.51042 7.90208 9.70625 7.70625C9.90208 7.51042 10 7.275 10 7C10 6.725 9.90208 6.48958 9.70625 6.29375C9.51042 6.09792 9.275 6 9 6C8.725 6 8.48958 6.09792 8.29375 6.29375C8.09792 6.48958 8 6.725 8 7C8 7.275 8.09792 7.51042 8.29375 7.70625C8.48958 7.90208 8.725 8 9 8Z" fill="context-fill"/>
+</svg>


=====================================
browser/components/torpreferences/content/lox-progress-ring.svg
=====================================
@@ -0,0 +1,13 @@
+<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
+<defs>
+  <filter id="ring-blur" x="0" y="0" width="44" height="44" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+    <feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.5 0"/>
+    <feGaussianBlur stdDeviation="2"/>
+    <feBlend mode="normal" in="SourceGraphic"/>
+  </filter>
+</defs>
+<path d="M 40,22 C 40,31.9411 31.9411,40 22,40 12.05887,40 4,31.9411 4,22 4,12.0589 12.05887,4 22,4 31.9411,4 40,12.0589 40,22 Z M 6.25,22 c 0,8.6985 7.05152,15.75 15.75,15.75 8.6985,0 15.75,-7.0515 15.75,-15.75 C 37.75,13.3015 30.6985,6.25 22,6.25 13.30152,6.25 6.25,13.3015 6.25,22 Z" fill="context-stroke"/>
+<g filter="url(#ring-blur)">
+  <path d="m 22,5.125 c 0,-0.62132 0.5042,-1.12866 1.1243,-1.08986 3.0298,0.18958 5.963,1.14263 8.5256,2.77013 0.5245,0.3331 0.6342,1.03991 0.269,1.54257 C 31.5537,8.8505 30.852,8.95814 30.3246,8.62974 28.1505,7.27609 25.6786,6.47291 23.1241,6.29015 22.5044,6.24582 22,5.74632 22,5.125 Z" fill="context-fill"/>
+</g>
+</svg>


=====================================
browser/components/torpreferences/content/lox-success.svg
=====================================
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+  <path d="M10 14C9.62059 14.0061 9.25109 13.8787 8.95594 13.6403C8.6608 13.4018 8.45874 13.0672 8.385 12.695L8.028 11.028C7.97202 10.7692 7.84264 10.5321 7.65536 10.345C7.46807 10.1579 7.23081 10.0287 6.972 9.973L5.305 9.616C4.524 9.447 4 8.798 4 8C4 7.202 4.524 6.553 5.305 6.385L6.972 6.028C7.23078 5.97185 7.46794 5.84243 7.65519 5.65519C7.84243 5.46794 7.97185 5.23078 8.028 4.972L8.386 3.305C8.553 2.524 9.202 2 10 2C10.798 2 11.447 2.524 11.615 3.305L11.972 4.972C12.086 5.5 12.5 5.914 13.028 6.027L14.695 6.384C15.476 6.553 16 7.202 16 8C16 8.798 15.476 9.447 14.695 9.615L13.028 9.972C12.769 10.028 12.5316 10.1575 12.3443 10.345C12.157 10.5324 12.0277 10.7699 11.972 11.029L11.614 12.695C11.5407 13.0672 11.3388 13.4019 11.0438 13.6404C10.7488 13.8789 10.3793 14.0062 10 14ZM10 3.25C9.902 3.25 9.669 3.28 9.608 3.566L9.25 5.234C9.14333 5.72828 8.89643 6.18132 8.53888 6.53888C8.18132 6.89643 7.72828 7.14333 7.234 7.25L5.567 7.607C5.281 7.669 5.25 7.902 5.25 8C5.25 8.098 5.281 8.331 5.567 8.393L7.234 8.75C7.72828 8.85667 8.18132 9.10357 8.53888 9.46112C8.89643 9.81868 9.14333 10.2717 9.25 10.766L9.608 12.434C9.669 12.72 9.902 12.75 10 12.75C10.098 12.75 10.331 12.72 10.392 12.434L10.75 10.767C10.8565 10.2725 11.1033 9.81928 11.4609 9.46154C11.8185 9.10379 12.2716 8.85674 12.766 8.75L14.433 8.393C14.719 8.331 14.75 8.098 14.75 8C14.75 7.902 14.719 7.669 14.433 7.607L12.766 7.25C12.2717 7.14333 11.8187 6.89643 11.4611 6.53888C11.1036 6.18132 10.8567 5.72828 10.75 5.234L10.392 3.566C10.331 3.28 10.098 3.25 10 3.25Z" fill="context-fill"/>
+  <path d="M2.44399 12.151L2.62599 11.302C2.71199 10.9 3.28599 10.9 3.37299 11.302L3.55499 12.151C3.57035 12.2229 3.60618 12.2888 3.65817 12.3408C3.71016 12.3928 3.77609 12.4286 3.84799 12.444L4.69699 12.626C5.09899 12.712 5.09899 13.286 4.69699 13.373L3.84799 13.555C3.77609 13.5704 3.71016 13.6062 3.65817 13.6582C3.60618 13.7102 3.57035 13.7761 3.55499 13.848L3.37299 14.697C3.28699 15.099 2.71299 15.099 2.62599 14.697L2.44399 13.848C2.42863 13.7761 2.39279 13.7102 2.3408 13.6582C2.28881 13.6062 2.22289 13.5704 2.15099 13.555L1.30199 13.373C0.899988 13.287 0.899988 12.713 1.30199 12.626L2.15099 12.444C2.22294 12.4288 2.28893 12.393 2.34094 12.341C2.39295 12.2889 2.42875 12.223 2.44399 12.151Z" fill="context-fill"/>
+  <path d="M2.44399 2.151L2.62599 1.302C2.71199 0.900004 3.28599 0.900004 3.37299 1.302L3.55499 2.151C3.57035 2.22291 3.60618 2.28883 3.65817 2.34082C3.71016 2.39281 3.77609 2.42864 3.84799 2.444L4.69699 2.626C5.09899 2.712 5.09899 3.286 4.69699 3.373L3.84899 3.556C3.77703 3.57125 3.71105 3.60704 3.65904 3.65905C3.60703 3.71106 3.57123 3.77705 3.55599 3.849L3.37299 4.698C3.28699 5.1 2.71299 5.1 2.62599 4.698L2.44399 3.849C2.42875 3.77705 2.39295 3.71106 2.34094 3.65905C2.28893 3.60704 2.22294 3.57125 2.15099 3.556L1.30199 3.373C0.899988 3.287 0.899988 2.713 1.30199 2.626L2.15099 2.444C2.22294 2.42876 2.28893 2.39296 2.34094 2.34095C2.39295 2.28894 2.42875 2.22296 2.44399 2.151Z" fill="context-fill"/>
+</svg>


=====================================
browser/components/torpreferences/content/provideBridgeDialog.js
=====================================
@@ -15,6 +15,46 @@ const { TorParsers } = ChromeUtils.importESModule(
   "resource://gre/modules/TorParsers.sys.mjs"
 );
 
+const { Lox, LoxErrors } = ChromeUtils.importESModule(
+  "resource://gre/modules/Lox.sys.mjs"
+);
+
+/*
+ * Fake Lox module:
+
+const LoxErrors = {
+  BadInvite: "BadInvite",
+  LoxServerUnreachable: "LoxServerUnreachable",
+  Other: "Other",
+};
+
+const Lox = {
+  failError: null,
+  // failError: LoxErrors.BadInvite,
+  // failError: LoxErrors.LoxServerUnreachable,
+  // failError: LoxErrors.Other,
+  redeemInvite(invite) {
+    return new Promise((res, rej) => {
+      setTimeout(() => {
+        if (this.failError) {
+          rej({ type: this.failError });
+        }
+        res("lox-id-000000");
+      }, 4000);
+    });
+  },
+  validateInvitation(invite) {
+    return invite.startsWith("lox-invite");
+  },
+  getBridges(id) {
+    return [
+      "0:0 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+      "0:1 BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+    ];
+  },
+};
+*/
+
 const gProvideBridgeDialog = {
   init() {
     this._result = window.arguments[0];
@@ -36,10 +76,14 @@ const gProvideBridgeDialog = {
 
     document.l10n.setAttributes(document.documentElement, titleId);
 
+    // TODO: Make conditional on Lox being enabled.
+    this._allowLoxInvite = mode !== "edit"; // && Lox.enabled
+
     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._allowLoxInvite
+        ? "user-provide-bridge-dialog-textarea-addresses-or-invite-label"
+        : "user-provide-bridge-dialog-textarea-addresses-label"
     );
 
     this._dialog = document.getElementById("user-provide-bridge-dialog");
@@ -48,6 +92,9 @@ const gProvideBridgeDialog = {
     this._errorEl = document.getElementById(
       "user-provide-bridge-error-message"
     );
+    this._connectingEl = document.getElementById(
+      "user-provide-bridge-connecting"
+    );
     this._resultDescription = document.getElementById(
       "user-provide-result-description"
     );
@@ -68,8 +115,9 @@ const gProvideBridgeDialog = {
       // 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"
+        this._allowLoxInvite
+          ? "user-provide-bridge-dialog-textarea-addresses-or-invite"
+          : "user-provide-bridge-dialog-textarea-addresses"
       );
     }
 
@@ -98,7 +146,17 @@ const gProvideBridgeDialog = {
     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.takeFocus();
+    this.updateResult();
+    this.updateAcceptDisabled();
+    this.onAcceptStateChange();
+  },
+
+  /**
+   * Reset focus position in the dialog.
+   */
+  takeFocus() {
+    if (this._page === "entry") {
       this._textarea.focus();
     } else {
       // Move focus to the <xul:window> element.
@@ -106,9 +164,6 @@ const gProvideBridgeDialog = {
       // button (with now different text).
       document.documentElement.focus();
     }
-
-    this.updateAcceptDisabled();
-    this.onAcceptStateChange();
   },
 
   /**
@@ -149,7 +204,36 @@ const gProvideBridgeDialog = {
    */
   updateAcceptDisabled() {
     this._acceptButton.disabled =
-      this._page === "entry" && validateBridgeLines(this._textarea.value).empty;
+      this._page === "entry" && (this.isEmpty() || this._loxLoading);
+  },
+
+  /**
+   * The lox loading state.
+   *
+   * @type {boolean}
+   */
+  _loxLoading: false,
+
+  /**
+   * Set the lox loading state. I.e. whether we are connecting to the lox
+   * server.
+   *
+   * @param {boolean} isLoading - Whether we are loading or not.
+   */
+  setLoxLoading(isLoading) {
+    this._loxLoading = isLoading;
+    this._textarea.readOnly = isLoading;
+    this._connectingEl.classList.toggle("show-connecting", isLoading);
+    if (
+      isLoading &&
+      this._acceptButton.contains(
+        this._acceptButton.getRootNode().activeElement
+      )
+    ) {
+      // Move focus to the alert before we disable the button.
+      this._connectingEl.focus();
+    }
+    this.updateAcceptDisabled();
   },
 
   /**
@@ -166,23 +250,63 @@ const gProvideBridgeDialog = {
     // Prevent closing the dialog.
     event.preventDefault();
 
-    const bridges = this.checkValue();
-    if (!bridges.length) {
+    if (this._loxLoading) {
+      // User can still click Next whilst loading.
+      console.error("Already have a pending lox invite");
+      return;
+    }
+
+    // Clear the result from any previous attempt.
+    delete this._result.loxId;
+    delete this._result.addresses;
+    // Clear any previous error.
+    this.updateError(null);
+
+    const value = this.checkValue();
+    if (!value) {
+      // Not valid.
+      return;
+    }
+    if (value.loxInvite) {
+      this.setLoxLoading(true);
+      Lox.redeemInvite(value.loxInvite)
+        .finally(() => {
+          // Set set the loading to false before setting the errors.
+          this.setLoxLoading(false);
+        })
+        .then(
+          loxId => {
+            this._result.loxId = loxId;
+            this.setPage("result");
+          },
+          loxError => {
+            console.error("Redeeming failed", loxError);
+            switch (loxError.type) {
+              case LoxErrors.BadInvite:
+                // TODO: distinguish between a bad invite, an invite that has
+                // expired, and an invite that has already been redeemed.
+                this.updateError({ type: "bad-invite" });
+                break;
+              case LoxErrors.LoxServerUnreachable:
+                this.updateError({ type: "no-server" });
+                break;
+              default:
+                this.updateError({ type: "invite-error" });
+                break;
+            }
+          }
+        );
+      return;
+    }
+
+    if (!value.addresses?.length) {
       // Not valid
       return;
     }
-    this._result.bridges = bridges;
-    this.updateResult();
+    this._result.addresses = value.addresses;
     this.setPage("result");
   },
 
-  /**
-   * The current timeout for updating the error.
-   *
-   * @type {integer?}
-   */
-  _updateErrorTimeout: null,
-
   /**
    * Update the displayed error.
    *
@@ -191,14 +315,13 @@ const gProvideBridgeDialog = {
    */
   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");
+      // Move focus back to the text area, likely away from the Next button or
+      // the "Connecting..." alert.
+      this._textarea.focus();
     } else {
       this._textarea.removeAttribute("aria-invalid");
     }
@@ -216,54 +339,122 @@ const gProvideBridgeDialog = {
         errorId = "user-provide-bridge-dialog-address-error";
         errorArgs = { line: error.line };
         break;
+      case "multiple-invites":
+        errorId = "user-provide-bridge-dialog-multiple-invites-error";
+        break;
+      case "mixed":
+        errorId = "user-provide-bridge-dialog-mixed-error";
+        break;
+      case "not-allowed-invite":
+        errorId = "user-provide-bridge-dialog-invite-not-allowed-error";
+        break;
+      case "bad-invite":
+        errorId = "user-provide-bridge-dialog-bad-invite-error";
+        break;
+      case "no-server":
+        errorId = "user-provide-bridge-dialog-no-server-error";
+        break;
+      case "invite-error":
+        // Generic invite error.
+        errorId = "user-provide-bridge-dialog-generic-invite-error";
+        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);
+    document.l10n.setAttributes(this._errorEl, errorId, errorArgs);
+  },
+
+  /**
+   * The condition for the value to be empty.
+   *
+   * @type {RegExp}
+   */
+  _emptyRegex: /^\s*$/,
+  /**
+   * Whether the input is considered empty.
+   *
+   * @returns {boolean} true if it is considered empty.
+   */
+  isEmpty() {
+    return this._emptyRegex.test(this._textarea.value);
   },
 
   /**
    * Check the current value in the textarea.
    *
-   * @returns {string[]} - The bridge addresses, if the entry is valid.
+   * @returns {object?} - The bridge addresses, or lox invite, or null if no
+   *   valid value.
    */
   checkValue() {
-    let bridges = [];
-    let error = null;
-    const validation = validateBridgeLines(this._textarea.value);
-    if (!validation.empty) {
+    if (this.isEmpty()) {
       // 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(null);
+      return null;
+    }
+
+    let loxInvite = null;
+    for (let line of this._textarea.value.split(/\r?\n/)) {
+      line = line.trim();
+      if (!line) {
+        continue;
+      }
+      // TODO: Once we have a Lox invite encoding, distinguish between a valid
+      // invite and something that looks like it should be an invite.
+      const isLoxInvite = Lox.validateInvitation(line);
+      if (isLoxInvite) {
+        if (!this._allowLoxInvite) {
+          this.updateError({ type: "not-allowed-invite" });
+          return null;
+        }
+        if (loxInvite) {
+          this.updateError({ type: "multiple-invites" });
+          return null;
+        }
+        loxInvite = line;
+      } else if (loxInvite) {
+        this.updateError({ type: "mixed" });
+        return null;
       }
     }
-    this.updateError(error);
-    return bridges;
+
+    if (loxInvite) {
+      return { loxInvite };
+    }
+
+    const validation = validateBridgeLines(this._textarea.value);
+    if (validation.errorLines.length) {
+      // Report first error.
+      this.updateError({
+        type: "invalid-address",
+        line: validation.errorLines[0],
+      });
+      return null;
+    }
+
+    return { addresses: validation.validBridges };
   },
 
   /**
    * Update the shown result on the last page.
    */
   updateResult() {
+    if (this._page !== "result") {
+      return;
+    }
+
+    const loxId = this._result.loxId;
+
     document.l10n.setAttributes(
       this._resultDescription,
-      // TODO: Use a different id when added through Lox invite.
-      "user-provide-bridge-dialog-result-addresses"
+      loxId
+        ? "user-provide-bridge-dialog-result-invite"
+        : "user-provide-bridge-dialog-result-addresses"
     );
 
     this._bridgeGrid.replaceChildren();
 
-    for (const bridgeLine of this._result.bridges) {
+    const bridgeResult = loxId ? Lox.getBridges(loxId) : this._result.addresses;
+
+    for (const bridgeLine of bridgeResult) {
       let details;
       try {
         details = TorParsers.parseBridgeLine(bridgeLine);
@@ -305,6 +496,11 @@ const gProvideBridgeDialog = {
   },
 };
 
+document.subDialogSetDefaultFocus = () => {
+  // Set the focus to the text area on load.
+  gProvideBridgeDialog.takeFocus();
+};
+
 window.addEventListener(
   "DOMContentLoaded",
   () => {


=====================================
browser/components/torpreferences/content/provideBridgeDialog.xhtml
=====================================
@@ -48,8 +48,15 @@
       <html:div id="user-provide-bridge-message-area">
         <html:span
           id="user-provide-bridge-error-message"
+          role="alert"
           aria-live="assertive"
         ></html:span>
+        <html:span
+          id="user-provide-bridge-connecting"
+          role="alert"
+          tabindex="0"
+          data-l10n-id="user-provide-bridge-dialog-connecting"
+        ></html:span>
       </html:div>
     </html:div>
     <html:div id="user-provide-bridge-result-page">


=====================================
browser/components/torpreferences/content/torPreferences.css
=====================================
@@ -161,6 +161,10 @@
   display: none;
 }
 
+#tor-bridges-current:not(.source-lox) #tor-bridges-lox-label {
+  display: none;
+}
+
 #tor-bridges-current:not(
   .source-user,
   .source-requested
@@ -168,6 +172,10 @@
   display: none;
 }
 
+#tor-bridges-current:not(.source-lox) #tor-bridges-lox-status {
+  display: none;
+}
+
 #tor-bridges-none,
 #tor-bridges-current {
   margin-inline: 0;
@@ -214,6 +222,24 @@
   flex: 0 0 auto;
 }
 
+#tor-bridges-lox-label {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+#tor-bridges-lox-label > * {
+  flex: 0 0 auto;
+}
+
+#tor-bridges-lox-label-icon {
+  content: url("chrome://browser/content/torpreferences/lox-bridge-pass.svg");
+  width: 16px;
+  height: 16px;
+  -moz-context-properties: fill;
+  fill: var(--in-content-icon-color);
+}
+
 #tor-bridges-current-heading {
   margin: 0;
   margin-inline-end: 2em;
@@ -397,11 +423,14 @@
   clip-path: inset(50%);
 }
 
-#tor-bridges-share {
+.tor-bridges-details-box {
   margin-block-start: 24px;
   border-radius: 4px;
   border: 1px solid var(--in-content-border-color);
   padding: 16px;
+}
+
+#tor-bridges-share {
   display: grid;
   grid-template:
     "heading heading heading" min-content
@@ -452,6 +481,169 @@
   fill: currentColor;
 }
 
+#tor-bridges-lox-status {
+  margin-block-start: 8px;
+}
+
+#tor-bridges-lox-status:not(.show-next-unlock) #tor-bridges-lox-details {
+  display: none;
+}
+
+#tor-bridges-lox-status:not(.show-unlock-alert) #tor-bridges-lox-unlock-alert {
+  display: none;
+}
+
+.tor-bridges-lox-box {
+  display: grid;
+  grid-template:
+    "image intro intro" min-content
+    ". list list" auto
+    ". invites button" min-content
+    / min-content 1fr max-content;
+  align-items: start;
+  gap: 0 8px;
+}
+
+.tor-bridges-lox-image-outer {
+  grid-area: image;
+  /* The ring is 36px by 36px, but has 4px of padding for a Gaussian blur. */
+  width: 44px;
+  height: 44px;
+  margin: -4px;
+  align-self: center;
+  justify-self: center;
+  /* fill is the progress, stroke is the empty ring. */
+  -moz-context-properties: fill, stroke;
+  fill: var(--in-content-success-icon-color);
+  stroke: var(--in-content-border-color);
+  content: url("chrome://browser/content/torpreferences/lox-progress-ring.svg");
+}
+
+#tor-bridges-lox-unlock-alert.lox-unlock-upgrade .tor-bridges-lox-image-outer {
+  content: url("chrome://browser/content/torpreferences/lox-complete-ring.svg");
+}
+
+.tor-bridges-lox-image-inner {
+  grid-area: image;
+  /* Extra 4px space for gaussian blur. */
+  width: 16px;
+  height: 16px;
+  align-self: center;
+  justify-self: center;
+  -moz-context-properties: fill;
+  fill: var(--in-content-icon-color);
+}
+
+#tor-bridges-lox-details .tor-bridges-lox-image-inner {
+  content: url("chrome://browser/content/torpreferences/lox-bridge-pass.svg");
+}
+
+#tor-bridges-lox-unlock-alert .tor-bridges-lox-image-inner {
+  content: url("chrome://browser/content/torpreferences/bridge.svg");
+}
+
+#tor-bridges-lox-unlock-alert.lox-unlock-upgrade .tor-bridges-lox-image-inner {
+  content: url("chrome://browser/content/torpreferences/lox-success.svg");
+}
+
+.tor-bridges-lox-intro {
+  grid-area: intro;
+  font-weight: 700;
+  align-self: center;
+}
+
+.tor-bridges-lox-list {
+  grid-area: list;
+  margin: 0;
+  padding: 0;
+  display: grid;
+  /* Align the icons, as if list markers. */
+  grid-template-columns: max-content 1fr;
+  align-items: start;
+}
+
+.tor-bridges-lox-list-item {
+  display: contents;
+}
+
+.tor-bridges-lox-list-item::before {
+  /* We use ::before rather than list-style-image to have more control. */
+  display: block;
+  box-sizing: content-box;
+  width: 18px;
+  height: 18px;
+  margin-inline: 4px 6px;
+  /* We want the icons to be center-aligned relative to the *first* line. */
+  /* TODO: After firefox 120, can use line-height unit "lh" to do proper
+   * center-alignment: calc((1lh - 18px) / 2)
+   * For now, we use 3.4ex as an approximation for 1lh */
+  margin-block-start: calc((3.4ex - 18px) / 2);
+  /* fill is the icon color, stroke is the border color. */
+  -moz-context-properties: fill, stroke;
+  fill: var(--in-content-icon-color);
+}
+
+.tor-bridges-lox-list-item-bridge::before {
+  content: url("chrome://browser/content/torpreferences/lox-bridge-icon.svg");
+}
+
+.tor-bridges-lox-list-item-invite::before {
+  content: url("chrome://browser/content/torpreferences/lox-invite-icon.svg");
+}
+
+#tor-bridges-lox-details .tor-bridges-lox-list-item::before {
+  stroke: var(--in-content-border-color);
+}
+
+#tor-bridges-lox-unlock-alert .tor-bridges-lox-list-item::before {
+  stroke: var(--in-content-success-icon-color);
+}
+
+#tor-bridges-lox-details:not(.lox-next-gain-bridges) #tor-bridges-lox-next-unlock-gain-bridges {
+  display: none;
+}
+
+#tor-bridges-lox-details:not(.lox-next-first-invites) #tor-bridges-lox-next-unlock-first-invites {
+  display: none;
+}
+
+#tor-bridges-lox-details:not(.lox-next-more-invites) #tor-bridges-lox-next-unlock-more-invites {
+  display: none;
+}
+
+
+#tor-bridges-lox-unlock-alert:not(.lox-unlock-gain-bridges) #tor-bridges-lox-unlock-alert-gain-bridges {
+  display: none;
+}
+
+#tor-bridges-lox-unlock-alert:not(.lox-unlock-new-bridges) #tor-bridges-lox-unlock-alert-new-bridges {
+  display: none;
+}
+
+#tor-bridges-lox-unlock-alert:not(.lox-unlock-invites) #tor-bridges-lox-unlock-alert-invites {
+  display: none;
+}
+
+#tor-bridges-lox-remaining-invites {
+  grid-area: invites;
+  justify-self: end;
+  align-self: center;
+}
+
+#tor-bridges-lox-details:not(.lox-has-invites) :is(
+  #tor-bridges-lox-remaining-invites,
+  #tor-bridges-lox-show-invites-button
+) {
+  display: none;
+}
+
+.tor-bridges-lox-button {
+  grid-area: button;
+  margin: 0;
+  line-height: 1;
+  align-self: center;
+}
+
 #tor-bridges-provider-heading {
   font-size: 1.14em;
   margin-block: 48px 8px;
@@ -723,6 +915,15 @@ groupbox#torPreferences-bridges-group textarea {
   display: none;
 }
 
+#user-provide-bridge-connecting {
+  color: var(--text-color-deemphasized);
+  /* TODO: Add spinner ::before */
+}
+
+#user-provide-bridge-connecting:not(.show-connecting) {
+  display: none;
+}
+
 #user-provide-bridge-result-page {
   flex: 1 1 0;
   min-height: 0;


=====================================
browser/components/torpreferences/jar.mn
=====================================
@@ -3,6 +3,12 @@ browser.jar:
     content/browser/torpreferences/bridge-qr.svg                     (content/bridge-qr.svg)
     content/browser/torpreferences/telegram-logo.svg                 (content/telegram-logo.svg)
     content/browser/torpreferences/bridge-bot.svg                    (content/bridge-bot.svg)
+    content/browser/torpreferences/lox-invite-icon.svg               (content/lox-invite-icon.svg)
+    content/browser/torpreferences/lox-bridge-icon.svg               (content/lox-bridge-icon.svg)
+    content/browser/torpreferences/lox-bridge-pass.svg               (content/lox-bridge-pass.svg)
+    content/browser/torpreferences/lox-success.svg                   (content/lox-success.svg)
+    content/browser/torpreferences/lox-complete-ring.svg             (content/lox-complete-ring.svg)
+    content/browser/torpreferences/lox-progress-ring.svg             (content/lox-progress-ring.svg)
     content/browser/torpreferences/bridgeQrDialog.xhtml              (content/bridgeQrDialog.xhtml)
     content/browser/torpreferences/bridgeQrDialog.js                 (content/bridgeQrDialog.js)
     content/browser/torpreferences/builtinBridgeDialog.xhtml         (content/builtinBridgeDialog.xhtml)


=====================================
browser/locales/en-US/browser/tor-browser.ftl
=====================================
@@ -56,6 +56,10 @@ tor-bridges-your-bridges = Your bridges
 tor-bridges-source-user = Added by you
 tor-bridges-source-built-in = Built-in
 tor-bridges-source-requested = Requested from Tor
+# Here "Bridge pass" is a noun: a bridge pass gives users access to some tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+# This is shown when the user is getting their bridges from Lox.
+tor-bridges-source-lox = Bridge pass
 # The "..." menu button for all current bridges.
 tor-bridges-options-button =
     .title = All bridges
@@ -116,12 +120,75 @@ tor-bridges-update-changed-bridges = Your Tor bridges have changed.
 
 # Shown for requested bridges and bridges added by the user.
 tor-bridges-share-heading = Help others connect
-#
 tor-bridges-share-description = Share your bridges with trusted contacts.
 tor-bridges-copy-addresses-button = Copy addresses
 tor-bridges-qr-addresses-button =
     .title = Show QR code
 
+# Shown when using a "bridge pass", i.e. using Lox.
+# Here "bridge pass" is a noun: a bridge pass gives users access to some tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+# Here "bridge bot" refers to a service that automatically gives out bridges for the user to use, i.e. the Lox authority.
+tor-bridges-lox-description = With a bridge pass, the bridge bot will send you new bridges when your bridges get blocked. If your bridges don’t get blocked, you’ll unlock invites that let you share bridges with trusted contacts.
+# The number of days until the user's "bridge pass" is upgraded.
+# $numDays (Number) - The number of days until the next upgrade, an integer (1 or higher).
+# The "[one]" and "[other]" are special Fluent syntax to mark plural categories that depend on the value of "$numDays". You can use any number of plural categories that work for your locale: "[zero]", "[one]", "[two]", "[few]", "[many]" and/or "[other]". The "*" marks a category as default, and is required.
+# See https://projectfluent.org/fluent/guide/selectors.html .
+# So in English, the first form will be used if $numDays is "1" (singular) and the second form will be used if $numDays is anything else (plural).
+tor-bridges-lox-days-until-unlock =
+  { $numDays ->
+     [one] { $numDays } day until you unlock:
+    *[other] { $numDays } days until you unlock:
+  }
+# This is shown as a list item after "N days until you unlock:" when the user will gain two more bridges in the future.
+# Here "bridge bot" refers to a service that automatically gives out bridges for the user to use, i.e. the Lox authority.
+tor-bridges-lox-unlock-two-bridges = +2 bridges from the bridge bot
+# This is shown as a list item after "N days until you unlock:" when the user will gain access to invites for the first time.
+# Here "invites" is a noun, short for "invitations".
+tor-bridges-lox-unlock-first-invites = Invites for your trusted contacts
+# This is shown as a list item after "N days until you unlock:" when the user already has invites.
+# Here "invites" is a noun, short for "invitations".
+tor-bridges-lox-unlock-more-invites = More invites for your trusted contacts
+# Here "invite" is a noun, short for "invitation".
+# $numInvites (Number) - The number of invites remaining, an integer (0 or higher).
+# The "[one]" and "[other]" are special Fluent syntax to mark plural categories that depend on the value of "$numInvites". You can use any number of plural categories that work for your locale: "[zero]", "[one]", "[two]", "[few]", "[many]" and/or "[other]". The "*" marks a category as default, and is required.
+# See https://projectfluent.org/fluent/guide/selectors.html .
+# So in English, the first form will be used if $numInvites is "1" (singular) and the second form will be used if $numInvites is anything else (plural).
+tor-bridges-lox-remaining-invites =
+  { $numInvites ->
+     [one] { $numInvites } invite remaining
+    *[other] { $numInvites } invites remaining
+  }
+# Here "invites" is a noun, short for "invitations".
+tor-bridges-lox-show-invites-button = Show invites
+
+# Shown when the user's "bridge pass" has been upgraded.
+# Here "bridge pass" is a noun: a bridge pass gives users access to some tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+tor-bridges-lox-upgrade = Your bridge pass has been upgraded!
+# Shown when the user's bridges accessed through "bridge pass" have been blocked.
+tor-bridges-lox-blocked = Your blocked bridges have been replaced
+# Shown *after* the user has had their blocked bridges replaced.
+# Here "bridge bot" refers to a service that automatically gives out bridges for the user to use, i.e. the Lox authority.
+tor-bridges-lox-new-bridges = New bridges from the bridge bot
+# Shown *after* the user has gained two more bridges.
+# Here "bridge bot" refers to a service that automatically gives out bridges for the user to use, i.e. the Lox authority.
+tor-bridges-lox-gained-two-bridges = +2 bridges from the bridge bot
+# Shown *after* a user's "bridge pass" has changed.
+# Here "invite" is a noun, short for "invitation".
+# $numInvites (Number) - The number of invites remaining, an integer (0 or higher).
+# The "[one]" and "[other]" are special Fluent syntax to mark plural categories that depend on the value of "$numInvites". You can use any number of plural categories that work for your locale: "[zero]", "[one]", "[two]", "[few]", "[many]" and/or "[other]". The "*" marks a category as default, and is required.
+# See https://projectfluent.org/fluent/guide/selectors.html .
+# So in English, the first form will be used if $numInvites is "1" (singular) and the second form will be used if $numInvites is anything else (plural).
+tor-bridges-lox-new-invites =
+  { $numInvites ->
+     [one] You now have { $numInvites } remaining invite for your trusted contacts
+    *[other] You now have { $numInvites } remaining invites for your trusted contacts
+  }
+# Button for the user to acknowledge a change in their "bridge pass".
+tor-bridges-lox-got-it-button = Got it
+
+
 # Shown as a heading when the user has no current bridges.
 tor-bridges-add-bridges-heading = Add bridges
 # Shown as a heading when the user has existing bridges that can be replaced.
@@ -182,13 +249,50 @@ user-provide-bridge-dialog-description = Use bridges provided by a trusted organ
 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
+# Here "invite" is a noun, short for "invitation".
+# Short accessible name for text area when it can accept either bridge address or a single "bridge pass" invite.
+user-provide-bridge-dialog-textarea-addresses-or-invite-label = Bridge addresses or invite
 # Placeholder shown when adding new bridge addresses.
 user-provide-bridge-dialog-textarea-addresses =
     .placeholder = Paste your bridge addresses here
+# Placeholder shown when the user can add new bridge addresses or a single "bridge pass" invite.
+# Here "bridge pass invite" is a noun: a bridge pass invite can be shared with other users to give them their own bridge pass, so they can get access to tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+# And "invite" is simply short for "invitation".
+# NOTE: "invite" is singular, whilst "addresses" is plural.
+user-provide-bridge-dialog-textarea-addresses-or-invite =
+    .placeholder = Paste your bridge addresses or a bridge pass invite 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 }.
+# Error shown when the user has entered more than one "bridge pass" invite.
+# Here "invite" is a noun, short for "invitation".
+user-provide-bridge-dialog-multiple-invites-error = Cannot include more than one invite.
+# Error shown when the user has mixed their invite with addresses.
+# Here "invite" is a noun, short for "invitation".
+user-provide-bridge-dialog-mixed-error = Cannot mix bridge addresses with an invite.
+# Error shown when the user has entered an invite when it is not supported.
+# Here "bridge pass invite" is a noun: a bridge pass invite can be shared with other users to give them their own bridge pass, so they can get access to tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+# And "invite" is simply short for "invitation".
+user-provide-bridge-dialog-invite-not-allowed-error = Cannot include a bridge pass invite.
+# Error shown when the invite was not accepted by the server.
+user-provide-bridge-dialog-bad-invite-error = Invite was not accepted. Try a different one.
+# Error shown when the "bridge pass" server does not respond.
+# Here "bridge pass" is a noun: a bridge pass gives users access to some tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+user-provide-bridge-dialog-no-server-error = Unable to connect to bridge pass server.
+# Generic error when an invite failed.
+# Here "invite" is a noun, short for "invitation".
+user-provide-bridge-dialog-generic-invite-error = Failed to redeem invite.
+
+# Here "bridge pass" is a noun: a bridge pass gives users access to some tor bridges.
+# So "pass" is referring to something that gives permission or access. Similar to "token", "permit" or "voucher", but for permanent use rather than one-time.
+user-provide-bridge-dialog-connecting = Connecting to bridge pass server…
 
+# Shown after the user has entered a "bridge pass" invite.
+user-provide-bridge-dialog-result-invite = The following bridges were shared with you.
+# Shown after the user has entered bridge addresses.
 user-provide-bridge-dialog-result-addresses = The following bridges were entered by you.
 user-provide-bridge-dialog-next-button =
     .label = Next


=====================================
toolkit/components/lox/Lox.sys.mjs
=====================================
@@ -128,12 +128,12 @@ class LoxImpl {
   }
 
   /**
-   * Formats and returns bridges from the stored Lox credential
+   * Formats and returns bridges from the stored Lox credential.
    *
-   * @param {string} loxid The id string associated with a lox credential
+   * @param {string} loxid The id string associated with a lox credential.
    *
    * @returns {string[]} An array of formatted bridge lines. The array is empty
-   * if there are no bridges.
+   *   if there are no bridges.
    */
   getBridges(loxid) {
     if (!this.#initialized) {
@@ -250,6 +250,7 @@ class LoxImpl {
       this.#pubKeyPromise = this.#makeRequest("pubkeys", [])
         .then(pubKeys => {
           this.#pubKeys = JSON.stringify(pubKeys);
+          this.#store();
         })
         .catch(() => {
           // We always try to update, but if that doesn't work fall back to stored data
@@ -266,6 +267,7 @@ class LoxImpl {
       this.#encTablePromise = this.#makeRequest("reachability", [])
         .then(encTable => {
           this.#encTable = JSON.stringify(encTable);
+          this.#store();
         })
         .catch(() => {
           // Try to update first, but if that doesn't work fall back to stored data
@@ -283,6 +285,7 @@ class LoxImpl {
       this.#constantsPromise = this.#makeRequest("constants", [])
         .then(constants => {
           this.#constants = JSON.stringify(constants);
+          this.#store();
         })
         .catch(() => {
           if (!this.#constants) {
@@ -389,18 +392,17 @@ class LoxImpl {
   }
 
   /**
-   * Parses an input string to check if it is a valid Lox invitation
+   * Parses an input string to check if it is a valid Lox invitation.
    *
-   * @param {string} invite A Lox invitation
-   * @returns {Promise<bool>} A promise that resolves to true if the value passed
-   * in was a Lox invitation and false if it is not
+   * @param {string} invite A Lox invitation.
+   * @returns {bool} Whether the value passed in was a Lox invitation.
    */
-  async validateInvitation(invite) {
+  validateInvitation(invite) {
     if (!this.#initialized) {
       throw new LoxError(LoxErrors.NotInitialized);
     }
     try {
-      await lazy.invitation_is_trusted(invite);
+      lazy.invitation_is_trusted(invite);
     } catch (err) {
       console.log(err);
       return false;
@@ -420,22 +422,27 @@ class LoxImpl {
   }
 
   /**
-   * Redeems a Lox invitation to obtain a credential and bridges
+   * Redeems a Lox invitation to obtain a credential and bridges.
    *
-   * @param {string} invite A Lox invitation
-   * @returns {Promise<string>} A promise with the loxid of the associated credential on success
+   * @param {string} invite A Lox invitation.
+   * @returns {string} The loxid of the associated credential on success.
    */
   async redeemInvite(invite) {
     if (!this.#initialized) {
       throw new LoxError(LoxErrors.NotInitialized);
     }
     await this.#getPubKeys();
-    let request = await lazy.open_invite(invite.invite);
+    let request = await lazy.open_invite(JSON.parse(invite).invite);
     let id = this.#genLoxId();
-    let response = await this.#makeRequest(
-      "openreq",
-      JSON.parse(request).request
-    );
+    let response;
+    try {
+      response = await this.#makeRequest(
+        "openreq",
+        JSON.parse(request).request
+      );
+    } catch {
+      throw new LoxError(LoxErrors.LoxServerUnreachable);
+    }
     console.log("openreq response: ", response);
     if (response.hasOwnProperty("error")) {
       throw new LoxError(LoxErrors.BadInvite);
@@ -451,9 +458,9 @@ class LoxImpl {
   }
 
   /**
-   * Get metadata on all invites historically generated by this credential
+   * Get metadata on all invites historically generated by this credential.
    *
-   * @returns {object[]} A list of all historical invites
+   * @returns {string[]} A list of all historical invites.
    */
   getInvites() {
     if (!this.#initialized) {
@@ -463,12 +470,14 @@ class LoxImpl {
   }
 
   /**
-   * Generates a new trusted Lox invitation that a user can pass to their contacts
+   * Generates a new trusted Lox invitation that a user can pass to their
+   * contacts.
    *
-   * @returns {Promise<string>} A promise that resolves to a valid Lox invitation. The promise
-   *    will reject if:
-   *      - there is no saved Lox credential
-   *      - the saved credential does not have any invitations available
+   * Throws if:
+   *  - there is no saved Lox credential, or
+   *  - the saved credential does not have any invitations available.
+   *
+   * @returns {string} A valid Lox invitation.
    */
   async generateInvite() {
     if (!this.#initialized) {
@@ -514,11 +523,10 @@ class LoxImpl {
   }
 
   /**
-   * Get the number of invites that a user has remaining
+   * Get the number of invites that a user has remaining.
    *
-   * @returns {Promise<int>} A promise with the number of invites that can still be generated
-   *    by a user's credential. This promise will reject if:
-   *        - There is no credential
+   * @returns {int} The number of invites that can still be generated by a
+   *   user's credential.
    */
   getRemainingInviteCount() {
     if (!this.#initialized) {
@@ -691,11 +699,10 @@ class LoxImpl {
    */
 
   /**
-   * Get a list of accumulated events
+   * Get a list of accumulated events.
    *
-   * @returns {Promise<EventData[]>} A promise with a list of the accumulated,
-   *   unacknowledged events associated with a user's credential. This promise will reject if
-   *        - There is no credential
+   * @returns {EventData[]} A list of the accumulated, unacknowledged events
+   *   associated with a user's credential.
    */
   getEventData() {
     if (!this.#initialized) {
@@ -709,7 +716,7 @@ class LoxImpl {
   }
 
   /**
-   * Clears accumulated event data
+   * Clears accumulated event data.
    */
   clearEventData() {
     if (!this.#initialized) {
@@ -720,7 +727,7 @@ class LoxImpl {
   }
 
   /**
-   * Clears accumulated invitations
+   * Clears accumulated invitations.
    */
   clearInvites() {
     if (!this.#initialized) {
@@ -739,7 +746,9 @@ class LoxImpl {
    */
 
   /**
-   * Get dates at which access to new features will unlock
+   * Get details about the next feature unlock.
+   *
+   * @returns {UnlockData} - Details about the next unlock.
    */
   async getNextUnlock() {
     if (!this.#initialized) {
@@ -753,10 +762,10 @@ class LoxImpl {
     let nextUnlocks = JSON.parse(
       lazy.get_next_unlock(this.#constants, this.#credentials[loxid])
     );
-    const level = lazy.get_trust_level(this.#credentials[loxid]);
+    const level = parseInt(lazy.get_trust_level(this.#credentials[loxid]));
     const unlocks = {
       date: nextUnlocks.trust_level_unlock_date,
-      level: level + 1,
+      nextLevel: level + 1,
     };
     return unlocks;
   }



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

-- 
View it on GitLab: https://gitlab.torproject.org/tpo/applications/tor-browser/-/compare/fd4565d104572e24a4b7de10caaec144cbad962b...8069e4ee62453a13f3318adcca8d6125a0682b52
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/tbb-commits/attachments/20240131/e57f54db/attachment-0001.htm>


More information about the tbb-commits mailing list