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