tor-browser

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

TabListView.sys.mjs (18173B)


      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 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
      9  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     10 });
     11 
     12 import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs";
     13 
     14 function getContextMenu(window) {
     15  return getChromeWindow(window).document.getElementById(
     16    "SyncedTabsSidebarContext"
     17  );
     18 }
     19 
     20 function getTabsFilterContextMenu(window) {
     21  return getChromeWindow(window).document.getElementById(
     22    "SyncedTabsSidebarTabsFilterContext"
     23  );
     24 }
     25 
     26 /*
     27 * TabListView
     28 *
     29 * Given a state, this object will render the corresponding DOM.
     30 * It maintains no state of it's own. It listens for DOM events
     31 * and triggers actions that may cause the state to change and
     32 * ultimately the view to rerender.
     33 */
     34 export function TabListView(window, props) {
     35  this.props = props;
     36 
     37  this._window = window;
     38  this._doc = this._window.document;
     39 
     40  this._tabsContainerTemplate = this._doc.getElementById(
     41    "tabs-container-template"
     42  );
     43  this._clientTemplate = this._doc.getElementById("client-template");
     44  this._emptyClientTemplate = this._doc.getElementById("empty-client-template");
     45  this._tabTemplate = this._doc.getElementById("tab-template");
     46  this.tabsFilter = this._doc.querySelector(".tabsFilter");
     47 
     48  this.container = this._doc.createElement("div");
     49 
     50  this._attachFixedListeners();
     51 
     52  this._setupContextMenu();
     53 }
     54 
     55 TabListView.prototype = {
     56  render(state) {
     57    // Don't rerender anything; just update attributes, e.g. selection
     58    if (state.canUpdateAll) {
     59      this._update(state);
     60      return;
     61    }
     62    // Rerender the tab list
     63    if (state.canUpdateInput) {
     64      this._updateSearchBox(state);
     65      this._createList(state);
     66      return;
     67    }
     68    // Create the world anew
     69    this._create(state);
     70  },
     71 
     72  // Create the initial DOM from templates
     73  _create(state) {
     74    let wrapper = this._doc.importNode(
     75      this._tabsContainerTemplate.content,
     76      true
     77    ).firstElementChild;
     78    this._clearChilden();
     79    this.container.appendChild(wrapper);
     80 
     81    this.list = this.container.querySelector(".list");
     82 
     83    this._createList(state);
     84    this._updateSearchBox(state);
     85 
     86    this._attachListListeners();
     87  },
     88 
     89  _createList(state) {
     90    this._clearChilden(this.list);
     91    for (let client of state.clients) {
     92      if (state.filter) {
     93        this._renderFilteredClient(client);
     94      } else {
     95        this._renderClient(client);
     96      }
     97    }
     98    if (this.list.firstElementChild) {
     99      const firstTab = this.list.firstElementChild.querySelector(
    100        ".item.tab:first-child .item-title"
    101      );
    102      if (firstTab) {
    103        firstTab.setAttribute("tabindex", 2);
    104      }
    105    }
    106  },
    107 
    108  destroy() {
    109    this._teardownContextMenu();
    110    this.container.remove();
    111  },
    112 
    113  _update(state) {
    114    this._updateSearchBox(state);
    115    for (let client of state.clients) {
    116      let clientNode = this._doc.getElementById("item-" + client.id);
    117      if (clientNode) {
    118        this._updateClient(client, clientNode);
    119      }
    120 
    121      client.tabs.forEach((tab, index) => {
    122        let tabNode = this._doc.getElementById(
    123          "tab-" + client.id + "-" + index
    124        );
    125        this._updateTab(tab, tabNode, index);
    126      });
    127    }
    128  },
    129 
    130  // Client rows are hidden when the list is filtered
    131  _renderFilteredClient(client) {
    132    client.tabs.forEach((tab, index) => {
    133      let node = this._renderTab(client, tab, index);
    134      this.list.appendChild(node);
    135    });
    136  },
    137 
    138  _updateLastSyncTitle(lastModified, itemNode) {
    139    let lastSync = new Date(lastModified);
    140    let lastSyncTitle = getChromeWindow(this._window).gSync.formatLastSyncDate(
    141      lastSync
    142    );
    143    itemNode.setAttribute("title", lastSyncTitle);
    144  },
    145 
    146  _renderClient(client) {
    147    let itemNode = client.tabs.length
    148      ? this._createClient(client)
    149      : this._createEmptyClient(client);
    150 
    151    itemNode.addEventListener("mouseover", () =>
    152      this._updateLastSyncTitle(client.lastModified, itemNode)
    153    );
    154 
    155    this._updateClient(client, itemNode);
    156 
    157    let tabsList = itemNode.querySelector(".item-tabs-list");
    158    client.tabs.forEach((tab, index) => {
    159      let node = this._renderTab(client, tab, index);
    160      tabsList.appendChild(node);
    161    });
    162 
    163    this.list.appendChild(itemNode);
    164    return itemNode;
    165  },
    166 
    167  _renderTab(client, tab, index) {
    168    let itemNode = this._createTab(tab);
    169    this._updateTab(tab, itemNode, index);
    170    return itemNode;
    171  },
    172 
    173  _createClient() {
    174    return this._doc.importNode(this._clientTemplate.content, true)
    175      .firstElementChild;
    176  },
    177 
    178  _createEmptyClient() {
    179    return this._doc.importNode(this._emptyClientTemplate.content, true)
    180      .firstElementChild;
    181  },
    182 
    183  _createTab() {
    184    return this._doc.importNode(this._tabTemplate.content, true)
    185      .firstElementChild;
    186  },
    187 
    188  _clearChilden(node) {
    189    let parent = node || this.container;
    190    while (parent.firstChild) {
    191      parent.firstChild.remove();
    192    }
    193  },
    194 
    195  // These listeners are attached only once, when we initialize the view
    196  _attachFixedListeners() {
    197    this.tabsFilter.addEventListener("input", this.onFilter.bind(this));
    198    this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this));
    199    this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this));
    200  },
    201 
    202  // These listeners have to be re-created every time since we re-create the list
    203  _attachListListeners() {
    204    this.list.addEventListener("click", this.onClick.bind(this));
    205    this.list.addEventListener("mouseup", this.onMouseUp.bind(this));
    206    this.list.addEventListener("keydown", this.onKeyDown.bind(this));
    207  },
    208 
    209  _updateSearchBox(state) {
    210    this.tabsFilter.value = state.filter;
    211    if (state.inputFocused) {
    212      this.tabsFilter.focus();
    213    }
    214  },
    215 
    216  /**
    217   * Update the element representing an item, ensuring it's in sync with the
    218   * underlying data.
    219   *
    220   * @param {client} item - Item to use as a source.
    221   * @param {Element} itemNode - Element to update.
    222   */
    223  _updateClient(item, itemNode) {
    224    itemNode.setAttribute("id", "item-" + item.id);
    225    this._updateLastSyncTitle(item.lastModified, itemNode);
    226    if (item.closed) {
    227      itemNode.classList.add("closed");
    228    } else {
    229      itemNode.classList.remove("closed");
    230    }
    231    if (item.selected) {
    232      itemNode.classList.add("selected");
    233    } else {
    234      itemNode.classList.remove("selected");
    235    }
    236    if (item.focused) {
    237      itemNode.focus();
    238    }
    239    itemNode.setAttribute("clientType", item.clientType);
    240    itemNode.dataset.id = item.id;
    241    itemNode.querySelector(".item-title").textContent = item.name;
    242  },
    243 
    244  /**
    245   * Update the element representing a tab, ensuring it's in sync with the
    246   * underlying data.
    247   *
    248   * @param {tab} item - Item to use as a source.
    249   * @param {Element} itemNode - Element to update.
    250   */
    251  _updateTab(item, itemNode, index) {
    252    itemNode.setAttribute("title", `${item.title}\n${item.url}`);
    253    itemNode.setAttribute("id", "tab-" + item.client + "-" + index);
    254    if (item.selected) {
    255      itemNode.classList.add("selected");
    256    } else {
    257      itemNode.classList.remove("selected");
    258    }
    259    if (item.focused) {
    260      itemNode.focus();
    261    }
    262    itemNode.dataset.url = item.url;
    263 
    264    itemNode.querySelector(".item-title").textContent = item.title;
    265 
    266    if (item.icon) {
    267      let icon = itemNode.querySelector(".item-icon-container");
    268      icon.style.backgroundImage = "url(" + item.icon + ")";
    269    }
    270  },
    271 
    272  onMouseUp(event) {
    273    if (event.which == 2) {
    274      // Middle click
    275      this.onClick(event);
    276    }
    277  },
    278 
    279  onClick(event) {
    280    let itemNode = this._findParentItemNode(event.target);
    281    if (!itemNode) {
    282      return;
    283    }
    284 
    285    if (itemNode.classList.contains("tab")) {
    286      let url = itemNode.dataset.url;
    287      if (url) {
    288        this.onOpenSelected(url, event);
    289      }
    290    }
    291 
    292    // Middle click on a client
    293    if (itemNode.classList.contains("client")) {
    294      let where = lazy.BrowserUtils.whereToOpenLink(event);
    295      if (where != "current") {
    296        this._openAllClientTabs(itemNode, where);
    297      }
    298    }
    299 
    300    if (
    301      event.target.classList.contains("item-twisty-container") &&
    302      event.which != 2
    303    ) {
    304      this.props.onToggleBranch(itemNode.dataset.id);
    305      return;
    306    }
    307 
    308    let position = this._getSelectionPosition(itemNode);
    309    this.props.onSelectRow(position);
    310  },
    311 
    312  /**
    313   * Handle a keydown event on the list box.
    314   *
    315   * @param {Event} event - Triggering event.
    316   */
    317  onKeyDown(event) {
    318    if (event.keyCode == this._window.KeyEvent.DOM_VK_DOWN) {
    319      event.preventDefault();
    320      this.props.onMoveSelectionDown();
    321    } else if (event.keyCode == this._window.KeyEvent.DOM_VK_UP) {
    322      event.preventDefault();
    323      this.props.onMoveSelectionUp();
    324    } else if (event.keyCode == this._window.KeyEvent.DOM_VK_RETURN) {
    325      let selectedNode = this.container.querySelector(".item.selected");
    326      if (selectedNode.dataset.url) {
    327        this.onOpenSelected(selectedNode.dataset.url, event);
    328      } else if (selectedNode) {
    329        this.props.onToggleBranch(selectedNode.dataset.id);
    330      }
    331    }
    332  },
    333 
    334  onBookmarkTab() {
    335    let item = this._getSelectedTabNode();
    336    if (item) {
    337      let title = item.querySelector(".item-title").textContent;
    338      this.props.onBookmarkTab(item.dataset.url, title);
    339    }
    340  },
    341 
    342  onCopyTabLocation() {
    343    let item = this._getSelectedTabNode();
    344    if (item) {
    345      this.props.onCopyTabLocation(item.dataset.url);
    346    }
    347  },
    348 
    349  onOpenSelected(url, event) {
    350    let where = lazy.BrowserUtils.whereToOpenLink(event);
    351    this.props.onOpenTab(url, where, {});
    352  },
    353 
    354  onOpenSelectedFromContextMenu(event) {
    355    let item = this._getSelectedTabNode();
    356    if (item) {
    357      let where = event.target.getAttribute("where");
    358      let params = {
    359        private: event.target.hasAttribute("private"),
    360      };
    361      this.props.onOpenTab(item.dataset.url, where, params);
    362    }
    363  },
    364 
    365  onOpenSelectedInContainerTab(event) {
    366    let item = this._getSelectedTabNode();
    367    if (item) {
    368      this.props.onOpenTab(item.dataset.url, "tab", {
    369        userContextId: parseInt(event.target?.dataset.usercontextid),
    370      });
    371    }
    372  },
    373 
    374  onOpenAllInTabs() {
    375    let item = this._getSelectedClientNode();
    376    if (item) {
    377      this._openAllClientTabs(item, "tab");
    378    }
    379  },
    380 
    381  onFilter(event) {
    382    let query = event.target.value;
    383    if (query) {
    384      this.props.onFilter(query);
    385    } else {
    386      this.props.onClearFilter();
    387    }
    388  },
    389 
    390  onFilterFocus() {
    391    this.props.onFilterFocus();
    392  },
    393  onFilterBlur() {
    394    this.props.onFilterBlur();
    395  },
    396 
    397  _getSelectedTabNode() {
    398    let item = this.container.querySelector(".item.selected");
    399    if (this._isTab(item) && item.dataset.url) {
    400      return item;
    401    }
    402    return null;
    403  },
    404 
    405  _getSelectedClientNode() {
    406    let item = this.container.querySelector(".item.selected");
    407    if (this._isClient(item)) {
    408      return item;
    409    }
    410    return null;
    411  },
    412 
    413  // Set up the custom context menu
    414  _setupContextMenu() {
    415    this._window.addEventListener("contextmenu", this, {
    416      mozSystemGroup: true,
    417    });
    418    for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
    419      let menu = getMenu(this._window);
    420      menu.addEventListener("popupshowing", this, true);
    421      menu.addEventListener("command", this, true);
    422    }
    423  },
    424 
    425  _teardownContextMenu() {
    426    // Tear down context menu
    427    this._window.removeEventListener("contextmenu", this, {
    428      mozSystemGroup: true,
    429    });
    430    for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
    431      let menu = getMenu(this._window);
    432      menu.removeEventListener("popupshowing", this, true);
    433      menu.removeEventListener("command", this, true);
    434    }
    435  },
    436 
    437  handleEvent(event) {
    438    switch (event.type) {
    439      case "contextmenu":
    440        this.handleContextMenu(event);
    441        break;
    442 
    443      case "popupshowing": {
    444        if (
    445          event.target.getAttribute("id") ==
    446          "SyncedTabsSidebarTabsFilterContext"
    447        ) {
    448          this.handleTabsFilterContextMenuShown(event);
    449        }
    450        break;
    451      }
    452 
    453      case "command": {
    454        let menu = event.target.closest("menupopup");
    455        switch (menu.getAttribute("id")) {
    456          case "SyncedTabsSidebarContext":
    457            this.handleContentContextMenuCommand(event);
    458            break;
    459 
    460          case "SyncedTabsOpenSelectedInContainerTabMenu":
    461            this.onOpenSelectedInContainerTab(event);
    462            break;
    463 
    464          case "SyncedTabsSidebarTabsFilterContext":
    465            this.handleTabsFilterContextMenuCommand(event);
    466            break;
    467        }
    468        break;
    469      }
    470    }
    471  },
    472 
    473  handleTabsFilterContextMenuShown(event) {
    474    let document = event.target.ownerDocument;
    475    let focusedElement = document.commandDispatcher.focusedElement;
    476    if (focusedElement != this.tabsFilter.inputField) {
    477      this.tabsFilter.focus();
    478    }
    479    for (let item of event.target.children) {
    480      if (!item.hasAttribute("cmd")) {
    481        continue;
    482      }
    483      let command = item.getAttribute("cmd");
    484      let controller =
    485        document.commandDispatcher.getControllerForCommand(command);
    486      if (controller.isCommandEnabled(command)) {
    487        item.removeAttribute("disabled");
    488      } else {
    489        item.setAttribute("disabled", "true");
    490      }
    491    }
    492  },
    493 
    494  handleContentContextMenuCommand(event) {
    495    let id = event.target.getAttribute("id");
    496    switch (id) {
    497      case "syncedTabsOpenSelected":
    498      case "syncedTabsOpenSelectedInTab":
    499      case "syncedTabsOpenSelectedInWindow":
    500      case "syncedTabsOpenSelectedInPrivateWindow":
    501        this.onOpenSelectedFromContextMenu(event);
    502        break;
    503      case "syncedTabsOpenAllInTabs":
    504        this.onOpenAllInTabs();
    505        break;
    506      case "syncedTabsBookmarkSelected":
    507        this.onBookmarkTab();
    508        break;
    509      case "syncedTabsCopySelected":
    510        this.onCopyTabLocation();
    511        break;
    512      case "syncedTabsRefresh":
    513      case "syncedTabsRefreshFilter":
    514        this.props.onSyncRefresh();
    515        break;
    516    }
    517  },
    518 
    519  handleTabsFilterContextMenuCommand(event) {
    520    let command = event.target.getAttribute("cmd");
    521    let dispatcher = getChromeWindow(this._window).document.commandDispatcher;
    522    let controller =
    523      dispatcher.focusedElement.controllers.getControllerForCommand(command);
    524    controller.doCommand(command);
    525  },
    526 
    527  handleContextMenu(event) {
    528    let menu;
    529 
    530    if (event.target == this.tabsFilter) {
    531      menu = getTabsFilterContextMenu(this._window);
    532    } else {
    533      let itemNode = this._findParentItemNode(event.target);
    534      if (itemNode) {
    535        let position = this._getSelectionPosition(itemNode);
    536        this.props.onSelectRow(position);
    537      }
    538      menu = getContextMenu(this._window);
    539      this.adjustContextMenu(menu);
    540    }
    541 
    542    menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
    543  },
    544 
    545  adjustContextMenu(menu) {
    546    let item = this.container.querySelector(".item.selected");
    547    let showTabOptions = this._isTab(item);
    548 
    549    let el = menu.firstElementChild;
    550 
    551    while (el) {
    552      let show = false;
    553      if (showTabOptions) {
    554        if (el.getAttribute("id") == "syncedTabsOpenSelectedInPrivateWindow") {
    555          show = lazy.PrivateBrowsingUtils.enabled;
    556        } else if (
    557          el.getAttribute("id") === "syncedTabsOpenSelectedInContainerTab"
    558        ) {
    559          show =
    560            Services.prefs.getBoolPref("privacy.userContext.enabled", false) &&
    561            !lazy.PrivateBrowsingUtils.isWindowPrivate(
    562              getChromeWindow(this._window)
    563            );
    564        } else if (
    565          el.getAttribute("id") != "syncedTabsOpenAllInTabs" &&
    566          el.getAttribute("id") != "syncedTabsManageDevices"
    567        ) {
    568          show = true;
    569        }
    570      } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") {
    571        const tabs = item.querySelectorAll(".item-tabs-list > .item.tab");
    572        show = !!tabs.length;
    573      } else if (el.getAttribute("id") == "syncedTabsRefresh") {
    574        show = true;
    575      } else if (el.getAttribute("id") == "syncedTabsManageDevices") {
    576        show = true;
    577      }
    578      el.hidden = !show;
    579 
    580      el = el.nextElementSibling;
    581    }
    582  },
    583 
    584  /**
    585   * Find the parent item element, from a given child element.
    586   *
    587   * @param {Element} node - Child element.
    588   * @returns {Element} Element for the item, or null if not found.
    589   */
    590  _findParentItemNode(node) {
    591    while (
    592      node &&
    593      node !== this.list &&
    594      node !== this._doc.documentElement &&
    595      !node.classList.contains("item")
    596    ) {
    597      node = node.parentNode;
    598    }
    599 
    600    if (node !== this.list && node !== this._doc.documentElement) {
    601      return node;
    602    }
    603 
    604    return null;
    605  },
    606 
    607  _findParentBranchNode(node) {
    608    while (
    609      node &&
    610      !node.classList.contains("list") &&
    611      node !== this._doc.documentElement &&
    612      !node.parentNode.classList.contains("list")
    613    ) {
    614      node = node.parentNode;
    615    }
    616 
    617    if (node !== this.list && node !== this._doc.documentElement) {
    618      return node;
    619    }
    620 
    621    return null;
    622  },
    623 
    624  _getSelectionPosition(itemNode) {
    625    let parent = this._findParentBranchNode(itemNode);
    626    let parentPosition = this._indexOfNode(parent.parentNode, parent);
    627    let childPosition = -1;
    628    // if the node is not a client, find its position within the parent
    629    if (parent !== itemNode) {
    630      childPosition = this._indexOfNode(itemNode.parentNode, itemNode);
    631    }
    632    return [parentPosition, childPosition];
    633  },
    634 
    635  _indexOfNode(parent, child) {
    636    return Array.prototype.indexOf.call(parent.children, child);
    637  },
    638 
    639  _isTab(item) {
    640    return item && item.classList.contains("tab");
    641  },
    642 
    643  _isClient(item) {
    644    return item && item.classList.contains("client");
    645  },
    646 
    647  _openAllClientTabs(clientNode, where) {
    648    const tabs = clientNode.querySelector(".item-tabs-list").children;
    649    const urls = [...tabs].map(tab => tab.dataset.url);
    650    this.props.onOpenTabs(urls, where);
    651  },
    652 };