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);