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 5import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm"; 6import { 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"; 15import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; 16import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats"; 17import React from "react"; 18import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; 19import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.jsm"; 20import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; 21import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper"; 22const SPOC_TYPE = "SPOC"; 23const NEWTAB_SOURCE = "newtab"; 24 25export class TopSiteLink extends React.PureComponent { 26 constructor(props) { 27 super(props); 28 this.state = { screenshotImage: null }; 29 this.onDragEvent = this.onDragEvent.bind(this); 30 this.onKeyPress = this.onKeyPress.bind(this); 31 } 32 33 /* 34 * Helper to determine whether the drop zone should allow a drop. We only allow 35 * dropping top sites for now. We don't allow dropping on sponsored top sites 36 * as their position is fixed. 37 */ 38 _allowDrop(e) { 39 return ( 40 (this.dragged || !this.props.link.sponsored_position) && 41 e.dataTransfer.types.includes("text/topsite-index") 42 ); 43 } 44 45 onDragEvent(event) { 46 switch (event.type) { 47 case "click": 48 // Stop any link clicks if we started any dragging 49 if (this.dragged) { 50 event.preventDefault(); 51 } 52 break; 53 case "dragstart": 54 event.target.blur(); 55 if (this.props.link.sponsored_position) { 56 event.preventDefault(); 57 break; 58 } 59 this.dragged = true; 60 event.dataTransfer.effectAllowed = "move"; 61 event.dataTransfer.setData("text/topsite-index", this.props.index); 62 this.props.onDragEvent( 63 event, 64 this.props.index, 65 this.props.link, 66 this.props.title 67 ); 68 break; 69 case "dragend": 70 this.props.onDragEvent(event); 71 break; 72 case "dragenter": 73 case "dragover": 74 case "drop": 75 if (this._allowDrop(event)) { 76 event.preventDefault(); 77 this.props.onDragEvent(event, this.props.index); 78 } 79 break; 80 case "mousedown": 81 // Block the scroll wheel from appearing for middle clicks on search top sites 82 if (event.button === 1 && this.props.link.searchTopSite) { 83 event.preventDefault(); 84 } 85 // Reset at the first mouse event of a potential drag 86 this.dragged = false; 87 break; 88 } 89 } 90 91 /** 92 * Helper to obtain the next state based on nextProps and prevState. 93 * 94 * NOTE: Rename this method to getDerivedStateFromProps when we update React 95 * to >= 16.3. We will need to update tests as well. We cannot rename this 96 * method to getDerivedStateFromProps now because there is a mismatch in 97 * the React version that we are using for both testing and production. 98 * (i.e. react-test-render => "16.3.2", react => "16.2.0"). 99 * 100 * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. 101 */ 102 static getNextStateFromProps(nextProps, prevState) { 103 const { screenshot } = nextProps.link; 104 const imageInState = ScreenshotUtils.isRemoteImageLocal( 105 prevState.screenshotImage, 106 screenshot 107 ); 108 if (imageInState) { 109 return null; 110 } 111 112 // Since image was updated, attempt to revoke old image blob URL, if it exists. 113 ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage); 114 115 return { 116 screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot), 117 }; 118 } 119 120 // NOTE: Remove this function when we update React to >= 16.3 since React will 121 // call getDerivedStateFromProps automatically. We will also need to 122 // rename getNextStateFromProps to getDerivedStateFromProps. 123 componentWillMount() { 124 const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state); 125 if (nextState) { 126 this.setState(nextState); 127 } 128 } 129 130 // NOTE: Remove this function when we update React to >= 16.3 since React will 131 // call getDerivedStateFromProps automatically. We will also need to 132 // rename getNextStateFromProps to getDerivedStateFromProps. 133 componentWillReceiveProps(nextProps) { 134 const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state); 135 if (nextState) { 136 this.setState(nextState); 137 } 138 } 139 140 componentWillUnmount() { 141 ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage); 142 } 143 144 onKeyPress(event) { 145 // If we have tabbed to a search shortcut top site, and we click 'enter', 146 // we should execute the onClick function. This needs to be added because 147 // search top sites are anchor tags without an href. See bug 1483135 148 if (this.props.link.searchTopSite && event.key === "Enter") { 149 this.props.onClick(event); 150 } 151 } 152 153 /* 154 * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number 155 * Apply that random number to the color array. The same url will always generate the same color. 156 */ 157 generateColor() { 158 let { title, colors } = this.props; 159 if (!colors) { 160 return ""; 161 } 162 163 let colorArray = colors.split(","); 164 165 const hashStr = str => { 166 let hash = 0; 167 for (let i = 0; i < str.length; i++) { 168 let charCode = str.charCodeAt(i); 169 hash += charCode; 170 } 171 return hash; 172 }; 173 174 let hash = hashStr(title); 175 let index = hash % colorArray.length; 176 return colorArray[index]; 177 } 178 179 calculateStyle() { 180 const { defaultStyle, link } = this.props; 181 182 const { tippyTopIcon, faviconSize } = link; 183 let imageClassName; 184 let imageStyle; 185 let showSmallFavicon = false; 186 let smallFaviconStyle; 187 let hasScreenshotImage = 188 this.state.screenshotImage && this.state.screenshotImage.url; 189 let selectedColor; 190 191 if (defaultStyle) { 192 // force no styles (letter fallback) even if the link has imagery 193 selectedColor = this.generateColor(); 194 } else if (link.searchTopSite) { 195 imageClassName = "top-site-icon rich-icon"; 196 imageStyle = { 197 backgroundColor: link.backgroundColor, 198 backgroundImage: `url(${tippyTopIcon})`, 199 }; 200 smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` }; 201 } else if (link.customScreenshotURL) { 202 // assume high quality custom screenshot and use rich icon styles and class names 203 204 // TopSite spoc experiment only 205 const spocImgURL = 206 link.type === SPOC_TYPE ? link.customScreenshotURL : ""; 207 208 imageClassName = "top-site-icon rich-icon"; 209 imageStyle = { 210 backgroundColor: link.backgroundColor, 211 backgroundImage: hasScreenshotImage 212 ? `url(${this.state.screenshotImage.url})` 213 : `url(${spocImgURL})`, 214 }; 215 } else if (tippyTopIcon || faviconSize >= MIN_RICH_FAVICON_SIZE) { 216 // styles and class names for top sites with rich icons 217 imageClassName = "top-site-icon rich-icon"; 218 imageStyle = { 219 backgroundColor: link.backgroundColor, 220 backgroundImage: `url(${tippyTopIcon || link.favicon})`, 221 }; 222 } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) { 223 showSmallFavicon = true; 224 smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; 225 } else { 226 selectedColor = this.generateColor(); 227 imageClassName = ""; 228 } 229 230 return { 231 showSmallFavicon, 232 smallFaviconStyle, 233 imageStyle, 234 imageClassName, 235 selectedColor, 236 }; 237 } 238 239 render() { 240 const { 241 children, 242 className, 243 isDraggable, 244 link, 245 onClick, 246 title, 247 } = this.props; 248 const topSiteOuterClassName = `top-site-outer${ 249 className ? ` ${className}` : "" 250 }${link.isDragged ? " dragged" : ""}${ 251 link.searchTopSite ? " search-shortcut" : "" 252 }`; 253 const [letterFallback] = title; 254 const { 255 showSmallFavicon, 256 smallFaviconStyle, 257 imageStyle, 258 imageClassName, 259 selectedColor, 260 } = this.calculateStyle(); 261 262 let draggableProps = {}; 263 if (isDraggable) { 264 draggableProps = { 265 onClick: this.onDragEvent, 266 onDragEnd: this.onDragEvent, 267 onDragStart: this.onDragEvent, 268 onMouseDown: this.onDragEvent, 269 }; 270 } 271 272 return ( 273 <li 274 className={topSiteOuterClassName} 275 onDrop={this.onDragEvent} 276 onDragOver={this.onDragEvent} 277 onDragEnter={this.onDragEvent} 278 onDragLeave={this.onDragEvent} 279 {...draggableProps} 280 > 281 <div className="top-site-inner"> 282 {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} 283 {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} 284 <a 285 className="top-site-button" 286 href={link.searchTopSite ? undefined : link.url} 287 tabIndex="0" 288 onKeyPress={this.onKeyPress} 289 onClick={onClick} 290 draggable={true} 291 > 292 <div className="tile" aria-hidden={true}> 293 <div 294 className={ 295 selectedColor 296 ? "icon-wrapper letter-fallback" 297 : "icon-wrapper" 298 } 299 data-fallback={letterFallback} 300 style={selectedColor ? { backgroundColor: selectedColor } : {}} 301 > 302 <div className={imageClassName} style={imageStyle} /> 303 {showSmallFavicon && ( 304 <div 305 className="top-site-icon default-icon" 306 data-fallback={smallFaviconStyle ? "" : letterFallback} 307 style={smallFaviconStyle} 308 /> 309 )} 310 </div> 311 {link.searchTopSite && ( 312 <div className="top-site-icon search-topsite" /> 313 )} 314 </div> 315 <div 316 className={`title${link.isPinned ? " has-icon pinned" : ""}${ 317 link.type === SPOC_TYPE || link.show_sponsored_label 318 ? " sponsored" 319 : "" 320 }`} 321 > 322 <span dir="auto"> 323 {link.isPinned && <div className="icon icon-pin-small" />} 324 {title || <br />} 325 <span 326 className="sponsored-label" 327 data-l10n-id="newtab-topsite-sponsored" 328 /> 329 </span> 330 </div> 331 </a> 332 {children} 333 {link.type === SPOC_TYPE ? ( 334 <ImpressionStats 335 flightId={link.flightId} 336 rows={[ 337 { 338 id: link.id, 339 pos: link.pos, 340 shim: link.shim && link.shim.impression, 341 }, 342 ]} 343 dispatch={this.props.dispatch} 344 source={TOP_SITES_SOURCE} 345 /> 346 ) : null} 347 {/* Set up an impression wrapper for the sponsored TopSite */} 348 {link.sponsored_position ? ( 349 <TopSiteImpressionWrapper 350 tile={{ 351 position: this.props.index + 1, 352 tile_id: link.sponsored_tile_id || -1, 353 reporting_url: link.sponsored_impression_url, 354 advertiser: title.toLocaleLowerCase(), 355 source: NEWTAB_SOURCE, 356 }} 357 dispatch={this.props.dispatch} 358 /> 359 ) : null} 360 </div> 361 </li> 362 ); 363 } 364} 365TopSiteLink.defaultProps = { 366 title: "", 367 link: {}, 368 isDraggable: true, 369}; 370 371export class TopSite extends React.PureComponent { 372 constructor(props) { 373 super(props); 374 this.state = { showContextMenu: false }; 375 this.onLinkClick = this.onLinkClick.bind(this); 376 this.onMenuUpdate = this.onMenuUpdate.bind(this); 377 } 378 379 /** 380 * Report to telemetry additional information about the item. 381 */ 382 _getTelemetryInfo() { 383 const value = { icon_type: this.props.link.iconType }; 384 // Filter out "not_pinned" type for being the default 385 if (this.props.link.isPinned) { 386 value.card_type = "pinned"; 387 } 388 if (this.props.link.searchTopSite) { 389 // Set the card_type as "search" regardless of its pinning status 390 value.card_type = "search"; 391 value.search_vendor = this.props.link.hostname; 392 } 393 if ( 394 this.props.link.type === SPOC_TYPE || 395 this.props.link.sponsored_position 396 ) { 397 value.card_type = "spoc"; 398 } 399 return { value }; 400 } 401 402 userEvent(event) { 403 this.props.dispatch( 404 ac.UserEvent( 405 Object.assign( 406 { 407 event, 408 source: TOP_SITES_SOURCE, 409 action_position: this.props.index, 410 }, 411 this._getTelemetryInfo() 412 ) 413 ) 414 ); 415 } 416 417 onLinkClick(event) { 418 this.userEvent("CLICK"); 419 420 // Specially handle a top site link click for "typed" frecency bonus as 421 // specified as a property on the link. 422 event.preventDefault(); 423 const { altKey, button, ctrlKey, metaKey, shiftKey } = event; 424 if (!this.props.link.searchTopSite) { 425 this.props.dispatch( 426 ac.OnlyToMain({ 427 type: at.OPEN_LINK, 428 data: Object.assign(this.props.link, { 429 event: { altKey, button, ctrlKey, metaKey, shiftKey }, 430 }), 431 }) 432 ); 433 434 // Fire off a spoc specific impression. 435 if (this.props.link.type === SPOC_TYPE) { 436 this.props.dispatch( 437 ac.ImpressionStats({ 438 source: TOP_SITES_SOURCE, 439 click: 0, 440 tiles: [ 441 { 442 id: this.props.link.id, 443 pos: this.props.link.pos, 444 shim: this.props.link.shim && this.props.link.shim.click, 445 }, 446 ], 447 }) 448 ); 449 } 450 if (this.props.link.sendAttributionRequest) { 451 this.props.dispatch( 452 ac.OnlyToMain({ 453 type: at.PARTNER_LINK_ATTRIBUTION, 454 data: { 455 targetURL: this.props.link.url, 456 source: "newtab", 457 }, 458 }) 459 ); 460 } 461 if (this.props.link.sponsored_position) { 462 const title = this.props.link.label || this.props.link.hostname; 463 this.props.dispatch( 464 ac.OnlyToMain({ 465 type: at.TOP_SITES_IMPRESSION_STATS, 466 data: { 467 type: "click", 468 position: this.props.index + 1, 469 tile_id: this.props.link.sponsored_tile_id || -1, 470 reporting_url: this.props.link.sponsored_click_url, 471 advertiser: title.toLocaleLowerCase(), 472 source: NEWTAB_SOURCE, 473 }, 474 }) 475 ); 476 } 477 } else { 478 this.props.dispatch( 479 ac.OnlyToMain({ 480 type: at.FILL_SEARCH_TERM, 481 data: { label: this.props.link.label }, 482 }) 483 ); 484 } 485 } 486 487 onMenuUpdate(isOpen) { 488 if (isOpen) { 489 this.props.onActivate(this.props.index); 490 } else { 491 this.props.onActivate(); 492 } 493 } 494 495 render() { 496 const { props } = this; 497 const { link } = props; 498 const isContextMenuOpen = props.activeIndex === props.index; 499 const title = link.label || link.hostname; 500 let menuOptions; 501 if (link.sponsored_position) { 502 menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS; 503 } else if (link.searchTopSite) { 504 menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; 505 } else if (link.type === SPOC_TYPE) { 506 menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; 507 } else { 508 menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS; 509 } 510 511 return ( 512 <TopSiteLink 513 {...props} 514 onClick={this.onLinkClick} 515 onDragEvent={this.props.onDragEvent} 516 className={`${props.className || ""}${ 517 isContextMenuOpen ? " active" : "" 518 }`} 519 title={title} 520 > 521 <div> 522 <ContextMenuButton 523 tooltip="newtab-menu-content-tooltip" 524 tooltipArgs={{ title }} 525 onUpdate={this.onMenuUpdate} 526 > 527 <LinkMenu 528 dispatch={props.dispatch} 529 index={props.index} 530 onUpdate={this.onMenuUpdate} 531 options={menuOptions} 532 site={link} 533 shouldSendImpressionStats={link.type === SPOC_TYPE} 534 siteInfo={this._getTelemetryInfo()} 535 source={TOP_SITES_SOURCE} 536 /> 537 </ContextMenuButton> 538 </div> 539 </TopSiteLink> 540 ); 541 } 542} 543TopSite.defaultProps = { 544 link: {}, 545 onActivate() {}, 546}; 547 548export class TopSitePlaceholder extends React.PureComponent { 549 constructor(props) { 550 super(props); 551 this.onEditButtonClick = this.onEditButtonClick.bind(this); 552 } 553 554 onEditButtonClick() { 555 this.props.dispatch({ 556 type: at.TOP_SITES_EDIT, 557 data: { index: this.props.index }, 558 }); 559 } 560 561 render() { 562 return ( 563 <TopSiteLink 564 {...this.props} 565 className={`placeholder ${this.props.className || ""}`} 566 isDraggable={false} 567 > 568 <button 569 aria-haspopup="true" 570 className="context-menu-button edit-button icon" 571 data-l10n-id="newtab-menu-topsites-placeholder-tooltip" 572 onClick={this.onEditButtonClick} 573 /> 574 </TopSiteLink> 575 ); 576 } 577} 578 579export class TopSiteList extends React.PureComponent { 580 static get DEFAULT_STATE() { 581 return { 582 activeIndex: null, 583 draggedIndex: null, 584 draggedSite: null, 585 draggedTitle: null, 586 topSitesPreview: null, 587 }; 588 } 589 590 constructor(props) { 591 super(props); 592 this.state = TopSiteList.DEFAULT_STATE; 593 this.onDragEvent = this.onDragEvent.bind(this); 594 this.onActivate = this.onActivate.bind(this); 595 } 596 597 componentWillReceiveProps(nextProps) { 598 if (this.state.draggedSite) { 599 const prevTopSites = this.props.TopSites && this.props.TopSites.rows; 600 const newTopSites = nextProps.TopSites && nextProps.TopSites.rows; 601 if ( 602 prevTopSites && 603 prevTopSites[this.state.draggedIndex] && 604 prevTopSites[this.state.draggedIndex].url === 605 this.state.draggedSite.url && 606 (!newTopSites[this.state.draggedIndex] || 607 newTopSites[this.state.draggedIndex].url !== 608 this.state.draggedSite.url) 609 ) { 610 // We got the new order from the redux store via props. We can clear state now. 611 this.setState(TopSiteList.DEFAULT_STATE); 612 } 613 } 614 } 615 616 userEvent(event, index) { 617 this.props.dispatch( 618 ac.UserEvent({ 619 event, 620 source: TOP_SITES_SOURCE, 621 action_position: index, 622 }) 623 ); 624 } 625 626 onDragEvent(event, index, link, title) { 627 switch (event.type) { 628 case "dragstart": 629 this.dropped = false; 630 this.setState({ 631 draggedIndex: index, 632 draggedSite: link, 633 draggedTitle: title, 634 activeIndex: null, 635 }); 636 this.userEvent("DRAG", index); 637 break; 638 case "dragend": 639 if (!this.dropped) { 640 // If there was no drop event, reset the state to the default. 641 this.setState(TopSiteList.DEFAULT_STATE); 642 } 643 break; 644 case "dragenter": 645 if (index === this.state.draggedIndex) { 646 this.setState({ topSitesPreview: null }); 647 } else { 648 this.setState({ 649 topSitesPreview: this._makeTopSitesPreview(index), 650 }); 651 } 652 break; 653 case "drop": 654 if (index !== this.state.draggedIndex) { 655 this.dropped = true; 656 this.props.dispatch( 657 ac.AlsoToMain({ 658 type: at.TOP_SITES_INSERT, 659 data: { 660 site: { 661 url: this.state.draggedSite.url, 662 label: this.state.draggedTitle, 663 customScreenshotURL: this.state.draggedSite 664 .customScreenshotURL, 665 // Only if the search topsites experiment is enabled 666 ...(this.state.draggedSite.searchTopSite && { 667 searchTopSite: true, 668 }), 669 }, 670 index, 671 draggedFromIndex: this.state.draggedIndex, 672 }, 673 }) 674 ); 675 this.userEvent("DROP", index); 676 } 677 break; 678 } 679 } 680 681 _getTopSites() { 682 // Make a copy of the sites to truncate or extend to desired length 683 let topSites = this.props.TopSites.rows.slice(); 684 topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW; 685 return topSites; 686 } 687 688 /** 689 * Make a preview of the topsites that will be the result of dropping the currently 690 * dragged site at the specified index. 691 */ 692 _makeTopSitesPreview(index) { 693 const topSites = this._getTopSites(); 694 topSites[this.state.draggedIndex] = null; 695 const preview = topSites.map(site => 696 site && (site.isPinned || site.sponsored_position) ? site : null 697 ); 698 const unpinned = topSites.filter( 699 site => site && !site.isPinned && !site.sponsored_position 700 ); 701 const siteToInsert = Object.assign({}, this.state.draggedSite, { 702 isPinned: true, 703 isDragged: true, 704 }); 705 706 if (!preview[index]) { 707 preview[index] = siteToInsert; 708 } else { 709 // Find the hole to shift the pinned site(s) towards. We shift towards the 710 // hole left by the site being dragged. 711 let holeIndex = index; 712 const indexStep = index > this.state.draggedIndex ? -1 : 1; 713 while (preview[holeIndex]) { 714 holeIndex += indexStep; 715 } 716 717 // Shift towards the hole. 718 const shiftingStep = index > this.state.draggedIndex ? 1 : -1; 719 while ( 720 index > this.state.draggedIndex ? holeIndex < index : holeIndex > index 721 ) { 722 let nextIndex = holeIndex + shiftingStep; 723 while (preview[nextIndex] && preview[nextIndex].sponsored_position) { 724 nextIndex += shiftingStep; 725 } 726 preview[holeIndex] = preview[nextIndex]; 727 holeIndex = nextIndex; 728 } 729 preview[index] = siteToInsert; 730 } 731 732 // Fill in the remaining holes with unpinned sites. 733 for (let i = 0; i < preview.length; i++) { 734 if (!preview[i]) { 735 preview[i] = unpinned.shift() || null; 736 } 737 } 738 739 return preview; 740 } 741 742 onActivate(index) { 743 this.setState({ activeIndex: index }); 744 } 745 746 render() { 747 const { props } = this; 748 const topSites = this.state.topSitesPreview || this._getTopSites(); 749 const topSitesUI = []; 750 const commonProps = { 751 onDragEvent: this.onDragEvent, 752 dispatch: props.dispatch, 753 }; 754 // We assign a key to each placeholder slot. We need it to be independent 755 // of the slot index (i below) so that the keys used stay the same during 756 // drag and drop reordering and the underlying DOM nodes are reused. 757 // This mostly (only?) affects linux so be sure to test on linux before changing. 758 let holeIndex = 0; 759 760 // On narrow viewports, we only show 6 sites per row. We'll mark the rest as 761 // .hide-for-narrow to hide in CSS via @media query. 762 const maxNarrowVisibleIndex = props.TopSitesRows * 6; 763 764 for (let i = 0, l = topSites.length; i < l; i++) { 765 const link = 766 topSites[i] && 767 Object.assign({}, topSites[i], { 768 iconType: this.props.topSiteIconType(topSites[i]), 769 }); 770 const slotProps = { 771 key: link ? link.url : holeIndex++, 772 index: i, 773 }; 774 if (i >= maxNarrowVisibleIndex) { 775 slotProps.className = "hide-for-narrow"; 776 } 777 topSitesUI.push( 778 !link ? ( 779 <TopSitePlaceholder {...slotProps} {...commonProps} /> 780 ) : ( 781 <TopSite 782 link={link} 783 activeIndex={this.state.activeIndex} 784 onActivate={this.onActivate} 785 {...slotProps} 786 {...commonProps} 787 colors={props.colors} 788 /> 789 ) 790 ); 791 } 792 return ( 793 <ul 794 className={`top-sites-list${ 795 this.state.draggedSite ? " dnd-active" : "" 796 }`} 797 > 798 {topSitesUI} 799 </ul> 800 ); 801 } 802} 803