tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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