tor-browser

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

SyncedTabsListStore.sys.mjs (7256B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs";
      6 
      7 /**
      8 * SyncedTabsListStore
      9 *
     10 * Instances of this store encapsulate all of the state associated with a synced tabs list view.
     11 * The state includes the clients, their tabs, the row that is currently selected,
     12 * and the filtered query.
     13 */
     14 export function SyncedTabsListStore(SyncedTabs) {
     15  EventEmitter.call(this);
     16  this._SyncedTabs = SyncedTabs;
     17  this.data = [];
     18  this._closedClients = {};
     19  this._selectedRow = [-1, -1];
     20  this.filter = "";
     21  this.inputFocused = false;
     22 }
     23 
     24 Object.assign(SyncedTabsListStore.prototype, EventEmitter.prototype, {
     25  // This internal method triggers the "change" event that views
     26  // listen for. It denormalizes the state so that it's easier for
     27  // the view to deal with. updateType hints to the view what
     28  // actually needs to be rerendered or just updated, and can be
     29  // empty (to (re)render everything), "searchbox" (to rerender just the tab list),
     30  // or "all" (to skip rendering and just update all attributes of existing nodes).
     31  _change(updateType) {
     32    let selectedParent = this._selectedRow[0];
     33    let selectedChild = this._selectedRow[1];
     34    let rowSelected = false;
     35    // clone the data so that consumers can't mutate internal storage
     36    let data = Cu.cloneInto(this.data, {});
     37    let tabCount = 0;
     38 
     39    data.forEach((client, index) => {
     40      client.closed = !!this._closedClients[client.id];
     41 
     42      if (rowSelected || selectedParent < 0) {
     43        return;
     44      }
     45      if (this.filter) {
     46        if (selectedParent < tabCount + client.tabs.length) {
     47          client.tabs[selectedParent - tabCount].selected = true;
     48          client.tabs[selectedParent - tabCount].focused = !this.inputFocused;
     49          rowSelected = true;
     50        } else {
     51          tabCount += client.tabs.length;
     52        }
     53        return;
     54      }
     55      if (selectedParent === index && selectedChild === -1) {
     56        client.selected = true;
     57        client.focused = !this.inputFocused;
     58        rowSelected = true;
     59      } else if (selectedParent === index) {
     60        client.tabs[selectedChild].selected = true;
     61        client.tabs[selectedChild].focused = !this.inputFocused;
     62        rowSelected = true;
     63      }
     64    });
     65 
     66    // If this were React the view would be smart enough
     67    // to not re-render the whole list unless necessary. But it's
     68    // not, so updateType is a hint to the view of what actually
     69    // needs to be rerendered.
     70    this.emit("change", {
     71      clients: data,
     72      canUpdateAll: updateType === "all",
     73      canUpdateInput: updateType === "searchbox",
     74      filter: this.filter,
     75      inputFocused: this.inputFocused,
     76    });
     77  },
     78 
     79  /**
     80   * Moves the row selection from a child to its parent,
     81   * which occurs when the parent of a selected row closes.
     82   */
     83  _selectParentRow() {
     84    this._selectedRow[1] = -1;
     85  },
     86 
     87  _toggleBranch(id, closed) {
     88    this._closedClients[id] = closed;
     89    if (this._closedClients[id]) {
     90      this._selectParentRow();
     91    }
     92    this._change("all");
     93  },
     94 
     95  _isOpen(client) {
     96    return !this._closedClients[client.id];
     97  },
     98 
     99  moveSelectionDown() {
    100    let branchRow = this._selectedRow[0];
    101    let childRow = this._selectedRow[1];
    102    let branch = this.data[branchRow];
    103 
    104    if (this.filter) {
    105      this.selectRow(branchRow + 1);
    106      return;
    107    }
    108 
    109    if (branchRow < 0) {
    110      this.selectRow(0, -1);
    111    } else if (
    112      (!branch.tabs.length ||
    113        childRow >= branch.tabs.length - 1 ||
    114        !this._isOpen(branch)) &&
    115      branchRow < this.data.length
    116    ) {
    117      this.selectRow(branchRow + 1, -1);
    118    } else if (childRow < branch.tabs.length) {
    119      this.selectRow(branchRow, childRow + 1);
    120    }
    121  },
    122 
    123  moveSelectionUp() {
    124    let branchRow = this._selectedRow[0];
    125    let childRow = this._selectedRow[1];
    126 
    127    if (this.filter) {
    128      this.selectRow(branchRow - 1);
    129      return;
    130    }
    131 
    132    if (branchRow < 0) {
    133      this.selectRow(0, -1);
    134    } else if (childRow < 0 && branchRow > 0) {
    135      let prevBranch = this.data[branchRow - 1];
    136      let newChildRow = this._isOpen(prevBranch)
    137        ? prevBranch.tabs.length - 1
    138        : -1;
    139      this.selectRow(branchRow - 1, newChildRow);
    140    } else if (childRow >= 0) {
    141      this.selectRow(branchRow, childRow - 1);
    142    }
    143  },
    144 
    145  // Selects a row and makes sure the selection is within bounds
    146  selectRow(parent, child) {
    147    let maxParentRow = this.filter ? this._tabCount() : this.data.length;
    148    let parentRow = parent;
    149    if (parent <= -1) {
    150      parentRow = 0;
    151    } else if (parent >= maxParentRow) {
    152      return;
    153    }
    154 
    155    let childRow = child;
    156    if (
    157      parentRow === -1 ||
    158      this.filter ||
    159      typeof child === "undefined" ||
    160      child < -1
    161    ) {
    162      childRow = -1;
    163    } else if (child >= this.data[parentRow].tabs.length) {
    164      childRow = this.data[parentRow].tabs.length - 1;
    165    }
    166 
    167    if (
    168      this._selectedRow[0] === parentRow &&
    169      this._selectedRow[1] === childRow
    170    ) {
    171      return;
    172    }
    173 
    174    this._selectedRow = [parentRow, childRow];
    175    this.inputFocused = false;
    176    this._change("all");
    177    // Record the telemetry event
    178    let extraOptions = {
    179      tab_pos: this._selectedRow[1].toString(),
    180      filter: this.filter,
    181    };
    182    this._SyncedTabs.recordSyncedTabsTelemetry(
    183      "synced_tabs_sidebar",
    184      "click",
    185      extraOptions
    186    );
    187  },
    188 
    189  _tabCount() {
    190    return this.data.reduce((prev, curr) => curr.tabs.length + prev, 0);
    191  },
    192 
    193  toggleBranch(id) {
    194    this._toggleBranch(id, !this._closedClients[id]);
    195  },
    196 
    197  closeBranch(id) {
    198    this._toggleBranch(id, true);
    199  },
    200 
    201  openBranch(id) {
    202    this._toggleBranch(id, false);
    203  },
    204 
    205  focusInput() {
    206    this.inputFocused = true;
    207    // A change type of "all" updates rather than rebuilds, which is what we
    208    // want here - only the selection/focus has changed.
    209    this._change("all");
    210  },
    211 
    212  blurInput() {
    213    this.inputFocused = false;
    214    // A change type of "all" updates rather than rebuilds, which is what we
    215    // want here - only the selection/focus has changed.
    216    this._change("all");
    217  },
    218 
    219  clearFilter() {
    220    this.filter = "";
    221    this._selectedRow = [-1, -1];
    222    return this.getData();
    223  },
    224 
    225  // Fetches data from the SyncedTabs module and triggers
    226  // and update
    227  getData(filter) {
    228    let updateType;
    229    let hasFilter = typeof filter !== "undefined";
    230    if (hasFilter) {
    231      this.filter = filter;
    232      this._selectedRow = [-1, -1];
    233 
    234      // When a filter is specified we tell the view that only the list
    235      // needs to be rerendered so that it doesn't disrupt the input
    236      // field's focus.
    237      updateType = "searchbox";
    238    }
    239 
    240    // return promise for tests
    241    return this._SyncedTabs
    242      .getTabClients(this.filter)
    243      .then(result => {
    244        if (!hasFilter) {
    245          // Only sort clients and tabs if we're rendering the whole list.
    246          this._SyncedTabs.sortTabClientsByLastUsed(result);
    247        }
    248        this.data = result;
    249        this._change(updateType);
    250      })
    251      .catch(console.error);
    252  },
    253 });