diff options
Diffstat (limited to 'devtools/client/netmonitor/src/components/messages/MessageListContent.js')
-rw-r--r-- | devtools/client/netmonitor/src/components/messages/MessageListContent.js | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/components/messages/MessageListContent.js b/devtools/client/netmonitor/src/components/messages/MessageListContent.js new file mode 100644 index 0000000000..e75830bf99 --- /dev/null +++ b/devtools/client/netmonitor/src/components/messages/MessageListContent.js @@ -0,0 +1,405 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + Component, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js"); +const { + connect, +} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js"); +const { PluralForm } = require("resource://devtools/shared/plural-form.js"); +const { + getDisplayedMessages, + isCurrentChannelClosed, + getClosedConnectionDetails, +} = require("resource://devtools/client/netmonitor/src/selectors/index.js"); +const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { table, tbody, tr, td, div, input, label, hr, p } = dom; +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); +const MESSAGES_EMPTY_TEXT = L10N.getStr("messagesEmptyText"); +const TOGGLE_MESSAGES_TRUNCATION = L10N.getStr("toggleMessagesTruncation"); +const TOGGLE_MESSAGES_TRUNCATION_TITLE = L10N.getStr( + "toggleMessagesTruncation.title" +); +const CONNECTION_CLOSED_TEXT = L10N.getStr("netmonitor.ws.connection.closed"); +const { + CHANNEL_TYPE, + WEB_SOCKET_OPCODE, + MESSAGE_HEADERS, +} = require("resource://devtools/client/netmonitor/src/constants.js"); +const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js"); + +const { + getSelectedMessage, +} = require("resource://devtools/client/netmonitor/src/selectors/index.js"); + +// Components +const MessageListContextMenu = require("resource://devtools/client/netmonitor/src/components/messages/MessageListContextMenu.js"); +loader.lazyGetter(this, "MessageListHeader", function () { + return createFactory( + require("resource://devtools/client/netmonitor/src/components/messages/MessageListHeader.js") + ); +}); +loader.lazyGetter(this, "MessageListItem", function () { + return createFactory( + require("resource://devtools/client/netmonitor/src/components/messages/MessageListItem.js") + ); +}); + +const LEFT_MOUSE_BUTTON = 0; + +/** + * Renders the actual contents of the message list. + */ +class MessageListContent extends Component { + static get propTypes() { + return { + connector: PropTypes.object.isRequired, + startPanelContainer: PropTypes.object, + messages: PropTypes.array, + selectedMessage: PropTypes.object, + selectMessage: PropTypes.func.isRequired, + columns: PropTypes.object.isRequired, + isClosed: PropTypes.bool.isRequired, + closedConnectionDetails: PropTypes.object, + channelId: PropTypes.number, + channelType: PropTypes.string, + onSelectMessageDelta: PropTypes.func.isRequired, + }; + } + + constructor(props) { + super(props); + + this.onContextMenu = this.onContextMenu.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.messagesLimit = Services.prefs.getIntPref( + "devtools.netmonitor.msg.displayed-messages.limit" + ); + this.currentTruncatedNum = 0; + this.state = { + checked: false, + }; + this.pinnedToBottom = false; + this.initIntersectionObserver = false; + this.intersectionObserver = null; + this.toggleTruncationCheckBox = this.toggleTruncationCheckBox.bind(this); + } + + componentDidMount() { + const { startPanelContainer } = this.props; + const { scrollAnchor } = this.refs; + + if (scrollAnchor) { + // Always scroll to anchor when MessageListContent component first mounts. + scrollAnchor.scrollIntoView(); + } + this.setupScrollToBottom(startPanelContainer, scrollAnchor); + } + + componentDidUpdate(prevProps) { + const { startPanelContainer, channelId } = this.props; + const { scrollAnchor } = this.refs; + + // When messages are cleared, the previous scrollAnchor would be destroyed, so we need to reset this boolean. + if (!scrollAnchor) { + this.initIntersectionObserver = false; + } + + // In addition to that, we need to reset currentTruncatedNum + if (prevProps.messages.length && this.props.messages.length === 0) { + this.currentTruncatedNum = 0; + } + + // If a new connection is selected, scroll to anchor. + if (channelId !== prevProps.channelId && scrollAnchor) { + scrollAnchor.scrollIntoView(); + } + + // Do not autoscroll if the selection changed. This would cause + // the newly selected message to jump just after clicking in. + // (not user friendly) + // + // If the selection changed, we need to ensure that the newly + // selected message is properly scrolled into the visible area. + if (prevProps.selectedMessage === this.props.selectedMessage) { + this.setupScrollToBottom(startPanelContainer, scrollAnchor); + } else { + const head = document.querySelector("thead.message-list-headers-group"); + const selectedRow = document.querySelector( + "tr.message-list-item.selected" + ); + + if (selectedRow) { + const rowRect = selectedRow.getBoundingClientRect(); + const scrollableRect = startPanelContainer.getBoundingClientRect(); + const headRect = head.getBoundingClientRect(); + + if (rowRect.top <= scrollableRect.top) { + selectedRow.scrollIntoView(true); + + // We need to scroll a bit more to get the row out + // of the header. The header is sticky and overlaps + // part of the scrollable area. + startPanelContainer.scrollTop -= headRect.height; + } else if (rowRect.bottom > scrollableRect.bottom) { + selectedRow.scrollIntoView(false); + } + } + } + } + + componentWillUnmount() { + // Reset observables and boolean values. + const { scrollAnchor } = this.refs; + + if (this.intersectionObserver) { + if (scrollAnchor) { + this.intersectionObserver.unobserve(scrollAnchor); + } + this.initIntersectionObserver = false; + this.pinnedToBottom = false; + } + } + + setupScrollToBottom(startPanelContainer, scrollAnchor) { + if (startPanelContainer && scrollAnchor) { + // Initialize intersection observer. + if (!this.initIntersectionObserver) { + this.intersectionObserver = new IntersectionObserver( + () => { + // When scrollAnchor first comes into view, this.pinnedToBottom is set to true. + // When the anchor goes out of view, this callback function triggers again and toggles this.pinnedToBottom. + // Subsequent scroll into/out of view will toggle this.pinnedToBottom. + this.pinnedToBottom = !this.pinnedToBottom; + }, + { + root: startPanelContainer, + threshold: 0.1, + } + ); + if (this.intersectionObserver) { + this.intersectionObserver.observe(scrollAnchor); + this.initIntersectionObserver = true; + } + } + + if (this.pinnedToBottom) { + scrollAnchor.scrollIntoView(); + } + } + } + + toggleTruncationCheckBox() { + this.setState({ + checked: !this.state.checked, + }); + } + + onMouseDown(evt, item) { + if (evt.button === LEFT_MOUSE_BUTTON) { + this.props.selectMessage(item); + } + } + + onContextMenu(evt, item) { + evt.preventDefault(); + const { connector, channelType } = this.props; + this.contextMenu = new MessageListContextMenu({ + connector, + showBinaryOptions: + channelType === CHANNEL_TYPE.WEB_SOCKET && + item.opCode === WEB_SOCKET_OPCODE.BINARY, + }); + this.contextMenu.open(evt, item); + } + + /** + * Handler for keyboard events. For arrow up/down, page up/down, home/end, + * move the selection up or down. + */ + onKeyDown(evt) { + evt.preventDefault(); + evt.stopPropagation(); + let delta; + + switch (evt.key) { + case "ArrowUp": + delta = -1; + break; + case "ArrowDown": + delta = +1; + break; + case "PageUp": + delta = "PAGE_UP"; + break; + case "PageDown": + delta = "PAGE_DOWN"; + break; + case "Home": + delta = -Infinity; + break; + case "End": + delta = +Infinity; + break; + } + + if (delta) { + this.props.onSelectMessageDelta(delta); + } + } + + render() { + const { + messages, + selectedMessage, + connector, + columns, + isClosed, + closedConnectionDetails, + } = this.props; + + if (messages.length === 0) { + return div( + { className: "empty-notice message-list-empty-notice" }, + MESSAGES_EMPTY_TEXT + ); + } + + const visibleColumns = MESSAGE_HEADERS.filter( + header => columns[header.name] + ).map(col => col.name); + + let displayedMessages; + let MESSAGES_TRUNCATED; + const shouldTruncate = messages.length > this.messagesLimit; + if (shouldTruncate) { + // If the checkbox is checked, we display all messages after the currentTruncatedNum limit. + // If the checkbox is unchecked, we display all messages after the messagesLimit. + this.currentTruncatedNum = this.state.checked + ? this.currentTruncatedNum + : messages.length - this.messagesLimit; + displayedMessages = messages.slice(this.currentTruncatedNum); + + MESSAGES_TRUNCATED = PluralForm.get( + this.currentTruncatedNum, + L10N.getStr("netmonitor.ws.truncated-messages.warning") + ).replace("#1", this.currentTruncatedNum); + } else { + displayedMessages = messages; + } + + let connectionClosedMsg = CONNECTION_CLOSED_TEXT; + if ( + closedConnectionDetails && + closedConnectionDetails.code !== undefined && + closedConnectionDetails.reason !== undefined + ) { + connectionClosedMsg += `: ${closedConnectionDetails.code} ${closedConnectionDetails.reason}`; + } + return div( + {}, + table( + { className: "message-list-table" }, + MessageListHeader(), + tbody( + { + className: "message-list-body", + onKeyDown: this.onKeyDown, + }, + tr( + { + tabIndex: 0, + }, + td( + { + className: "truncated-messages-cell", + colSpan: visibleColumns.length, + }, + shouldTruncate && + div( + { + className: "truncated-messages-header", + }, + div( + { + className: "truncated-messages-container", + }, + div({ + className: "truncated-messages-warning-icon", + }), + div( + { + className: "truncated-message", + title: MESSAGES_TRUNCATED, + }, + MESSAGES_TRUNCATED + ) + ), + label( + { + className: "truncated-messages-checkbox-label", + title: TOGGLE_MESSAGES_TRUNCATION_TITLE, + }, + input({ + type: "checkbox", + className: "truncation-checkbox", + title: TOGGLE_MESSAGES_TRUNCATION_TITLE, + checked: this.state.checked, + onChange: this.toggleTruncationCheckBox, + }), + TOGGLE_MESSAGES_TRUNCATION + ) + ) + ) + ), + displayedMessages.map((item, index) => + MessageListItem({ + key: "message-list-item-" + index, + item, + index, + isSelected: item === selectedMessage, + onMouseDown: evt => this.onMouseDown(evt, item), + onContextMenu: evt => this.onContextMenu(evt, item), + connector, + visibleColumns, + }) + ) + ) + ), + isClosed && + p( + { + className: "msg-connection-closed-message", + }, + connectionClosedMsg + ), + hr({ + ref: "scrollAnchor", + className: "message-list-scroll-anchor", + }) + ); + } +} + +module.exports = connect( + state => ({ + selectedMessage: getSelectedMessage(state), + messages: getDisplayedMessages(state), + columns: state.messages.columns, + isClosed: isCurrentChannelClosed(state), + closedConnectionDetails: getClosedConnectionDetails(state), + channelType: state.messages.currentChannelType, + }), + dispatch => ({ + selectMessage: item => dispatch(Actions.selectMessage(item)), + onSelectMessageDelta: delta => dispatch(Actions.selectMessageDelta(delta)), + }) +)(MessageListContent); |