TopSite.jsx (34145B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; 6 import { 7 MIN_RICH_FAVICON_SIZE, 8 MIN_SMALL_FAVICON_SIZE, 9 TOP_SITES_CONTEXT_MENU_OPTIONS, 10 TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS, 11 TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS, 12 TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS, 13 TOP_SITES_SOURCE, 14 } from "./TopSitesConstants"; 15 import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; 16 import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats"; 17 import React from "react"; 18 import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; 19 import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; 20 import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; 21 import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper"; 22 import { connect } from "react-redux"; 23 import { MessageWrapper } from "../MessageWrapper/MessageWrapper"; 24 import { ShortcutFeatureHighlight } from "../DiscoveryStreamComponents/FeatureHighlight/ShortcutFeatureHighlight"; 25 26 const SPOC_TYPE = "SPOC"; 27 const NEWTAB_SOURCE = "newtab"; 28 29 // For cases if we want to know if this is sponsored by either sponsored_position or type. 30 // We have two sources for sponsored topsites, and 31 // sponsored_position is set by one sponsored source, and type is set by another. 32 // This is not called in all cases, sometimes we want to know if it's one source 33 // or the other. This function is only applicable in cases where we only care if it's either. 34 function isSponsored(link) { 35 return link?.sponsored_position || link?.type === SPOC_TYPE; 36 } 37 38 export class TopSiteLink extends React.PureComponent { 39 constructor(props) { 40 super(props); 41 this.state = { screenshotImage: null }; 42 this.onDragEvent = this.onDragEvent.bind(this); 43 this.onKeyPress = this.onKeyPress.bind(this); 44 this.shouldShowOMCHighlight = this.shouldShowOMCHighlight.bind(this); 45 } 46 47 /* 48 * Helper to determine whether the drop zone should allow a drop. We only allow 49 * dropping top sites for now. We don't allow dropping on sponsored top sites 50 * as their position is fixed. 51 */ 52 _allowDrop(e) { 53 return ( 54 (this.dragged || !isSponsored(this.props.link)) && 55 e.dataTransfer.types.includes("text/topsite-index") 56 ); 57 } 58 59 onDragEvent(event) { 60 switch (event.type) { 61 case "click": 62 // Stop any link clicks if we started any dragging 63 if (this.dragged) { 64 event.preventDefault(); 65 } 66 break; 67 case "dragstart": 68 event.target.blur(); 69 if (isSponsored(this.props.link)) { 70 event.preventDefault(); 71 break; 72 } 73 this.dragged = true; 74 event.dataTransfer.effectAllowed = "move"; 75 event.dataTransfer.setData("text/topsite-index", this.props.index); 76 this.props.onDragEvent( 77 event, 78 this.props.index, 79 this.props.link, 80 this.props.title 81 ); 82 break; 83 case "dragend": 84 this.props.onDragEvent(event); 85 break; 86 case "dragenter": 87 case "dragover": 88 case "drop": 89 if (this._allowDrop(event)) { 90 event.preventDefault(); 91 this.props.onDragEvent(event, this.props.index); 92 } 93 break; 94 case "mousedown": 95 // Block the scroll wheel from appearing for middle clicks on search top sites 96 if (event.button === 1 && this.props.link.searchTopSite) { 97 event.preventDefault(); 98 } 99 // Reset at the first mouse event of a potential drag 100 this.dragged = false; 101 break; 102 } 103 } 104 105 /** 106 * Helper to obtain the next state based on nextProps and prevState. 107 * 108 * NOTE: Rename this method to getDerivedStateFromProps when we update React 109 * to >= 16.3. We will need to update tests as well. We cannot rename this 110 * method to getDerivedStateFromProps now because there is a mismatch in 111 * the React version that we are using for both testing and production. 112 * (i.e. react-test-render => "16.3.2", react => "16.2.0"). 113 * 114 * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. 115 */ 116 static getNextStateFromProps(nextProps, prevState) { 117 const { screenshot } = nextProps.link; 118 const imageInState = ScreenshotUtils.isRemoteImageLocal( 119 prevState.screenshotImage, 120 screenshot 121 ); 122 if (imageInState) { 123 return null; 124 } 125 126 // Since image was updated, attempt to revoke old image blob URL, if it exists. 127 ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage); 128 129 return { 130 screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot), 131 }; 132 } 133 134 // NOTE: Remove this function when we update React to >= 16.3 since React will 135 // call getDerivedStateFromProps automatically. We will also need to 136 // rename getNextStateFromProps to getDerivedStateFromProps. 137 componentWillMount() { 138 const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state); 139 if (nextState) { 140 this.setState(nextState); 141 } 142 } 143 144 // NOTE: Remove this function when we update React to >= 16.3 since React will 145 // call getDerivedStateFromProps automatically. We will also need to 146 // rename getNextStateFromProps to getDerivedStateFromProps. 147 componentWillReceiveProps(nextProps) { 148 const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state); 149 if (nextState) { 150 this.setState(nextState); 151 } 152 } 153 154 componentWillUnmount() { 155 ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage); 156 } 157 158 onKeyPress(event) { 159 // If we have tabbed to a search shortcut top site, and we click 'enter', 160 // we should execute the onClick function. This needs to be added because 161 // search top sites are anchor tags without an href. See bug 1483135 162 if ( 163 event.key === "Enter" && 164 (this.props.link.searchTopSite || this.props.isAddButton) 165 ) { 166 this.props.onClick(event); 167 } 168 } 169 170 /* 171 * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number 172 * Apply that random number to the color array. The same url will always generate the same color. 173 */ 174 generateColor() { 175 let { title, colors } = this.props; 176 if (!colors) { 177 return ""; 178 } 179 180 let colorArray = colors.split(","); 181 182 const hashStr = str => { 183 let hash = 0; 184 for (let i = 0; i < str.length; i++) { 185 let charCode = str.charCodeAt(i); 186 hash += charCode; 187 } 188 return hash; 189 }; 190 191 let hash = hashStr(title); 192 let index = hash % colorArray.length; 193 return colorArray[index]; 194 } 195 196 calculateStyle() { 197 const { defaultStyle, link } = this.props; 198 199 const { tippyTopIcon, faviconSize } = link; 200 let imageClassName; 201 let imageStyle; 202 let showSmallFavicon = false; 203 let smallFaviconStyle; 204 let hasScreenshotImage = 205 this.state.screenshotImage && this.state.screenshotImage.url; 206 let selectedColor; 207 208 if (defaultStyle) { 209 // force no styles (letter fallback) even if the link has imagery 210 selectedColor = this.generateColor(); 211 } else if (link.searchTopSite) { 212 imageClassName = "top-site-icon rich-icon"; 213 imageStyle = { 214 backgroundColor: link.backgroundColor, 215 backgroundImage: `url(${tippyTopIcon})`, 216 }; 217 smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` }; 218 } else if (link.customScreenshotURL) { 219 // assume high quality custom screenshot and use rich icon styles and class names 220 imageClassName = "top-site-icon rich-icon"; 221 imageStyle = { 222 backgroundColor: link.backgroundColor, 223 backgroundImage: hasScreenshotImage 224 ? `url(${this.state.screenshotImage.url})` 225 : "", 226 }; 227 } else if ( 228 tippyTopIcon || 229 link.type === SPOC_TYPE || 230 faviconSize >= MIN_RICH_FAVICON_SIZE 231 ) { 232 // styles and class names for top sites with rich icons 233 imageClassName = "top-site-icon rich-icon"; 234 imageStyle = { 235 backgroundColor: link.backgroundColor, 236 backgroundImage: `url(${tippyTopIcon || link.favicon})`, 237 }; 238 } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) { 239 showSmallFavicon = true; 240 smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; 241 } else { 242 selectedColor = this.generateColor(); 243 imageClassName = ""; 244 } 245 246 return { 247 showSmallFavicon, 248 smallFaviconStyle, 249 imageStyle, 250 imageClassName, 251 selectedColor, 252 }; 253 } 254 255 shouldShowOMCHighlight(componentId) { 256 const messageData = this.props.Messages?.messageData; 257 if (!messageData || Object.keys(messageData).length === 0) { 258 return false; 259 } 260 return messageData?.content?.messageType === componentId; 261 } 262 263 render() { 264 const { 265 children, 266 className, 267 isDraggable, 268 link, 269 onClick, 270 title, 271 isAddButton, 272 visibleTopSites, 273 } = this.props; 274 275 const topSiteOuterClassName = `top-site-outer${ 276 className ? ` ${className}` : "" 277 }${link.isDragged ? " dragged" : ""}${ 278 link.searchTopSite ? " search-shortcut" : "" 279 }`; 280 const [letterFallback] = title; 281 const { 282 showSmallFavicon, 283 smallFaviconStyle, 284 imageStyle, 285 imageClassName, 286 selectedColor, 287 } = this.calculateStyle(); 288 289 const addButtonLabell10n = { 290 "data-l10n-id": "newtab-topsites-add-shortcut-label", 291 }; 292 const addButtonTitlel10n = { 293 "data-l10n-id": "newtab-topsites-add-shortcut-title", 294 }; 295 const addPinnedTitlel10n = { 296 "data-l10n-id": "topsite-label-pinned", 297 "data-l10n-args": JSON.stringify({ title }), 298 }; 299 300 let draggableProps = {}; 301 if (isDraggable) { 302 draggableProps = { 303 onClick: this.onDragEvent, 304 onDragEnd: this.onDragEvent, 305 onDragStart: this.onDragEvent, 306 onMouseDown: this.onDragEvent, 307 }; 308 } 309 310 let impressionStats = null; 311 if (link.type === SPOC_TYPE) { 312 // Record impressions for Pocket tiles. 313 impressionStats = ( 314 <ImpressionStats 315 flightId={link.flightId} 316 rows={[ 317 { 318 id: link.id, 319 pos: link.pos, 320 shim: link.shim && link.shim.impression, 321 advertiser: title.toLocaleLowerCase(), 322 }, 323 ]} 324 dispatch={this.props.dispatch} 325 source={TOP_SITES_SOURCE} 326 /> 327 ); 328 } else if (isSponsored(link)) { 329 // Record impressions for non-Pocket sponsored tiles. 330 impressionStats = ( 331 <TopSiteImpressionWrapper 332 actionType={at.TOP_SITES_SPONSORED_IMPRESSION_STATS} 333 tile={{ 334 position: this.props.index, 335 tile_id: link.sponsored_tile_id || -1, 336 reporting_url: link.sponsored_impression_url, 337 advertiser: title.toLocaleLowerCase(), 338 source: NEWTAB_SOURCE, 339 visible_topsites: visibleTopSites, 340 frecency_boosted: link.type === "frecency-boost", 341 attribution: link.attribution, 342 }} 343 // For testing. 344 IntersectionObserver={this.props.IntersectionObserver} 345 document={this.props.document} 346 dispatch={this.props.dispatch} 347 /> 348 ); 349 } else { 350 // Record impressions for organic tiles. 351 impressionStats = ( 352 <TopSiteImpressionWrapper 353 actionType={at.TOP_SITES_ORGANIC_IMPRESSION_STATS} 354 tile={{ 355 position: this.props.index, 356 source: NEWTAB_SOURCE, 357 isPinned: this.props.link.isPinned, 358 guid: this.props.link.guid, 359 visible_topsites: visibleTopSites, 360 smartScores: this.props.link.scores, 361 smartWeights: this.props.link.weights, 362 }} 363 // For testing. 364 IntersectionObserver={this.props.IntersectionObserver} 365 document={this.props.document} 366 dispatch={this.props.dispatch} 367 /> 368 ); 369 } 370 371 return ( 372 <li 373 className={topSiteOuterClassName} 374 onDrop={this.onDragEvent} 375 onDragOver={this.onDragEvent} 376 onDragEnter={this.onDragEvent} 377 onDragLeave={this.onDragEvent} 378 ref={this.props.setRef} 379 {...draggableProps} 380 > 381 <div className="top-site-inner"> 382 {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} 383 {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} 384 <a 385 className="top-site-button" 386 href={link.searchTopSite ? undefined : link.url} 387 tabIndex={this.props.tabIndex} 388 onKeyPress={this.onKeyPress} 389 onClick={onClick} 390 draggable={true} 391 data-is-sponsored-link={!!link.sponsored_tile_id} 392 onFocus={this.props.onFocus} 393 aria-label={link.isPinned ? undefined : title} 394 {...(isAddButton && { ...addButtonTitlel10n })} 395 {...(!isAddButton && { title })} 396 {...(link.isPinned && { ...addPinnedTitlel10n })} 397 data-l10n-args={JSON.stringify({ title })} 398 > 399 {link.isPinned && <div className="icon icon-pin-small" />} 400 <div className="tile" aria-hidden={true}> 401 <div 402 className={ 403 selectedColor 404 ? "icon-wrapper letter-fallback" 405 : "icon-wrapper" 406 } 407 data-fallback={letterFallback} 408 style={selectedColor ? { backgroundColor: selectedColor } : {}} 409 > 410 <div className={imageClassName} style={imageStyle} /> 411 {showSmallFavicon && ( 412 <div 413 className="top-site-icon default-icon" 414 data-fallback={smallFaviconStyle ? "" : letterFallback} 415 style={smallFaviconStyle} 416 /> 417 )} 418 </div> 419 </div> 420 <div 421 className={`title${link.isPinned ? " has-icon pinned" : ""}${ 422 link.type === SPOC_TYPE || link.show_sponsored_label 423 ? " sponsored" 424 : "" 425 }`} 426 > 427 <span 428 className="title-label" 429 dir="auto" 430 {...(isAddButton && { ...addButtonLabell10n })} 431 > 432 {link.searchTopSite && ( 433 <div className="top-site-icon search-topsite" /> 434 )} 435 {title || <br />} 436 </span> 437 <span 438 className="sponsored-label" 439 data-l10n-id="newtab-topsite-sponsored" 440 /> 441 </div> 442 </a> 443 {isAddButton && this.shouldShowOMCHighlight("ShortcutHighlight") && ( 444 <MessageWrapper 445 dispatch={this.props.dispatch} 446 onClick={e => e.stopPropagation()} 447 > 448 <ShortcutFeatureHighlight 449 dispatch={this.props.dispatch} 450 feature="FEATURE_SHORTCUT_HIGHLIGHT" 451 position="inset-block-end inset-inline-start" 452 messageData={this.props.Messages?.messageData} 453 /> 454 </MessageWrapper> 455 )} 456 {children} 457 {impressionStats} 458 </div> 459 </li> 460 ); 461 } 462 } 463 TopSiteLink.defaultProps = { 464 title: "", 465 link: {}, 466 isDraggable: true, 467 }; 468 469 export class TopSite extends React.PureComponent { 470 constructor(props) { 471 super(props); 472 this.state = { showContextMenu: false }; 473 this.onLinkClick = this.onLinkClick.bind(this); 474 this.onMenuUpdate = this.onMenuUpdate.bind(this); 475 } 476 477 /** 478 * Report to telemetry additional information about the item. 479 */ 480 _getTelemetryInfo() { 481 const value = { icon_type: this.props.link.iconType }; 482 // Filter out "not_pinned" type for being the default 483 if (this.props.link.isPinned) { 484 value.card_type = "pinned"; 485 } 486 if (this.props.link.searchTopSite) { 487 // Set the card_type as "search" regardless of its pinning status 488 value.card_type = "search"; 489 value.search_vendor = this.props.link.hostname; 490 } 491 if (isSponsored(this.props.link)) { 492 value.card_type = "spoc"; 493 } 494 return { value }; 495 } 496 497 userEvent(event) { 498 this.props.dispatch( 499 ac.UserEvent( 500 Object.assign( 501 { 502 event, 503 source: TOP_SITES_SOURCE, 504 action_position: this.props.index, 505 }, 506 this._getTelemetryInfo() 507 ) 508 ) 509 ); 510 } 511 512 onLinkClick(event) { 513 this.userEvent("CLICK"); 514 515 // Specially handle a top site link click for "typed" frecency bonus as 516 // specified as a property on the link. 517 event.preventDefault(); 518 const { altKey, button, ctrlKey, metaKey, shiftKey } = event; 519 if (!this.props.link.searchTopSite) { 520 this.props.dispatch( 521 ac.OnlyToMain({ 522 type: at.OPEN_LINK, 523 data: Object.assign(this.props.link, { 524 event: { altKey, button, ctrlKey, metaKey, shiftKey }, 525 is_sponsored: !!this.props.link.sponsored_tile_id, 526 }), 527 }) 528 ); 529 530 if (this.props.link.type === SPOC_TYPE) { 531 // Record a Pocket-specific click. 532 this.props.dispatch( 533 ac.ImpressionStats({ 534 source: TOP_SITES_SOURCE, 535 click: 0, 536 tiles: [ 537 { 538 id: this.props.link.id, 539 pos: this.props.link.pos, 540 shim: this.props.link.shim && this.props.link.shim.click, 541 }, 542 ], 543 }) 544 ); 545 546 // Record a click for a Pocket sponsored tile. 547 // This first event is for the shim property 548 // and is used by our ad service provider. 549 this.props.dispatch( 550 ac.DiscoveryStreamUserEvent({ 551 event: "CLICK", 552 source: TOP_SITES_SOURCE, 553 action_position: this.props.link.pos, 554 value: { 555 card_type: "spoc", 556 tile_id: this.props.link.id, 557 shim: this.props.link.shim && this.props.link.shim.click, 558 attribution: this.props.link.attribution, 559 }, 560 }) 561 ); 562 563 // A second event is recoded for internal usage. 564 const title = this.props.link.label || this.props.link.hostname; 565 this.props.dispatch( 566 ac.OnlyToMain({ 567 type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, 568 data: { 569 type: "click", 570 position: this.props.link.pos, 571 tile_id: this.props.link.id, 572 advertiser: title.toLocaleLowerCase(), 573 source: NEWTAB_SOURCE, 574 attribution: this.props.link.attribution, 575 }, 576 }) 577 ); 578 } else if (isSponsored(this.props.link)) { 579 // Record a click for a non-Pocket sponsored tile. 580 const title = this.props.link.label || this.props.link.hostname; 581 this.props.dispatch( 582 ac.OnlyToMain({ 583 type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, 584 data: { 585 type: "click", 586 position: this.props.index, 587 tile_id: this.props.link.sponsored_tile_id || -1, 588 reporting_url: this.props.link.sponsored_click_url, 589 advertiser: title.toLocaleLowerCase(), 590 source: NEWTAB_SOURCE, 591 visible_topsites: this.props.visibleTopSites, 592 frecency_boosted: this.props.link.type === "frecency-boost", 593 attribution: this.props.link.attribution, 594 }, 595 }) 596 ); 597 } else { 598 // Record a click for an organic tile. 599 this.props.dispatch( 600 ac.OnlyToMain({ 601 type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, 602 data: { 603 type: "click", 604 position: this.props.index, 605 source: NEWTAB_SOURCE, 606 isPinned: this.props.link.isPinned, 607 guid: this.props.link.guid, 608 visible_topsites: this.props.visibleTopSites, 609 smartScores: this.props.link.scores, 610 smartWeights: this.props.link.weights, 611 }, 612 }) 613 ); 614 } 615 616 if (this.props.link.sendAttributionRequest) { 617 this.props.dispatch( 618 ac.OnlyToMain({ 619 type: at.PARTNER_LINK_ATTRIBUTION, 620 data: { 621 targetURL: this.props.link.url, 622 source: "newtab", 623 }, 624 }) 625 ); 626 } 627 } else { 628 this.props.dispatch( 629 ac.OnlyToMain({ 630 type: at.FILL_SEARCH_TERM, 631 data: { label: this.props.link.label }, 632 }) 633 ); 634 } 635 } 636 637 onMenuUpdate(isOpen) { 638 if (isOpen) { 639 this.props.onActivate(this.props.index); 640 } else { 641 this.props.onActivate(); 642 } 643 } 644 645 render() { 646 const { props } = this; 647 const { link } = props; 648 const isContextMenuOpen = props.activeIndex === props.index; 649 const title = link.label || link.title || link.hostname; 650 let menuOptions; 651 if (link.sponsored_position) { 652 menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS; 653 } else if (link.searchTopSite) { 654 menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; 655 } else if (link.type === SPOC_TYPE) { 656 menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; 657 } else { 658 menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS; 659 } 660 661 return ( 662 <TopSiteLink 663 {...props} 664 onClick={this.onLinkClick} 665 onDragEvent={this.props.onDragEvent} 666 className={`${props.className || ""}${ 667 isContextMenuOpen ? " active" : "" 668 }`} 669 title={title} 670 setPref={this.props.setPref} 671 tabIndex={this.props.tabIndex} 672 onFocus={this.props.onFocus} 673 > 674 <div> 675 <ContextMenuButton 676 tooltip="newtab-menu-content-tooltip" 677 tooltipArgs={{ title }} 678 onUpdate={this.onMenuUpdate} 679 tabIndex={this.props.tabIndex} 680 onFocus={this.props.onFocus} 681 > 682 <LinkMenu 683 dispatch={props.dispatch} 684 index={props.index} 685 onUpdate={this.onMenuUpdate} 686 options={menuOptions} 687 site={link} 688 shouldSendImpressionStats={link.type === SPOC_TYPE} 689 siteInfo={this._getTelemetryInfo()} 690 source={TOP_SITES_SOURCE} 691 /> 692 </ContextMenuButton> 693 </div> 694 </TopSiteLink> 695 ); 696 } 697 } 698 TopSite.defaultProps = { 699 link: {}, 700 onActivate() {}, 701 }; 702 703 export class TopSiteAddButton extends React.PureComponent { 704 constructor(props) { 705 super(props); 706 this.onEditButtonClick = this.onEditButtonClick.bind(this); 707 } 708 709 onEditButtonClick() { 710 this.props.dispatch({ 711 type: at.TOP_SITES_EDIT, 712 data: { index: this.props.index }, 713 }); 714 } 715 716 render() { 717 return ( 718 <TopSiteLink 719 {...this.props} 720 isAddButton={true} 721 className={`add-button ${this.props.className || ""}`} 722 onClick={this.onEditButtonClick} 723 setPref={this.props.setPref} 724 isDraggable={false} 725 tabIndex={this.props.tabIndex} 726 /> 727 ); 728 } 729 } 730 731 export class TopSitePlaceholder extends React.PureComponent { 732 render() { 733 return ( 734 <TopSiteLink 735 {...this.props} 736 className={`placeholder ${this.props.className || ""}`} 737 isDraggable={false} 738 /> 739 ); 740 } 741 } 742 743 export class _TopSiteList extends React.PureComponent { 744 static get DEFAULT_STATE() { 745 return { 746 activeIndex: null, 747 draggedIndex: null, 748 draggedSite: null, 749 draggedTitle: null, 750 topSitesPreview: null, 751 focusedIndex: 0, 752 }; 753 } 754 755 constructor(props) { 756 super(props); 757 this.state = _TopSiteList.DEFAULT_STATE; 758 this.onDragEvent = this.onDragEvent.bind(this); 759 this.onActivate = this.onActivate.bind(this); 760 this.onWrapperFocus = this.onWrapperFocus.bind(this); 761 this.onTopsiteFocus = this.onTopsiteFocus.bind(this); 762 this.onWrapperBlur = this.onWrapperBlur.bind(this); 763 this.onKeyDown = this.onKeyDown.bind(this); 764 } 765 766 componentWillReceiveProps(nextProps) { 767 if (this.state.draggedSite) { 768 const prevTopSites = this.props.TopSites && this.props.TopSites.rows; 769 const newTopSites = nextProps.TopSites && nextProps.TopSites.rows; 770 if ( 771 prevTopSites && 772 prevTopSites[this.state.draggedIndex] && 773 prevTopSites[this.state.draggedIndex].url === 774 this.state.draggedSite.url && 775 (!newTopSites[this.state.draggedIndex] || 776 newTopSites[this.state.draggedIndex].url !== 777 this.state.draggedSite.url) 778 ) { 779 // We got the new order from the redux store via props. We can clear state now. 780 this.setState(_TopSiteList.DEFAULT_STATE); 781 } 782 } 783 } 784 785 userEvent(event, index) { 786 this.props.dispatch( 787 ac.UserEvent({ 788 event, 789 source: TOP_SITES_SOURCE, 790 action_position: index, 791 }) 792 ); 793 } 794 795 onDragEvent(event, index, link, title) { 796 switch (event.type) { 797 case "dragstart": 798 this.dropped = false; 799 this.setState({ 800 draggedIndex: index, 801 draggedSite: link, 802 draggedTitle: title, 803 activeIndex: null, 804 }); 805 this.userEvent("DRAG", index); 806 break; 807 case "dragend": 808 if (!this.dropped) { 809 // If there was no drop event, reset the state to the default. 810 this.setState(_TopSiteList.DEFAULT_STATE); 811 } 812 break; 813 case "dragenter": 814 if (index === this.state.draggedIndex) { 815 this.setState({ topSitesPreview: null }); 816 } else { 817 this.setState({ 818 topSitesPreview: this._makeTopSitesPreview(index), 819 }); 820 } 821 break; 822 case "drop": 823 if (index !== this.state.draggedIndex) { 824 this.dropped = true; 825 this.props.dispatch( 826 ac.AlsoToMain({ 827 type: at.TOP_SITES_INSERT, 828 data: { 829 site: { 830 url: this.state.draggedSite.url, 831 label: this.state.draggedTitle, 832 customScreenshotURL: 833 this.state.draggedSite.customScreenshotURL, 834 // Only if the search topsites experiment is enabled 835 ...(this.state.draggedSite.searchTopSite && { 836 searchTopSite: true, 837 }), 838 }, 839 index, 840 draggedFromIndex: this.state.draggedIndex, 841 }, 842 }) 843 ); 844 this.userEvent("DROP", index); 845 } 846 break; 847 } 848 } 849 850 _getTopSites() { 851 // Make a copy of the sites to truncate or extend to desired length 852 let topSites = this.props.TopSites.rows.slice(); 853 topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW; 854 // if topSites do not fill an entire row add 'Add shortcut' button to array of topSites 855 // (there should only be one of these) 856 let firstPlaceholder = topSites.findIndex(Object.is.bind(null, undefined)); 857 // make sure placeholder exists and there already isnt a add button 858 if (firstPlaceholder && !topSites.includes(site => site.isAddButton)) { 859 topSites[firstPlaceholder] = { isAddButton: true }; 860 } else if (topSites.includes(site => site.isAddButton)) { 861 topSites.push( 862 topSites.splice(topSites.indexOf({ isAddButton: true }), 1)[0] 863 ); 864 } 865 return topSites; 866 } 867 868 /** 869 * Make a preview of the topsites that will be the result of dropping the currently 870 * dragged site at the specified index. 871 */ 872 _makeTopSitesPreview(index) { 873 const topSites = this._getTopSites(); 874 topSites[this.state.draggedIndex] = null; 875 const preview = topSites.map(site => 876 site && (site.isPinned || isSponsored(site)) ? site : null 877 ); 878 const unpinned = topSites.filter( 879 site => site && !site.isPinned && !isSponsored(site) 880 ); 881 const siteToInsert = Object.assign({}, this.state.draggedSite, { 882 isPinned: true, 883 isDragged: true, 884 }); 885 886 if (!preview[index]) { 887 preview[index] = siteToInsert; 888 } else { 889 // Find the hole to shift the pinned site(s) towards. We shift towards the 890 // hole left by the site being dragged. 891 let holeIndex = index; 892 const indexStep = index > this.state.draggedIndex ? -1 : 1; 893 while (preview[holeIndex]) { 894 holeIndex += indexStep; 895 } 896 897 // Shift towards the hole. 898 const shiftingStep = index > this.state.draggedIndex ? 1 : -1; 899 while ( 900 index > this.state.draggedIndex ? holeIndex < index : holeIndex > index 901 ) { 902 let nextIndex = holeIndex + shiftingStep; 903 while (isSponsored(preview[nextIndex])) { 904 nextIndex += shiftingStep; 905 } 906 preview[holeIndex] = preview[nextIndex]; 907 holeIndex = nextIndex; 908 } 909 preview[index] = siteToInsert; 910 } 911 912 // Fill in the remaining holes with unpinned sites. 913 for (let i = 0; i < preview.length; i++) { 914 if (!preview[i]) { 915 preview[i] = unpinned.shift() || null; 916 } 917 } 918 919 return preview; 920 } 921 922 onActivate(index) { 923 this.setState({ activeIndex: index }); 924 } 925 926 onKeyDown(e) { 927 if (this.state.activeIndex || this.state.activeIndex === 0) { 928 return; 929 } 930 931 if (e.key === "ArrowLeft" || e.key === "ArrowRight") { 932 // Arrow direction should match visual navigation direction in RTL 933 const isRTL = document.dir === "rtl"; 934 const navigateToPrevious = isRTL 935 ? e.key === "ArrowRight" 936 : e.key === "ArrowLeft"; 937 938 const targetTopSite = navigateToPrevious 939 ? this.focusedRef?.previousSibling 940 : this.focusedRef?.nextSibling; 941 942 const targetAnchor = targetTopSite?.querySelector("a"); 943 if (targetAnchor) { 944 targetAnchor.tabIndex = 0; 945 targetAnchor.focus(); 946 } 947 } 948 } 949 950 onWrapperFocus() { 951 this.focusRef?.addEventListener("keydown", this.onKeyDown); 952 } 953 onWrapperBlur() { 954 this.focusRef?.removeEventListener("keydown", this.onKeyDown); 955 } 956 onTopsiteFocus(focusIndex) { 957 this.setState(() => ({ 958 focusedIndex: focusIndex, 959 })); 960 } 961 962 render() { 963 const { props } = this; 964 const topSites = this.state.topSitesPreview || this._getTopSites(); 965 const topSitesUI = []; 966 const commonProps = { 967 onDragEvent: this.onDragEvent, 968 dispatch: props.dispatch, 969 }; 970 // We assign a key to each placeholder slot. We need it to be independent 971 // of the slot index (i below) so that the keys used stay the same during 972 // drag and drop reordering and the underlying DOM nodes are reused. 973 // This mostly (only?) affects linux so be sure to test on linux before changing. 974 let holeIndex = 0; 975 976 // On narrow viewports, we only show 6 sites per row. We'll mark the rest as 977 // .hide-for-narrow to hide in CSS via @media query. 978 const maxNarrowVisibleIndex = props.TopSitesRows * 6; 979 980 for (let i = 0, l = topSites.length; i < l; i++) { 981 const link = 982 topSites[i] && 983 Object.assign({}, topSites[i], { 984 iconType: this.props.topSiteIconType(topSites[i]), 985 }); 986 987 const slotProps = { 988 key: link ? link.url : holeIndex++, 989 index: i, 990 }; 991 if (i >= maxNarrowVisibleIndex) { 992 slotProps.className = "hide-for-narrow"; 993 } 994 995 let topSiteLink; 996 // Use a placeholder if the link is empty or it's rendering a sponsored 997 // tile for the about:home startup cache. 998 if ( 999 !link || 1000 (props.App.isForStartupCache.TopSites && isSponsored(link)) 1001 ) { 1002 if (link) { 1003 topSiteLink = <TopSitePlaceholder {...slotProps} {...commonProps} />; 1004 } 1005 } else if (topSites[i]?.isAddButton) { 1006 topSiteLink = ( 1007 <TopSiteAddButton 1008 {...slotProps} 1009 {...commonProps} 1010 setRef={ 1011 i === this.state.focusedIndex 1012 ? el => { 1013 this.focusedRef = el; 1014 } 1015 : () => {} 1016 } 1017 tabIndex={i === this.state.focusedIndex ? 0 : -1} 1018 onFocus={() => { 1019 this.onTopsiteFocus(i); 1020 }} 1021 Messages={this.props.Messages} 1022 visibleTopSites={this.props.visibleTopSites} 1023 /> 1024 ); 1025 } else { 1026 topSiteLink = ( 1027 <TopSite 1028 link={link} 1029 activeIndex={this.state.activeIndex} 1030 onActivate={this.onActivate} 1031 {...slotProps} 1032 {...commonProps} 1033 colors={props.colors} 1034 setRef={ 1035 i === this.state.focusedIndex 1036 ? el => { 1037 this.focusedRef = el; 1038 } 1039 : () => {} 1040 } 1041 tabIndex={i === this.state.focusedIndex ? 0 : -1} 1042 onFocus={() => { 1043 this.onTopsiteFocus(i); 1044 }} 1045 visibleTopSites={this.props.visibleTopSites} 1046 /> 1047 ); 1048 } 1049 1050 topSitesUI.push(topSiteLink); 1051 } 1052 return ( 1053 <div className="top-sites-list-wrapper"> 1054 <ul 1055 role="group" 1056 aria-label="Shortcuts" 1057 onFocus={this.onWrapperFocus} 1058 onBlur={this.onWrapperBlur} 1059 ref={el => { 1060 this.focusRef = el; 1061 }} 1062 className={`top-sites-list${ 1063 this.state.draggedSite ? " dnd-active" : "" 1064 }`} 1065 > 1066 {topSitesUI} 1067 </ul> 1068 </div> 1069 ); 1070 } 1071 } 1072 1073 export const TopSiteList = connect(state => ({ 1074 App: state.App, 1075 Messages: state.Messages, 1076 Prefs: state.Prefs, 1077 }))(_TopSiteList);