Message.js (13846B)
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 // React & Redux 8 const { 9 Component, 10 createFactory, 11 createElement, 12 } = require("resource://devtools/client/shared/vendor/react.mjs"); 13 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 14 const { 15 l10n, 16 } = require("resource://devtools/client/webconsole/utils/messages.js"); 17 const actions = require("resource://devtools/client/webconsole/actions/index.js"); 18 const { 19 MESSAGE_LEVEL, 20 MESSAGE_SOURCE, 21 MESSAGE_TYPE, 22 } = require("resource://devtools/client/webconsole/constants.js"); 23 const { 24 MessageIndent, 25 } = require("resource://devtools/client/webconsole/components/Output/MessageIndent.js"); 26 const MessageIcon = require("resource://devtools/client/webconsole/components/Output/MessageIcon.js"); 27 const FrameView = createFactory( 28 require("resource://devtools/client/shared/components/Frame.js") 29 ); 30 31 loader.lazyRequireGetter( 32 this, 33 "CollapseButton", 34 "resource://devtools/client/webconsole/components/Output/CollapseButton.js" 35 ); 36 loader.lazyRequireGetter( 37 this, 38 "MessageRepeat", 39 "resource://devtools/client/webconsole/components/Output/MessageRepeat.js" 40 ); 41 loader.lazyRequireGetter( 42 this, 43 "PropTypes", 44 "resource://devtools/client/shared/vendor/react-prop-types.js" 45 ); 46 loader.lazyRequireGetter( 47 this, 48 "SmartTrace", 49 "resource://devtools/client/shared/components/SmartTrace.js" 50 ); 51 52 class Message extends Component { 53 static get propTypes() { 54 return { 55 open: PropTypes.bool, 56 collapsible: PropTypes.bool, 57 collapseTitle: PropTypes.string, 58 disabled: PropTypes.bool, 59 onToggle: PropTypes.func, 60 source: PropTypes.string.isRequired, 61 type: PropTypes.string.isRequired, 62 level: PropTypes.string.isRequired, 63 indent: PropTypes.number.isRequired, 64 inWarningGroup: PropTypes.bool, 65 isBlockedNetworkMessage: PropTypes.bool, 66 topLevelClasses: PropTypes.array.isRequired, 67 messageBody: PropTypes.any.isRequired, 68 repeat: PropTypes.any, 69 frame: PropTypes.any, 70 attachment: PropTypes.any, 71 stacktrace: PropTypes.any, 72 messageId: PropTypes.string, 73 scrollToMessage: PropTypes.bool, 74 exceptionDocURL: PropTypes.string, 75 request: PropTypes.object, 76 dispatch: PropTypes.func, 77 timeStamp: PropTypes.number, 78 timestampsVisible: PropTypes.bool.isRequired, 79 serviceContainer: PropTypes.shape({ 80 emitForTests: PropTypes.func.isRequired, 81 onViewSource: PropTypes.func.isRequired, 82 onViewSourceInDebugger: PropTypes.func, 83 onViewSourceInStyleEditor: PropTypes.func, 84 openContextMenu: PropTypes.func.isRequired, 85 openLink: PropTypes.func.isRequired, 86 sourceMapURLService: PropTypes.any, 87 preventStacktraceInitialRenderDelay: PropTypes.bool, 88 }), 89 notes: PropTypes.arrayOf( 90 PropTypes.shape({ 91 messageBody: PropTypes.string.isRequired, 92 frame: PropTypes.any, 93 }) 94 ), 95 maybeScrollToBottom: PropTypes.func, 96 message: PropTypes.object.isRequired, 97 }; 98 } 99 100 static get defaultProps() { 101 return { 102 indent: 0, 103 }; 104 } 105 106 constructor(props) { 107 super(props); 108 this.onLearnMoreClick = this.onLearnMoreClick.bind(this); 109 this.toggleMessage = this.toggleMessage.bind(this); 110 this.onContextMenu = this.onContextMenu.bind(this); 111 this.renderIcon = this.renderIcon.bind(this); 112 } 113 114 componentDidMount() { 115 if (this.messageNode) { 116 if (this.props.scrollToMessage) { 117 this.messageNode.scrollIntoView(); 118 } 119 120 this.emitNewMessage(this.messageNode); 121 } 122 } 123 124 componentDidCatch(e) { 125 this.setState({ error: e }); 126 } 127 128 // Event used in tests. Some message types don't pass it in because existing tests 129 // did not emit for them. 130 emitNewMessage(node) { 131 const { serviceContainer, messageId, timeStamp } = this.props; 132 serviceContainer.emitForTests( 133 "new-messages", 134 new Set([{ node, messageId, timeStamp }]) 135 ); 136 } 137 138 onLearnMoreClick(e) { 139 const { exceptionDocURL } = this.props; 140 this.props.serviceContainer.openLink(exceptionDocURL, e); 141 e.preventDefault(); 142 } 143 144 toggleMessage(e) { 145 // Don't bubble up to the main App component, which redirects focus to input, 146 // making difficult for screen reader users to review output 147 e.stopPropagation(); 148 const { open, dispatch, messageId, onToggle, disabled } = this.props; 149 150 if (disabled) { 151 return; 152 } 153 154 // Early exit the function to avoid the message to collapse if the user is 155 // selecting a range in the toggle message. 156 const window = e.target.ownerDocument.defaultView; 157 if (window.getSelection && window.getSelection().type === "Range") { 158 return; 159 } 160 161 // If defined on props, we let the onToggle() method handle the toggling, 162 // otherwise we toggle the message open/closed ourselves. 163 if (onToggle) { 164 onToggle(messageId, e); 165 } else if (open) { 166 dispatch(actions.messageClose(messageId)); 167 } else { 168 dispatch(actions.messageOpen(messageId)); 169 } 170 } 171 172 onContextMenu(e) { 173 const { serviceContainer, source, request, messageId } = this.props; 174 const messageInfo = { 175 source, 176 request, 177 messageId, 178 }; 179 serviceContainer.openContextMenu(e, messageInfo); 180 e.stopPropagation(); 181 e.preventDefault(); 182 } 183 184 renderIcon() { 185 const { level, inWarningGroup, isBlockedNetworkMessage, type, disabled } = 186 this.props; 187 188 if (inWarningGroup) { 189 return undefined; 190 } 191 192 if (disabled) { 193 return MessageIcon({ 194 level: MESSAGE_LEVEL.INFO, 195 type, 196 title: l10n.getStr("webconsole.disableIcon.title"), 197 }); 198 } 199 200 if (isBlockedNetworkMessage) { 201 return MessageIcon({ 202 level: MESSAGE_LEVEL.ERROR, 203 type: "blockedReason", 204 }); 205 } 206 207 return MessageIcon({ 208 level, 209 type, 210 }); 211 } 212 213 renderTimestamp() { 214 if (!this.props.timestampsVisible) { 215 return null; 216 } 217 218 const timestamp = this.props.timeStamp || Date.now(); 219 220 return dom.span( 221 { 222 className: "timestamp devtools-monospace", 223 title: l10n.dateString(timestamp), 224 }, 225 l10n.timestampString(timestamp) 226 ); 227 } 228 229 renderErrorState() { 230 const newBugUrl = 231 "https://bugzilla.mozilla.org/enter_bug.cgi?product=DevTools&component=Console"; 232 const timestampEl = this.renderTimestamp(); 233 234 return dom.div( 235 { 236 className: "message error message-did-catch", 237 }, 238 timestampEl, 239 MessageIcon({ level: "error" }), 240 dom.span( 241 { className: "message-body-wrapper" }, 242 dom.span( 243 { 244 className: "message-flex-body", 245 }, 246 // Add whitespaces for formatting when copying to the clipboard. 247 timestampEl ? " " : null, 248 dom.span( 249 { className: "message-body devtools-monospace" }, 250 l10n.getFormatStr("webconsole.message.componentDidCatch.label", [ 251 newBugUrl, 252 ]), 253 dom.button( 254 { 255 className: "devtools-button", 256 onClick: () => 257 navigator.clipboard.writeText( 258 JSON.stringify( 259 this.props.message, 260 function (key, value) { 261 if (key === "targetFront") { 262 return null; 263 } 264 265 // The message can hold one or multiple fronts that we need to serialize 266 if (value?.getGrip) { 267 return value.getGrip(); 268 } 269 return value; 270 }, 271 2 272 ) 273 ), 274 }, 275 l10n.getStr( 276 "webconsole.message.componentDidCatch.copyButton.label" 277 ) 278 ) 279 ) 280 ) 281 ), 282 dom.br() 283 ); 284 } 285 286 // eslint-disable-next-line complexity 287 render() { 288 if (this.state && this.state.error) { 289 return this.renderErrorState(); 290 } 291 292 const { 293 open, 294 collapsible, 295 collapseTitle, 296 disabled, 297 source, 298 type, 299 level, 300 indent, 301 inWarningGroup, 302 topLevelClasses, 303 messageBody, 304 frame, 305 stacktrace, 306 serviceContainer, 307 exceptionDocURL, 308 messageId, 309 notes, 310 } = this.props; 311 312 topLevelClasses.push("message", source, type, level); 313 if (open) { 314 topLevelClasses.push("open"); 315 } 316 317 if (disabled) { 318 topLevelClasses.push("disabled"); 319 } 320 321 const timestampEl = this.renderTimestamp(); 322 const icon = this.renderIcon(); 323 324 // Figure out if there is an expandable part to the message. 325 let attachment = null; 326 if (this.props.attachment) { 327 attachment = this.props.attachment; 328 } else if (stacktrace && open) { 329 const smartTraceAttributes = { 330 stacktrace, 331 onViewSourceInDebugger: 332 serviceContainer.onViewSourceInDebugger || 333 serviceContainer.onViewSource, 334 onViewSource: serviceContainer.onViewSource, 335 onReady: this.props.maybeScrollToBottom, 336 sourceMapURLService: serviceContainer.sourceMapURLService, 337 }; 338 339 if (serviceContainer.preventStacktraceInitialRenderDelay) { 340 smartTraceAttributes.initialRenderDelay = 0; 341 } 342 343 attachment = dom.div( 344 { 345 className: "stacktrace devtools-monospace", 346 }, 347 createElement(SmartTrace, smartTraceAttributes) 348 ); 349 } 350 351 // If there is an expandable part, make it collapsible. 352 let collapse = null; 353 if (collapsible && !disabled) { 354 collapse = createElement(CollapseButton, { 355 open, 356 title: collapseTitle, 357 onClick: this.toggleMessage, 358 }); 359 } 360 361 let notesNodes; 362 if (notes) { 363 notesNodes = notes.map(note => 364 dom.span( 365 { className: "message-flex-body error-note" }, 366 dom.span( 367 { className: "message-body devtools-monospace" }, 368 "note: " + note.messageBody 369 ), 370 dom.span( 371 { className: "message-location devtools-monospace" }, 372 note.frame 373 ? FrameView({ 374 frame: note.frame, 375 onClick: serviceContainer 376 ? serviceContainer.onViewSourceInDebugger || 377 serviceContainer.onViewSource 378 : undefined, 379 showEmptyPathAsHost: true, 380 sourceMapURLService: serviceContainer 381 ? serviceContainer.sourceMapURLService 382 : undefined, 383 }) 384 : null 385 ) 386 ) 387 ); 388 } else { 389 notesNodes = []; 390 } 391 392 const repeat = 393 this.props.repeat && this.props.repeat > 1 394 ? createElement(MessageRepeat, { repeat: this.props.repeat }) 395 : null; 396 397 let onFrameClick; 398 if (serviceContainer && frame) { 399 if (source === MESSAGE_SOURCE.CSS) { 400 onFrameClick = 401 serviceContainer.onViewSourceInStyleEditor || 402 serviceContainer.onViewSource; 403 } else { 404 // Point everything else to debugger, if source not available, 405 // it will fall back to view-source. 406 onFrameClick = 407 serviceContainer.onViewSourceInDebugger || 408 serviceContainer.onViewSource; 409 } 410 } 411 412 // Configure the location. 413 const location = frame 414 ? FrameView({ 415 className: "message-location devtools-monospace", 416 frame, 417 onClick: onFrameClick, 418 showEmptyPathAsHost: true, 419 sourceMapURLService: serviceContainer 420 ? serviceContainer.sourceMapURLService 421 : undefined, 422 messageSource: source, 423 }) 424 : null; 425 426 let learnMore; 427 if (exceptionDocURL) { 428 learnMore = dom.a( 429 { 430 className: "learn-more-link webconsole-learn-more-link", 431 href: exceptionDocURL, 432 title: exceptionDocURL.split("?")[0], 433 onClick: this.onLearnMoreClick, 434 }, 435 `[${l10n.getStr("webConsoleMoreInfoLabel")}]` 436 ); 437 } 438 439 const bodyElements = Array.isArray(messageBody) 440 ? messageBody 441 : [messageBody]; 442 443 return dom.div( 444 { 445 className: topLevelClasses.join(" "), 446 onContextMenu: this.onContextMenu, 447 ref: node => { 448 this.messageNode = node; 449 }, 450 "data-message-id": messageId, 451 "data-indent": indent || 0, 452 "aria-live": type === MESSAGE_TYPE.COMMAND ? "off" : "polite", 453 }, 454 timestampEl, 455 MessageIndent({ 456 indent, 457 inWarningGroup, 458 }), 459 this.props.isBlockedNetworkMessage ? collapse : icon, 460 this.props.isBlockedNetworkMessage ? icon : collapse, 461 dom.span( 462 { className: "message-body-wrapper" }, 463 dom.span( 464 { 465 className: "message-flex-body", 466 onClick: collapsible ? this.toggleMessage : undefined, 467 }, 468 // Add whitespaces for formatting when copying to the clipboard. 469 timestampEl ? " " : null, 470 dom.span( 471 { className: "message-body devtools-monospace" }, 472 ...bodyElements, 473 learnMore 474 ), 475 repeat ? " " : null, 476 repeat, 477 " ", 478 location 479 ), 480 attachment, 481 ...notesNodes 482 ), 483 // If an attachment is displayed, the final newline is handled by the attachment. 484 attachment ? null : dom.br() 485 ); 486 } 487 } 488 489 module.exports = Message;