/* 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 { MIN_RICH_FAVICON_SIZE, MIN_SMALL_FAVICON_SIZE, 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 { 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"; import { MessageWrapper } from "../MessageWrapper/MessageWrapper"; import { ShortcutFeatureHighlight } from "../DiscoveryStreamComponents/FeatureHighlight/ShortcutFeatureHighlight"; const SPOC_TYPE = "SPOC"; const NEWTAB_SOURCE = "newtab"; // For cases if we want to know if this is sponsored by either sponsored_position or type. // We have two sources for sponsored topsites, and // sponsored_position is set by one sponsored source, and type is set by another. // This is not called in all cases, sometimes we want to know if it's one source // or the other. This function is only applicable in cases where we only care if it's either. function isSponsored(link) { return link?.sponsored_position || link?.type === SPOC_TYPE; } 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); this.shouldShowOMCHighlight = this.shouldShowOMCHighlight.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) { return ( (this.dragged || (!isSponsored(this.props.link) && !this.props.isAddButton)) && e.dataTransfer.types.includes("text/topsite-index") ); } 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); this.props.onDragEvent( event, this.props.index, this.props.link, this.props.title ); break; case "dragend": 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.isAddButton) ) { 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, }; } shouldShowOMCHighlight(componentId) { const messageData = this.props.Messages?.messageData; if (!messageData || Object.keys(messageData).length === 0) { return false; } return messageData?.content?.messageType === componentId; } render() { const { children, className, isDraggable, link, onClick, title, isAddButton, visibleTopSites, } = this.props; const topSiteOuterClassName = `top-site-outer${ className ? ` ${className}` : "" }${link.isDragged ? " dragged" : ""}${ link.searchTopSite ? " search-shortcut" : "" }`; const [letterFallback] = title; const { showSmallFavicon, smallFaviconStyle, imageStyle, imageClassName, selectedColor, } = this.calculateStyle(); const addButtonLabell10n = { "data-l10n-id": "newtab-topsites-add-shortcut-label", }; const addButtonTitlel10n = { "data-l10n-id": "newtab-topsites-add-shortcut-title", }; 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 */}
    {showSmallFavicon && (
    )}
    {link.isPinned &&
    }
    {link.searchTopSite && (
    )} {title}
    {isAddButton && this.shouldShowOMCHighlight("ShortcutHighlight") && ( e.stopPropagation()} > )} {children} {impressionStats}
  • ); } } 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() { return ( ); } } export class TopSitePlaceholder extends React.PureComponent { render() { return ( ); } } export class _TopSiteList extends React.PureComponent { static get DEFAULT_STATE() { return { activeIndex: null, draggedIndex: null, draggedSite: null, draggedTitle: null, topSitesPreview: null, focusedIndex: 0, }; } constructor(props) { super(props); this.state = _TopSiteList.DEFAULT_STATE; this.onDragEvent = this.onDragEvent.bind(this); 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); } componentDidUpdate(prevProps) { if (this.state.draggedSite) { const prevTopSites = prevProps.TopSites && prevProps.TopSites.rows; const newTopSites = this.props.TopSites && this.props.TopSites.rows; if ( prevTopSites && prevTopSites[this.state.draggedIndex] && prevTopSites[this.state.draggedIndex].url === this.state.draggedSite.url && (!newTopSites[this.state.draggedIndex] || newTopSites[this.state.draggedIndex].url !== this.state.draggedSite.url) ) { // We got the new order from the redux store via props. We can clear state now. // eslint-disable-next-line react/no-did-update-set-state this.setState(_TopSiteList.DEFAULT_STATE); } } } userEvent(event, index) { this.props.dispatch( ac.UserEvent({ event, source: TOP_SITES_SOURCE, action_position: index, }) ); } onDragEvent(event, index, link, title) { switch (event.type) { case "dragstart": this.dropped = false; this.setState({ draggedIndex: index, draggedSite: link, draggedTitle: title, activeIndex: null, }); this.userEvent("DRAG", index); break; case "dragend": if (!this.dropped) { // If there was no drop event, reset the state to the default. this.setState(_TopSiteList.DEFAULT_STATE); } break; case "dragenter": if (index === this.state.draggedIndex) { this.setState({ topSitesPreview: null }); } else { this.setState({ topSitesPreview: this._makeTopSitesPreview(index), }); } break; case "drop": if (index !== this.state.draggedIndex) { this.dropped = true; this.props.dispatch( ac.AlsoToMain({ type: at.TOP_SITES_INSERT, data: { site: { url: this.state.draggedSite.url, label: this.state.draggedTitle, customScreenshotURL: this.state.draggedSite.customScreenshotURL, // Only if the search topsites experiment is enabled ...(this.state.draggedSite.searchTopSite && { searchTopSite: true, }), }, index, draggedFromIndex: this.state.draggedIndex, }, }) ); this.userEvent("DROP", index); } break; } } _getTopSites() { // Make a copy of the sites to truncate or extend to desired length let topSites = this.props.TopSites.rows.slice(); topSites.length = this.props.TopSitesRows * (this.props.topSitesMaxSitesPerRow ?? TOP_SITES_MAX_SITES_PER_ROW); // if topSites do not fill an entire row add 'Add shortcut' button to array of topSites // (there should only be one of these) const addButtonIndex = topSites.findIndex(site => site?.isAddButton); // Find the position right after the last regular shortcut let targetPosition = topSites.length - 1; for (let i = topSites.length - 1; i >= 0; i--) { if (topSites[i] && !topSites[i].isAddButton) { targetPosition = i + 1; break; } } if (addButtonIndex === -1) { // No add button exists yet, insert it at target position if it's within bounds if (targetPosition < topSites.length) { topSites[targetPosition] = { isAddButton: true }; } } else if (addButtonIndex !== targetPosition) { // Add button exists but not at the end, move it const [button] = topSites.splice(addButtonIndex, 1); // Adjust target if we removed something before it const adjustedTarget = addButtonIndex < targetPosition ? targetPosition - 1 : targetPosition; topSites[adjustedTarget] = button; } return topSites; } /** * Make a preview of the topsites that will be the result of dropping the currently * dragged site at the specified index. */ _makeTopSitesPreview(index) { const topSites = this._getTopSites(); topSites[this.state.draggedIndex] = null; const preview = topSites.map(site => site && (site.isPinned || isSponsored(site) || site.isAddButton) ? site : null ); const unpinned = topSites.filter( site => site && !site.isPinned && !isSponsored(site) && !site.isAddButton ); const siteToInsert = Object.assign({}, this.state.draggedSite, { isPinned: true, isDragged: true, }); if (!preview[index]) { preview[index] = siteToInsert; } else { // Find the hole to shift the pinned site(s) towards. We shift towards the // hole left by the site being dragged. let holeIndex = index; const indexStep = index > this.state.draggedIndex ? -1 : 1; while (preview[holeIndex]) { holeIndex += indexStep; } // Shift towards the hole. const shiftingStep = index > this.state.draggedIndex ? 1 : -1; while ( index > this.state.draggedIndex ? holeIndex < index : holeIndex > index ) { let nextIndex = holeIndex + shiftingStep; while ( preview[nextIndex] && (isSponsored(preview[nextIndex]) || preview[nextIndex].isAddButton) ) { nextIndex += shiftingStep; } preview[holeIndex] = preview[nextIndex]; holeIndex = nextIndex; } preview[index] = siteToInsert; } // Fill in the remaining holes with unpinned sites. for (let i = 0; i < preview.length; i++) { if (!preview[i]) { preview[i] = unpinned.shift() || null; } } return preview; } onActivate(index) { this.setState({ activeIndex: index }); } onKeyDown(e) { if (this.state.activeIndex || this.state.activeIndex === 0) { return; } if (e.key === "ArrowLeft" || e.key === "ArrowRight") { // Arrow direction should match visual navigation direction in RTL const isRTL = document.dir === "rtl"; const navigateToPrevious = isRTL ? e.key === "ArrowRight" : e.key === "ArrowLeft"; const targetTopSite = navigateToPrevious ? this.focusedRef?.previousSibling : this.focusedRef?.nextSibling; const targetAnchor = targetTopSite?.querySelector("a"); if (targetAnchor) { targetAnchor.tabIndex = 0; targetAnchor.focus(); } } } onWrapperFocus() { this.focusRef?.addEventListener("keydown", this.onKeyDown); } onWrapperBlur() { this.focusRef?.removeEventListener("keydown", this.onKeyDown); } onTopsiteFocus(focusIndex) { this.setState(() => ({ focusedIndex: focusIndex, })); } render() { const { props } = this; const topSites = this.state.topSitesPreview || this._getTopSites(); const topSitesUI = []; const commonProps = { onDragEvent: this.onDragEvent, dispatch: props.dispatch, }; // 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++) { const link = topSites[i] && Object.assign({}, topSites[i], { iconType: this.props.topSiteIconType(topSites[i]), }); const slotProps = { key: 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"; } 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) { topSiteLink = ( { this.focusedRef = el; } : () => {} } tabIndex={i === this.state.focusedIndex ? 0 : -1} onFocus={() => { this.onTopsiteFocus(i); }} Messages={this.props.Messages} visibleTopSites={this.props.visibleTopSites} /> ); } else { topSiteLink = ( { this.focusedRef = el; } : () => {} } tabIndex={i === this.state.focusedIndex ? 0 : -1} onFocus={() => { this.onTopsiteFocus(i); }} visibleTopSites={this.props.visibleTopSites} /> ); } // Skip empty slots — topSiteLink is null when there's no link and no placeholder. if (topSiteLink) { topSitesUI.push(topSiteLink); } } return (
      { this.focusRef = el; }} className={`top-sites-list${ this.state.draggedSite ? " dnd-active" : "" }`} style={{ "--top-sites-max-per-row": this.props.topSitesMaxSitesPerRow ?? TOP_SITES_MAX_SITES_PER_ROW, }} > {topSitesUI}
    ); } } export const TopSiteList = connect(state => ({ App: state.App, Messages: state.Messages, Prefs: state.Prefs, }))(_TopSiteList);