tor-browser

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

MessageListContent.js (12789B)


      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 "use strict";
      6 
      7 const {
      8  Component,
      9  createFactory,
     10 } = require("resource://devtools/client/shared/vendor/react.mjs");
     11 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs");
     12 const {
     13  connect,
     14 } = require("resource://devtools/client/shared/vendor/react-redux.js");
     15 const { PluralForm } = require("resource://devtools/shared/plural-form.js");
     16 const {
     17  getDisplayedMessages,
     18  isCurrentChannelClosed,
     19  getClosedConnectionDetails,
     20 } = require("resource://devtools/client/netmonitor/src/selectors/index.js");
     21 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
     22 const { table, tbody, tr, td, div, input, label, hr, p } = dom;
     23 const {
     24  L10N,
     25 } = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
     26 const MESSAGES_EMPTY_TEXT = L10N.getStr("messagesEmptyText");
     27 const TOGGLE_MESSAGES_TRUNCATION = L10N.getStr("toggleMessagesTruncation");
     28 const TOGGLE_MESSAGES_TRUNCATION_TITLE = L10N.getStr(
     29  "toggleMessagesTruncation.title"
     30 );
     31 const CONNECTION_CLOSED_TEXT = L10N.getStr("netmonitor.ws.connection.closed");
     32 const {
     33  CHANNEL_TYPE,
     34  WEB_SOCKET_OPCODE,
     35  MESSAGE_HEADERS,
     36 } = require("resource://devtools/client/netmonitor/src/constants.js");
     37 const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
     38 
     39 const {
     40  getSelectedMessage,
     41 } = require("resource://devtools/client/netmonitor/src/selectors/index.js");
     42 
     43 // Components
     44 const MessageListContextMenu = require("resource://devtools/client/netmonitor/src/components/messages/MessageListContextMenu.js");
     45 loader.lazyGetter(this, "MessageListHeader", function () {
     46  return createFactory(
     47    require("resource://devtools/client/netmonitor/src/components/messages/MessageListHeader.js")
     48  );
     49 });
     50 loader.lazyGetter(this, "MessageListItem", function () {
     51  return createFactory(
     52    require("resource://devtools/client/netmonitor/src/components/messages/MessageListItem.js")
     53  );
     54 });
     55 
     56 const LEFT_MOUSE_BUTTON = 0;
     57 
     58 /**
     59 * Renders the actual contents of the message list.
     60 */
     61 class MessageListContent extends Component {
     62  static get propTypes() {
     63    return {
     64      connector: PropTypes.object.isRequired,
     65      startPanelContainer: PropTypes.object,
     66      messages: PropTypes.array,
     67      selectedMessage: PropTypes.object,
     68      selectMessage: PropTypes.func.isRequired,
     69      columns: PropTypes.object.isRequired,
     70      isClosed: PropTypes.bool.isRequired,
     71      closedConnectionDetails: PropTypes.object,
     72      channelId: PropTypes.number,
     73      channelType: PropTypes.string,
     74      onSelectMessageDelta: PropTypes.func.isRequired,
     75    };
     76  }
     77 
     78  constructor(props) {
     79    super(props);
     80 
     81    this.onContextMenu = this.onContextMenu.bind(this);
     82    this.onKeyDown = this.onKeyDown.bind(this);
     83    this.messagesLimit = Services.prefs.getIntPref(
     84      "devtools.netmonitor.msg.displayed-messages.limit"
     85    );
     86    this.currentTruncatedNum = 0;
     87    this.state = {
     88      checked: false,
     89    };
     90    this.pinnedToBottom = false;
     91    this.initIntersectionObserver = false;
     92    this.intersectionObserver = null;
     93    this.toggleTruncationCheckBox = this.toggleTruncationCheckBox.bind(this);
     94  }
     95 
     96  componentDidMount() {
     97    const { startPanelContainer } = this.props;
     98    const { scrollAnchor } = this.refs;
     99 
    100    if (scrollAnchor) {
    101      // Always scroll to anchor when MessageListContent component first mounts.
    102      scrollAnchor.scrollIntoView();
    103    }
    104    this.setupScrollToBottom(startPanelContainer, scrollAnchor);
    105  }
    106 
    107  componentDidUpdate(prevProps) {
    108    const { startPanelContainer, channelId } = this.props;
    109    const { scrollAnchor } = this.refs;
    110 
    111    // When messages are cleared, the previous scrollAnchor would be destroyed, so we need to reset this boolean.
    112    if (!scrollAnchor) {
    113      this.initIntersectionObserver = false;
    114    }
    115 
    116    // In addition to that, we need to reset currentTruncatedNum
    117    if (prevProps.messages.length && this.props.messages.length === 0) {
    118      this.currentTruncatedNum = 0;
    119    }
    120 
    121    // If a new connection is selected, scroll to anchor.
    122    if (channelId !== prevProps.channelId && scrollAnchor) {
    123      scrollAnchor.scrollIntoView();
    124    }
    125 
    126    // Do not autoscroll if the selection changed. This would cause
    127    // the newly selected message to jump just after clicking in.
    128    // (not user friendly)
    129    //
    130    // If the selection changed, we need to ensure that the newly
    131    // selected message is properly scrolled into the visible area.
    132    if (prevProps.selectedMessage === this.props.selectedMessage) {
    133      this.setupScrollToBottom(startPanelContainer, scrollAnchor);
    134    } else {
    135      const head = document.querySelector("thead.message-list-headers-group");
    136      const selectedRow = document.querySelector(
    137        "tr.message-list-item.selected"
    138      );
    139 
    140      if (selectedRow) {
    141        const rowRect = selectedRow.getBoundingClientRect();
    142        const scrollableRect = startPanelContainer.getBoundingClientRect();
    143        const headRect = head.getBoundingClientRect();
    144 
    145        if (rowRect.top <= scrollableRect.top) {
    146          selectedRow.scrollIntoView(true);
    147 
    148          // We need to scroll a bit more to get the row out
    149          // of the header. The header is sticky and overlaps
    150          // part of the scrollable area.
    151          startPanelContainer.scrollTop -= headRect.height;
    152        } else if (rowRect.bottom > scrollableRect.bottom) {
    153          selectedRow.scrollIntoView(false);
    154        }
    155      }
    156    }
    157  }
    158 
    159  componentWillUnmount() {
    160    // Reset observables and boolean values.
    161    const { scrollAnchor } = this.refs;
    162 
    163    if (this.intersectionObserver) {
    164      if (scrollAnchor) {
    165        this.intersectionObserver.unobserve(scrollAnchor);
    166      }
    167      this.initIntersectionObserver = false;
    168      this.pinnedToBottom = false;
    169    }
    170  }
    171 
    172  setupScrollToBottom(startPanelContainer, scrollAnchor) {
    173    if (startPanelContainer && scrollAnchor) {
    174      // Initialize intersection observer.
    175      if (!this.initIntersectionObserver) {
    176        this.intersectionObserver = new IntersectionObserver(
    177          () => {
    178            // When scrollAnchor first comes into view, this.pinnedToBottom is set to true.
    179            // When the anchor goes out of view, this callback function triggers again and toggles this.pinnedToBottom.
    180            // Subsequent scroll into/out of view will toggle this.pinnedToBottom.
    181            this.pinnedToBottom = !this.pinnedToBottom;
    182          },
    183          {
    184            root: startPanelContainer,
    185            threshold: 0.1,
    186          }
    187        );
    188        if (this.intersectionObserver) {
    189          this.intersectionObserver.observe(scrollAnchor);
    190          this.initIntersectionObserver = true;
    191        }
    192      }
    193 
    194      if (this.pinnedToBottom) {
    195        scrollAnchor.scrollIntoView();
    196      }
    197    }
    198  }
    199 
    200  toggleTruncationCheckBox() {
    201    this.setState({
    202      checked: !this.state.checked,
    203    });
    204  }
    205 
    206  onMouseDown(evt, item) {
    207    if (evt.button === LEFT_MOUSE_BUTTON) {
    208      this.props.selectMessage(item);
    209    }
    210  }
    211 
    212  onContextMenu(evt, item) {
    213    evt.preventDefault();
    214    const { connector, channelType } = this.props;
    215    this.contextMenu = new MessageListContextMenu({
    216      connector,
    217      showBinaryOptions:
    218        channelType === CHANNEL_TYPE.WEB_SOCKET &&
    219        item.opCode === WEB_SOCKET_OPCODE.BINARY,
    220    });
    221    this.contextMenu.open(evt, item);
    222  }
    223 
    224  /**
    225   * Handler for keyboard events. For arrow up/down, page up/down, home/end,
    226   * move the selection up or down.
    227   */
    228  onKeyDown(evt) {
    229    evt.preventDefault();
    230    evt.stopPropagation();
    231    let delta;
    232 
    233    switch (evt.key) {
    234      case "ArrowUp":
    235        delta = -1;
    236        break;
    237      case "ArrowDown":
    238        delta = +1;
    239        break;
    240      case "PageUp":
    241        delta = "PAGE_UP";
    242        break;
    243      case "PageDown":
    244        delta = "PAGE_DOWN";
    245        break;
    246      case "Home":
    247        delta = -Infinity;
    248        break;
    249      case "End":
    250        delta = +Infinity;
    251        break;
    252    }
    253 
    254    if (delta) {
    255      this.props.onSelectMessageDelta(delta);
    256    }
    257  }
    258 
    259  render() {
    260    const {
    261      messages,
    262      selectedMessage,
    263      connector,
    264      columns,
    265      isClosed,
    266      closedConnectionDetails,
    267    } = this.props;
    268 
    269    if (messages.length === 0) {
    270      return div(
    271        { className: "empty-notice message-list-empty-notice" },
    272        MESSAGES_EMPTY_TEXT
    273      );
    274    }
    275 
    276    const visibleColumns = MESSAGE_HEADERS.filter(
    277      header => columns[header.name]
    278    ).map(col => col.name);
    279 
    280    let displayedMessages;
    281    let MESSAGES_TRUNCATED;
    282    const shouldTruncate = messages.length > this.messagesLimit;
    283    if (shouldTruncate) {
    284      // If the checkbox is checked, we display all messages after the currentTruncatedNum limit.
    285      // If the checkbox is unchecked, we display all messages after the messagesLimit.
    286      this.currentTruncatedNum = this.state.checked
    287        ? this.currentTruncatedNum
    288        : messages.length - this.messagesLimit;
    289      displayedMessages = messages.slice(this.currentTruncatedNum);
    290 
    291      MESSAGES_TRUNCATED = PluralForm.get(
    292        this.currentTruncatedNum,
    293        L10N.getStr("netmonitor.ws.truncated-messages.warning")
    294      ).replace("#1", this.currentTruncatedNum);
    295    } else {
    296      displayedMessages = messages;
    297    }
    298 
    299    let connectionClosedMsg = CONNECTION_CLOSED_TEXT;
    300    if (
    301      closedConnectionDetails &&
    302      closedConnectionDetails.code !== undefined &&
    303      closedConnectionDetails.reason !== undefined
    304    ) {
    305      connectionClosedMsg += `: ${closedConnectionDetails.code} ${closedConnectionDetails.reason}`;
    306    }
    307    return div(
    308      {},
    309      table(
    310        { className: "message-list-table" },
    311        MessageListHeader(),
    312        tbody(
    313          {
    314            className: "message-list-body",
    315            onKeyDown: this.onKeyDown,
    316          },
    317          tr(
    318            {
    319              tabIndex: 0,
    320            },
    321            td(
    322              {
    323                className: "truncated-messages-cell",
    324                colSpan: visibleColumns.length,
    325              },
    326              shouldTruncate &&
    327                div(
    328                  {
    329                    className: "truncated-messages-header",
    330                  },
    331                  div(
    332                    {
    333                      className: "truncated-messages-container",
    334                    },
    335                    div({
    336                      className: "truncated-messages-warning-icon",
    337                    }),
    338                    div(
    339                      {
    340                        className: "truncated-message",
    341                        title: MESSAGES_TRUNCATED,
    342                      },
    343                      MESSAGES_TRUNCATED
    344                    )
    345                  ),
    346                  label(
    347                    {
    348                      className: "truncated-messages-checkbox-label",
    349                      title: TOGGLE_MESSAGES_TRUNCATION_TITLE,
    350                    },
    351                    input({
    352                      type: "checkbox",
    353                      className: "truncation-checkbox",
    354                      title: TOGGLE_MESSAGES_TRUNCATION_TITLE,
    355                      checked: this.state.checked,
    356                      onChange: this.toggleTruncationCheckBox,
    357                    }),
    358                    TOGGLE_MESSAGES_TRUNCATION
    359                  )
    360                )
    361            )
    362          ),
    363          displayedMessages.map((item, index) =>
    364            MessageListItem({
    365              key: "message-list-item-" + index,
    366              item,
    367              index,
    368              isSelected: item === selectedMessage,
    369              onMouseDown: evt => this.onMouseDown(evt, item),
    370              onContextMenu: evt => this.onContextMenu(evt, item),
    371              connector,
    372              visibleColumns,
    373            })
    374          )
    375        )
    376      ),
    377      isClosed &&
    378        p(
    379          {
    380            className: "msg-connection-closed-message",
    381          },
    382          connectionClosedMsg
    383        ),
    384      hr({
    385        ref: "scrollAnchor",
    386        className: "message-list-scroll-anchor",
    387      })
    388    );
    389  }
    390 }
    391 
    392 module.exports = connect(
    393  state => ({
    394    selectedMessage: getSelectedMessage(state),
    395    messages: getDisplayedMessages(state),
    396    columns: state.messages.columns,
    397    isClosed: isCurrentChannelClosed(state),
    398    closedConnectionDetails: getClosedConnectionDetails(state),
    399    channelType: state.messages.currentChannelType,
    400  }),
    401  dispatch => ({
    402    selectMessage: item => dispatch(Actions.selectMessage(item)),
    403    onSelectMessageDelta: delta => dispatch(Actions.selectMessageDelta(delta)),
    404  })
    405 )(MessageListContent);