/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; import { isSponsored, MIN_RICH_FAVICON_SIZE, MIN_SMALL_FAVICON_SIZE, SPOC_TYPE, TOP_SITES_CONTEXT_MENU_OPTIONS, TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS, TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS, TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS, TOP_SITES_SOURCE, } from "./TopSitesConstants"; import { PinnedAreaOverlay } from "./PinnedAreaOverlay.jsx"; import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats"; import React from "react"; import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper"; import { connect } from "react-redux"; const NEWTAB_SOURCE = "newtab"; // Tilt so the lifted drag ghost reads as "picked up" (counter-clockwise). const DRAG_GHOST_ROTATION_DEG = -7.5; // Cursor relative to the ghost's top edge; negative = cursor sits just above it. const DRAG_GHOST_CURSOR_INSET = -12; /** * Clones the dragged tile, styles it (size + rotation, pin/menu chrome hidden via * `.drag-ghost`), and hands it to native setDragImage so the platform draws and * follows it. Mounted off-screen under the tile's parent to keep the cascade. * Caller MUST call destroy() on dragend. */ function createDragGhost(e, el) { const restingSize = Math.round(el.getBoundingClientRect().width); const mountPoint = el.parentElement || document.body; const clone = el.cloneNode(true); clone.classList.add("drag-ghost", "active"); // Drop any open context menu from the clone so only the tile is lifted. clone.querySelector(".context-menu")?.remove(); // Size the ghost to the hover/expanded tile (--col-width, per Figma), falling // back to the resting size where that token isn't defined. clone.style.cssText = `position:fixed;top:-9999px;left:-9999px;width:var(--col-width, ${restingSize}px);height:var(--col-width, ${restingSize}px);margin:0;pointer-events:none;transform:rotate(${DRAG_GHOST_ROTATION_DEG}deg)`; mountPoint.appendChild(clone); // offsetWidth is the layout width, unaffected by the rotate transform. e.dataTransfer.setDragImage( clone, clone.offsetWidth / 2, DRAG_GHOST_CURSOR_INSET ); return { destroy() { clone.remove(); }, }; } export class TopSiteLink extends React.PureComponent { constructor(props) { super(props); this.state = { screenshotImage: null }; this.onDragEvent = this.onDragEvent.bind(this); this.onKeyPress = this.onKeyPress.bind(this); } /* * Helper to determine whether the drop zone should allow a drop. We only allow * dropping top sites for now. We don't allow dropping on sponsored top sites * or the add shortcut button as their position is fixed. */ _allowDrop(e) { if (!e.dataTransfer.types.includes("text/topsite-index")) { return false; } // Grouped reorder is bounded to the pinned block: only pinned tiles are // valid drop targets, so a tile can't be dropped out into the frecent area. if (this.props.groupedPinsEnabled) { return !!this.props.link.isPinned; } return this.dragged || !isSponsored(this.props.link); } onDragEvent(event) { switch (event.type) { case "click": // Stop any link clicks if we started any dragging if (this.dragged) { event.preventDefault(); } break; case "dragstart": event.target.blur(); if (isSponsored(this.props.link)) { event.preventDefault(); break; } this.dragged = true; event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/topsite-index", this.props.index); if (this.props.groupedPinsEnabled) { // Lift a styled clone (rotated, hover-sized) as the drag image. this._dragGhost = createDragGhost(event, event.currentTarget); } this.props.onDragEvent( event, this.props.index, this.props.link, this.props.title ); break; case "dragend": this._dragGhost?.destroy(); this._dragGhost = null; this.props.onDragEvent(event); break; case "dragenter": case "dragover": case "drop": if (this._allowDrop(event)) { event.preventDefault(); this.props.onDragEvent(event, this.props.index); } break; case "mousedown": // Block the scroll wheel from appearing for middle clicks on search top sites if (event.button === 1 && this.props.link.searchTopSite) { event.preventDefault(); } // Reset at the first mouse event of a potential drag this.dragged = false; break; } } static getDerivedStateFromProps(nextProps, prevState) { const { screenshot } = nextProps.link; const imageInState = ScreenshotUtils.isRemoteImageLocal( prevState.screenshotImage, screenshot ); if (imageInState) { return null; } // Since image was updated, attempt to revoke old image blob URL, if it exists. ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage); return { screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot), }; } componentWillUnmount() { ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage); } onKeyPress(event) { // If we have tabbed to a search shortcut top site, and we click 'enter', // we should execute the onClick function. This needs to be added because // search top sites are anchor tags without an href. See bug 1483135 if (event.key === "Enter" && this.props.link.searchTopSite) { this.props.onClick(event); } } /* * Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number * Apply that random number to the color array. The same url will always generate the same color. */ generateColor() { let { title, colors } = this.props; if (!colors) { return ""; } let colorArray = colors.split(","); const hashStr = str => { let hash = 0; for (let i = 0; i < str.length; i++) { let charCode = str.charCodeAt(i); hash += charCode; } return hash; }; let hash = hashStr(title); let index = hash % colorArray.length; return colorArray[index]; } calculateStyle() { const { defaultStyle, link } = this.props; const { tippyTopIcon, faviconSize } = link; let imageClassName; let imageStyle; let showSmallFavicon = false; let smallFaviconStyle; let hasScreenshotImage = this.state.screenshotImage && this.state.screenshotImage.url; let selectedColor; if (defaultStyle) { // force no styles (letter fallback) even if the link has imagery selectedColor = this.generateColor(); } else if (link.searchTopSite) { imageClassName = "top-site-icon rich-icon"; imageStyle = { backgroundColor: link.backgroundColor, backgroundImage: `url(${tippyTopIcon})`, }; smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` }; } else if (link.customScreenshotURL) { // assume high quality custom screenshot and use rich icon styles and class names imageClassName = "top-site-icon rich-icon"; imageStyle = { backgroundColor: link.backgroundColor, backgroundImage: hasScreenshotImage ? `url(${this.state.screenshotImage.url})` : "", }; } else if ( tippyTopIcon || link.type === SPOC_TYPE || faviconSize >= MIN_RICH_FAVICON_SIZE ) { // styles and class names for top sites with rich icons imageClassName = "top-site-icon rich-icon"; imageStyle = { backgroundColor: link.backgroundColor, backgroundImage: `url(${tippyTopIcon || link.favicon})`, }; } else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) { showSmallFavicon = true; smallFaviconStyle = { backgroundImage: `url(${link.favicon})` }; } else { selectedColor = this.generateColor(); imageClassName = ""; } return { showSmallFavicon, smallFaviconStyle, imageStyle, imageClassName, selectedColor, }; } render() { const { children, className, isDraggable, link, onClick, title, visibleTopSites, } = this.props; const topSiteOuterClassName = `top-site-outer${ className ? ` ${className}` : "" }${link.isDragged ? " dragged" : ""}${ link.isCollapsed ? " collapsed" : "" }${link.searchTopSite ? " search-shortcut" : ""}`; const [letterFallback] = title; const { showSmallFavicon, smallFaviconStyle, imageStyle, imageClassName, selectedColor, } = this.calculateStyle(); const addPinnedTitlel10n = { "data-l10n-id": "topsite-label-pinned", "data-l10n-args": JSON.stringify({ title }), }; let draggableProps = {}; if (isDraggable) { draggableProps = { onClick: this.onDragEvent, onDragEnd: this.onDragEvent, onDragStart: this.onDragEvent, onMouseDown: this.onDragEvent, }; } let impressionStats = null; if (link.type === SPOC_TYPE) { // Record impressions for Pocket tiles. impressionStats = ( ); } else if (isSponsored(link)) { // Record impressions for non-Pocket sponsored tiles. impressionStats = ( ); } else { // Record impressions for organic tiles. impressionStats = ( ); } return (
  • {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
  • ); } } TopSiteLink.defaultProps = { title: "", link: {}, isDraggable: true, }; export class TopSite extends React.PureComponent { constructor(props) { super(props); this.state = { showContextMenu: false }; this.onLinkClick = this.onLinkClick.bind(this); this.onMenuUpdate = this.onMenuUpdate.bind(this); } /** * Report to telemetry additional information about the item. */ _getTelemetryInfo() { const value = { icon_type: this.props.link.iconType }; // Filter out "not_pinned" type for being the default if (this.props.link.isPinned) { value.card_type = "pinned"; } if (this.props.link.searchTopSite) { // Set the card_type as "search" regardless of its pinning status value.card_type = "search"; value.search_vendor = this.props.link.hostname; } if (isSponsored(this.props.link)) { value.card_type = "spoc"; } return { value }; } userEvent(event) { this.props.dispatch( ac.UserEvent( Object.assign( { event, source: TOP_SITES_SOURCE, action_position: this.props.index, }, this._getTelemetryInfo() ) ) ); } onLinkClick(event) { this.userEvent("CLICK"); // Specially handle a top site link click for "typed" frecency bonus as // specified as a property on the link. event.preventDefault(); const { altKey, button, ctrlKey, metaKey, shiftKey } = event; if (!this.props.link.searchTopSite) { this.props.dispatch( ac.OnlyToMain({ type: at.OPEN_LINK, data: Object.assign(this.props.link, { event: { altKey, button, ctrlKey, metaKey, shiftKey }, is_sponsored: !!this.props.link.sponsored_tile_id, }), }) ); if (this.props.link.type === SPOC_TYPE) { // Record a Pocket-specific click. this.props.dispatch( ac.ImpressionStats({ source: TOP_SITES_SOURCE, click: 0, tiles: [ { id: this.props.link.id, pos: this.props.link.pos, shim: this.props.link.shim && this.props.link.shim.click, }, ], }) ); // Record a click for a Pocket sponsored tile. // This first event is for the shim property // and is used by our ad service provider. this.props.dispatch( ac.DiscoveryStreamUserEvent({ event: "CLICK", source: TOP_SITES_SOURCE, action_position: this.props.link.pos, value: { card_type: "spoc", tile_id: this.props.link.id, shim: this.props.link.shim && this.props.link.shim.click, attribution: this.props.link.attribution, }, }) ); // A second event is recoded for internal usage. const title = this.props.link.label || this.props.link.hostname; this.props.dispatch( ac.OnlyToMain({ type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, data: { type: "click", position: this.props.link.pos, tile_id: this.props.link.id, advertiser: title.toLocaleLowerCase(), source: NEWTAB_SOURCE, attribution: this.props.link.attribution, }, }) ); } else if (isSponsored(this.props.link)) { // Record a click for a non-Pocket sponsored tile. const title = this.props.link.label || this.props.link.hostname; this.props.dispatch( ac.OnlyToMain({ type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, data: { type: "click", position: this.props.index, tile_id: this.props.link.sponsored_tile_id || -1, reporting_url: this.props.link.sponsored_click_url, advertiser: title.toLocaleLowerCase(), source: NEWTAB_SOURCE, visible_topsites: this.props.visibleTopSites, frecency_boosted: this.props.link.type === "frecency-boost", attribution: this.props.link.attribution, }, }) ); } else { // Record a click for an organic tile. this.props.dispatch( ac.OnlyToMain({ type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS, data: { type: "click", position: this.props.index, source: NEWTAB_SOURCE, isPinned: this.props.link.isPinned, guid: this.props.link.guid, visible_topsites: this.props.visibleTopSites, smartScores: this.props.link.scores, smartWeights: this.props.link.weights, }, }) ); } if (this.props.link.sendAttributionRequest) { this.props.dispatch( ac.OnlyToMain({ type: at.PARTNER_LINK_ATTRIBUTION, data: { targetURL: this.props.link.url, source: "newtab", }, }) ); } } else { this.props.dispatch( ac.OnlyToMain({ type: at.FILL_SEARCH_TERM, data: { label: this.props.link.label }, }) ); } } onMenuUpdate(isOpen) { if (isOpen) { this.props.onActivate(this.props.index); } else { this.props.onActivate(); } } render() { const { props } = this; const { link } = props; const isContextMenuOpen = props.activeIndex === props.index; const title = link.label || link.title || link.hostname; let menuOptions; if (link.sponsored_position) { menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS; } else if (link.searchTopSite) { menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS; } else if (link.type === SPOC_TYPE) { menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS; } else { menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS; } return (
    ); } } TopSite.defaultProps = { link: {}, onActivate() {}, }; export class TopSiteAddButton extends React.PureComponent { constructor(props) { super(props); this.onEditButtonClick = this.onEditButtonClick.bind(this); } onEditButtonClick() { this.props.dispatch({ type: at.TOP_SITES_EDIT, data: { index: this.props.index }, }); } render() { // In-grid buttons are large to match the tile icons; the full-row hover // overlay uses the default size. Both variants participate in the shortcuts' // arrow-key navigation. const button = ( ); if (this.props.inGrid) { return (
  • {button}
  • ); } // For a full row, overlay the button on the last tile; reveal on hover or // when the shortcuts row has focus. return
    {button}
    ; } } export class TopSitePlaceholder extends React.PureComponent { render() { return ( ); } } // The classic path renders this mode-agnostically. The grouped-pins path extends // it through an explicit, opt-in prop contract (all inert when groupedPinsEnabled // is false), kept here intentionally rather than composed via children/render-prop: // - groupedPinsEnabled: turns on the grouped-mode behavior below // - listProps / dropsOnList: zero-pin variant makes the whole
      one drop target // - decorations: zero-pin placeholder slot (zeroPinSlot/overZeroPin/setZeroPinRef) // - listRef: hands the
        back for the zero-pin drop geometry // - PinnedAreaOverlay (rendered inside the
          ) self-measures .pinned-cell tiles // Longer term these could be composed from the container (children/render-prop) // so this list goes back to being fully mode-agnostic. export class _TopSiteList extends React.PureComponent { static get DEFAULT_STATE() { return { activeIndex: null, focusedIndex: 0, }; } constructor(props) { super(props); this.state = _TopSiteList.DEFAULT_STATE; this.onActivate = this.onActivate.bind(this); this.onWrapperFocus = this.onWrapperFocus.bind(this); this.onTopsiteFocus = this.onTopsiteFocus.bind(this); this.onWrapperBlur = this.onWrapperBlur.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.onListDragLeave = this.onListDragLeave.bind(this); this.onListDragOver = this.onListDragOver.bind(this); } componentDidUpdate(prevProps) { // Drag state lives in the hook now; mirror the old reset of our own view // state (menu + focus) off the dragged-site signal. const started = !prevProps.draggedSite && this.props.draggedSite; const ended = prevProps.draggedSite && !this.props.draggedSite; if (started || ended) { // eslint-disable-next-line react/no-did-update-set-state this.setState(ended ? _TopSiteList.DEFAULT_STATE : { activeIndex: null }); } // Keyboard focus: the shortcuts share one roving tab stop (focusedIndex). If // the row count shrinks, the focused tile may no longer be rendered, leaving // the row with no tab stop — reset to the first tile to keep the row focusable. if (prevProps.TopSitesRows !== this.props.TopSitesRows) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ focusedIndex: 0 }); } } onActivate(index) { this.setState({ activeIndex: index }); } onKeyDown(e) { if (this.state.activeIndex || this.state.activeIndex === 0) { return; } if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") { return; } // Arrow direction should match visual navigation direction in RTL const isRTL = document.dir === "rtl"; const navigateToPrevious = isRTL ? e.key === "ArrowRight" : e.key === "ArrowLeft"; // Walk a flat, DOM-ordered list of focus targets: each tile's link plus // the add-button. const focusTargets = [...this.focusRef.querySelectorAll("a, .add-button")]; const currentIndex = focusTargets.indexOf(e.target); if (currentIndex === -1) { return; } // Wrap around the row: stepping forward past the last target returns to the // first, and vice versa. The extra `+ count` keeps the modulo positive when // wrapping backward from index 0. const count = focusTargets.length; const delta = navigateToPrevious ? -1 : 1; const target = focusTargets[(currentIndex + delta + count) % count]; if (target) { target.tabIndex = 0; target.focus(); } } onWrapperFocus() { this.focusRef?.addEventListener("keydown", this.onKeyDown); } onWrapperBlur() { this.focusRef?.removeEventListener("keydown", this.onKeyDown); } onTopsiteFocus(focusIndex) { this.setState(() => ({ focusedIndex: focusIndex, })); } // dragover fires continuously on whatever's under the cursor, so it's the // reliable "current element" signal (dragenter/dragleave order can't be // trusted between adjacent tiles). The reflow should only live while the // cursor is within the pinned drop region (the purple outline), so hit-test // the live overlay boxes: each box spans a whole pinned row, so crossing the // gaps between pins stays inside and doesn't flicker. Classic has no pinned // region, so it keeps its original behavior. onListDragOver(event) { // Preserve any list-level handler (zero-pin grouped drop geometry). this.props.listProps?.onDragOver?.(event); if (!this.props.groupedPinsEnabled) { return; } const boxes = [...event.currentTarget.querySelectorAll(".pinned-drop-box")]; if (!boxes.length) { return; } const inside = boxes.some(box => { const r = box.getBoundingClientRect(); return ( event.clientX >= r.left && event.clientX <= r.right && event.clientY >= r.top && event.clientY <= r.bottom ); }); if (!inside) { this.props.onDragEvent(event); } } // Safety net for the grouped pinned-region clear: leaving the grid straight // off an edge tile, where no in-region dragover lands first. Filters out // tile-to-tile crossings via relatedTarget. Grouped-only, so classic keeps its // original "placeholder persists until drop/dragend" behavior. onListDragLeave(event) { // Preserve any list-level handler (e.g. zero-pin grouped drop). this.props.listProps?.onDragLeave?.(event); if (!this.props.groupedPinsEnabled) { return; } if (!event.currentTarget.contains(event.relatedTarget)) { this.props.onDragEvent(event); } } render() { const { props } = this; const topSites = this.props.sites; const maxSitesPerRow = props.topSitesMaxSitesPerRow ?? TOP_SITES_MAX_SITES_PER_ROW; // The array can be sparse (e.g. dismissed tiles leave holes), so count the // tiles that actually render rather than relying on slot indices. const tileCount = topSites.filter(site => site && !site.isAddButton).length; const rowFull = tileCount > 0 && tileCount % maxSitesPerRow === 0; const topSitesUI = []; const commonProps = { onDragEvent: this.props.onDragEvent, dispatch: props.dispatch, groupedPinsEnabled: this.props.groupedPinsEnabled, // Zero-pin drops on the list (single synthetic target, no per-tile // handlers). The reorder+append path keeps per-tile handlers and adds a // list-level append target, so it passes listProps without this flag. dropsOnList: !!this.props.dropsOnList, }; const { decorations } = this.props; // We assign a key to each placeholder slot. We need it to be independent // of the slot index (i below) so that the keys used stay the same during // drag and drop reordering and the underlying DOM nodes are reused. // This mostly (only?) affects linux so be sure to test on linux before changing. let holeIndex = 0; // On narrow viewports, we only show 6 sites per row. We'll mark the rest as // .hide-for-narrow to hide in CSS via @media query. const novaEnabled = this.props.Prefs.values["nova.enabled"]; const maxNarrowVisibleIndex = props.TopSitesRows * 6; const maxSmallVisibleIndex = props.TopSitesRows * 8; for (let i = 0, l = topSites.length; i < l; i++) { // Zero-pin grouped drag: with no pins there are no slots to reorder, so // open the pin area with one placeholder at the first pinnable slot. if (decorations && i === decorations.zeroPinSlot) { topSitesUI.push( ); } const link = topSites[i] && Object.assign({}, topSites[i], { iconType: this.props.topSiteIconType(topSites[i]), }); const slotProps = { // Stable key so the button isn't remounted (and re-flashed) as its slot // shifts on pin/unpin — it has no url to key off of. key: link?.isAddButton ? "add-button" : link?.url || `hole-${holeIndex++}`, index: i, }; // @nova-cleanup(remove-conditional): Remove classic path once Nova ships if (novaEnabled) { if (i >= maxSmallVisibleIndex) { slotProps.className = "nova-hide-for-s"; } else if (i >= maxNarrowVisibleIndex) { slotProps.className = "nova-hide-for-xs"; } } else if (i >= maxSmallVisibleIndex) { slotProps.className = "hide-for-small"; } else if (i >= maxNarrowVisibleIndex) { slotProps.className = "hide-for-narrow"; } // Marker (no styles) the drop-zone overlay measures to size the box. The // preview marks a joining frecent isPinned, so the box grows with it. if (this.props.groupedPinsEnabled && link?.isPinned) { slotProps.className = `${ slotProps.className ? `${slotProps.className} ` : "" }pinned-cell`; } const { key: slotKey, ...restSlotProps } = slotProps; let topSiteLink = null; // Use a placeholder if the link is empty or it's rendering a sponsored // tile for the about:home startup cache. if ( !link || (props.App.isForStartupCache.TopSites && isSponsored(link)) ) { if (link) { topSiteLink = ( ); } } else if (topSites[i]?.isAddButton) { // Render the add button in-grid when the row isn't full. It carries the // slot's responsive hide class so it drops at the same breakpoints as a // tile in that column would. if (!rowFull) { topSiteLink = ( { this.onTopsiteFocus(i); }} /> ); } } else { // When this is the last tile of a full row, the add button has no free // cell of its own, so render it as a hover overlay anchored to this tile. let addButton = null; if (topSites[i + 1]?.isAddButton && rowFull) { addButton = ( { this.onTopsiteFocus(i + 1); }} /> ); } topSiteLink = ( { this.onTopsiteFocus(i); }} visibleTopSites={this.props.visibleTopSites} addButton={addButton} /> ); } // Skip empty slots — topSiteLink is null when there's no link and no placeholder. if (topSiteLink) { topSitesUI.push(topSiteLink); } } return (
            { this.focusRef = el; this.props.listRef?.(el); }} className={`top-sites-list${ this.props.draggedSite ? " dnd-active" : "" }`} style={{ "--top-sites-max-per-row": this.props.topSitesMaxSitesPerRow ?? TOP_SITES_MAX_SITES_PER_ROW, }} > {topSitesUI} {this.props.groupedPinsEnabled && ( )}
          ); } } export const TopSiteList = connect(state => ({ App: state.App, Prefs: state.Prefs, }))(_TopSiteList);