diff options
Diffstat (limited to 'browser/components/newtab/content-src/components/TopSites')
8 files changed, 2547 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx new file mode 100644 index 0000000000..4324c019f6 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx @@ -0,0 +1,192 @@ +/* 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.sys.mjs"; +import React from "react"; +import { TOP_SITES_SOURCE } from "./TopSitesConstants"; + +export class SelectableSearchShortcut extends React.PureComponent { + render() { + const { shortcut, selected } = this.props; + const imageStyle = { backgroundImage: `url("${shortcut.tippyTopIcon}")` }; + return ( + <div className="top-site-outer search-shortcut"> + <input + type="checkbox" + id={shortcut.keyword} + name={shortcut.keyword} + checked={selected} + onChange={this.props.onChange} + /> + <label htmlFor={shortcut.keyword}> + <div className="top-site-inner"> + <span> + <div className="tile"> + <div + className="top-site-icon rich-icon" + style={imageStyle} + data-fallback="@" + /> + <div className="top-site-icon search-topsite" /> + </div> + <div className="title"> + <span dir="auto">{shortcut.keyword}</span> + </div> + </span> + </div> + </label> + </div> + ); + } +} + +export class SearchShortcutsForm extends React.PureComponent { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onSaveButtonClick = this.onSaveButtonClick.bind(this); + + // clone the shortcuts and add them to the state so we can add isSelected property + const shortcuts = []; + const { rows, searchShortcuts } = props.TopSites; + searchShortcuts.forEach(shortcut => { + shortcuts.push({ + ...shortcut, + isSelected: !!rows.find( + row => + row && + row.isPinned && + row.searchTopSite && + row.label === shortcut.keyword + ), + }); + }); + this.state = { shortcuts }; + } + + handleChange(event) { + const { target } = event; + const { name, checked } = target; + this.setState(prevState => { + const shortcuts = prevState.shortcuts.slice(); + let shortcut = shortcuts.find(({ keyword }) => keyword === name); + shortcut.isSelected = checked; + return { shortcuts }; + }); + } + + onCancelButtonClick(ev) { + ev.preventDefault(); + this.props.onClose(); + } + + onSaveButtonClick(ev) { + ev.preventDefault(); + + // Check if there were any changes and act accordingly + const { rows } = this.props.TopSites; + const pinQueue = []; + const unpinQueue = []; + this.state.shortcuts.forEach(shortcut => { + const alreadyPinned = rows.find( + row => + row && + row.isPinned && + row.searchTopSite && + row.label === shortcut.keyword + ); + if (shortcut.isSelected && !alreadyPinned) { + pinQueue.push(this._searchTopSite(shortcut)); + } else if (!shortcut.isSelected && alreadyPinned) { + unpinQueue.push({ + url: alreadyPinned.url, + searchVendor: shortcut.shortURL, + }); + } + }); + + // Tell the feed to do the work. + this.props.dispatch( + ac.OnlyToMain({ + type: at.UPDATE_PINNED_SEARCH_SHORTCUTS, + data: { + addedShortcuts: pinQueue, + deletedShortcuts: unpinQueue, + }, + }) + ); + + // Send the Telemetry pings. + pinQueue.forEach(shortcut => { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_ADD", + value: { search_vendor: shortcut.searchVendor }, + }) + ); + }); + unpinQueue.forEach(shortcut => { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_DELETE", + value: { search_vendor: shortcut.searchVendor }, + }) + ); + }); + + this.props.onClose(); + } + + _searchTopSite(shortcut) { + return { + url: shortcut.url, + searchTopSite: true, + label: shortcut.keyword, + searchVendor: shortcut.shortURL, + }; + } + + render() { + return ( + <form className="topsite-form"> + <div className="search-shortcuts-container"> + <h3 + className="section-title grey-title" + data-l10n-id="newtab-topsites-add-search-engine-header" + /> + <div> + {this.state.shortcuts.map(shortcut => ( + <SelectableSearchShortcut + key={shortcut.keyword} + shortcut={shortcut} + selected={shortcut.isSelected} + onChange={this.handleChange} + /> + ))} + </div> + </div> + <section className="actions"> + <button + className="cancel" + type="button" + onClick={this.onCancelButtonClick} + data-l10n-id="newtab-topsites-cancel-button" + /> + <button + className="done" + type="submit" + onClick={this.onSaveButtonClick} + data-l10n-id="newtab-topsites-save-button" + /> + </section> + </form> + ); + } +} diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx new file mode 100644 index 0000000000..b7f0038558 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -0,0 +1,889 @@ +/* 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.sys.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"; + +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); + } + + /* + * 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 + * as their position is fixed. + */ + _allowDrop(e) { + return ( + (this.dragged || !isSponsored(this.props.link)) && + 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; + } + } + + /** + * Helper to obtain the next state based on nextProps and prevState. + * + * NOTE: Rename this method to getDerivedStateFromProps when we update React + * to >= 16.3. We will need to update tests as well. We cannot rename this + * method to getDerivedStateFromProps now because there is a mismatch in + * the React version that we are using for both testing and production. + * (i.e. react-test-render => "16.3.2", react => "16.2.0"). + * + * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43. + */ + static getNextStateFromProps(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), + }; + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillMount() { + const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state); + if (nextState) { + this.setState(nextState); + } + } + + // NOTE: Remove this function when we update React to >= 16.3 since React will + // call getDerivedStateFromProps automatically. We will also need to + // rename getNextStateFromProps to getDerivedStateFromProps. + componentWillReceiveProps(nextProps) { + const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + 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 (this.props.link.searchTopSite && event.key === "Enter") { + 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 } = + 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(); + + 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 = ( + <ImpressionStats + flightId={link.flightId} + rows={[ + { + id: link.id, + pos: link.pos, + shim: link.shim && link.shim.impression, + advertiser: title.toLocaleLowerCase(), + }, + ]} + dispatch={this.props.dispatch} + source={TOP_SITES_SOURCE} + /> + ); + } else if (isSponsored(link)) { + // Record impressions for non-Pocket sponsored tiles. + impressionStats = ( + <TopSiteImpressionWrapper + actionType={at.TOP_SITES_SPONSORED_IMPRESSION_STATS} + tile={{ + position: this.props.index, + tile_id: link.sponsored_tile_id || -1, + reporting_url: link.sponsored_impression_url, + advertiser: title.toLocaleLowerCase(), + source: NEWTAB_SOURCE, + }} + // For testing. + IntersectionObserver={this.props.IntersectionObserver} + document={this.props.document} + dispatch={this.props.dispatch} + /> + ); + } else { + // Record impressions for organic tiles. + impressionStats = ( + <TopSiteImpressionWrapper + actionType={at.TOP_SITES_ORGANIC_IMPRESSION_STATS} + tile={{ + position: this.props.index, + source: NEWTAB_SOURCE, + }} + // For testing. + IntersectionObserver={this.props.IntersectionObserver} + document={this.props.document} + dispatch={this.props.dispatch} + /> + ); + } + + return ( + <li + className={topSiteOuterClassName} + onDrop={this.onDragEvent} + onDragOver={this.onDragEvent} + onDragEnter={this.onDragEvent} + onDragLeave={this.onDragEvent} + {...draggableProps} + > + <div className="top-site-inner"> + {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */} + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + <a + className="top-site-button" + href={link.searchTopSite ? undefined : link.url} + tabIndex="0" + onKeyPress={this.onKeyPress} + onClick={onClick} + draggable={true} + data-is-sponsored-link={!!link.sponsored_tile_id} + > + <div className="tile" aria-hidden={true}> + <div + className={ + selectedColor + ? "icon-wrapper letter-fallback" + : "icon-wrapper" + } + data-fallback={letterFallback} + style={selectedColor ? { backgroundColor: selectedColor } : {}} + > + <div className={imageClassName} style={imageStyle} /> + {showSmallFavicon && ( + <div + className="top-site-icon default-icon" + data-fallback={smallFaviconStyle ? "" : letterFallback} + style={smallFaviconStyle} + /> + )} + </div> + {link.searchTopSite && ( + <div className="top-site-icon search-topsite" /> + )} + </div> + <div + className={`title${link.isPinned ? " has-icon pinned" : ""}${ + link.type === SPOC_TYPE || link.show_sponsored_label + ? " sponsored" + : "" + }`} + > + <span dir="auto"> + {link.isPinned && <div className="icon icon-pin-small" />} + {title || <br />} + <span + className="sponsored-label" + data-l10n-id="newtab-topsite-sponsored" + /> + </span> + </div> + </a> + {children} + {impressionStats} + </div> + </li> + ); + } +} +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 }, + }), + }) + ); + + 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, + }, + }) + ); + + // 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, + }, + }) + ); + } 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, + }, + }) + ); + } 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, + }, + }) + ); + } + + 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.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 ( + <TopSiteLink + {...props} + onClick={this.onLinkClick} + onDragEvent={this.props.onDragEvent} + className={`${props.className || ""}${ + isContextMenuOpen ? " active" : "" + }`} + title={title} + > + <div> + <ContextMenuButton + tooltip="newtab-menu-content-tooltip" + tooltipArgs={{ title }} + onUpdate={this.onMenuUpdate} + > + <LinkMenu + dispatch={props.dispatch} + index={props.index} + onUpdate={this.onMenuUpdate} + options={menuOptions} + site={link} + shouldSendImpressionStats={link.type === SPOC_TYPE} + siteInfo={this._getTelemetryInfo()} + source={TOP_SITES_SOURCE} + /> + </ContextMenuButton> + </div> + </TopSiteLink> + ); + } +} +TopSite.defaultProps = { + link: {}, + onActivate() {}, +}; + +export class TopSitePlaceholder 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 ( + <TopSiteLink + {...this.props} + className={`placeholder ${this.props.className || ""}`} + isDraggable={false} + > + <button + aria-haspopup="dialog" + className="context-menu-button edit-button icon" + data-l10n-id="newtab-menu-topsites-placeholder-tooltip" + onClick={this.onEditButtonClick} + /> + </TopSiteLink> + ); + } +} + +export class _TopSiteList extends React.PureComponent { + static get DEFAULT_STATE() { + return { + activeIndex: null, + draggedIndex: null, + draggedSite: null, + draggedTitle: null, + topSitesPreview: null, + }; + } + + constructor(props) { + super(props); + this.state = _TopSiteList.DEFAULT_STATE; + this.onDragEvent = this.onDragEvent.bind(this); + this.onActivate = this.onActivate.bind(this); + } + + componentWillReceiveProps(nextProps) { + if (this.state.draggedSite) { + const prevTopSites = this.props.TopSites && this.props.TopSites.rows; + const newTopSites = nextProps.TopSites && nextProps.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. + 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 * TOP_SITES_MAX_SITES_PER_ROW; + 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 : null + ); + const unpinned = topSites.filter( + site => site && !site.isPinned && !isSponsored(site) + ); + 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 (isSponsored(preview[nextIndex])) { + 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 }); + } + + 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 maxNarrowVisibleIndex = props.TopSitesRows * 6; + + 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 ? link.url : holeIndex++, + index: i, + }; + if (i >= maxNarrowVisibleIndex) { + slotProps.className = "hide-for-narrow"; + } + + let topSiteLink; + // 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 && isSponsored(link))) { + topSiteLink = <TopSitePlaceholder {...slotProps} {...commonProps} />; + } else { + topSiteLink = ( + <TopSite + link={link} + activeIndex={this.state.activeIndex} + onActivate={this.onActivate} + {...slotProps} + {...commonProps} + colors={props.colors} + /> + ); + } + + topSitesUI.push(topSiteLink); + } + return ( + <ul + className={`top-sites-list${ + this.state.draggedSite ? " dnd-active" : "" + }`} + > + {topSitesUI} + </ul> + ); + } +} + +export const TopSiteList = connect(state => ({ + App: state.App, +}))(_TopSiteList); diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx new file mode 100644 index 0000000000..7dd61bdc93 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx @@ -0,0 +1,323 @@ +/* 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.sys.mjs"; +import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import React from "react"; +import { TOP_SITES_SOURCE } from "./TopSitesConstants"; +import { TopSiteFormInput } from "./TopSiteFormInput"; +import { TopSiteLink } from "./TopSite"; + +export class TopSiteForm extends React.PureComponent { + constructor(props) { + super(props); + const { site } = props; + this.state = { + label: site ? site.label || site.hostname : "", + url: site ? site.url : "", + validationError: false, + customScreenshotUrl: site ? site.customScreenshotURL : "", + showCustomScreenshotForm: site ? site.customScreenshotURL : false, + }; + this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this); + this.onLabelChange = this.onLabelChange.bind(this); + this.onUrlChange = this.onUrlChange.bind(this); + this.onCancelButtonClick = this.onCancelButtonClick.bind(this); + this.onClearUrlClick = this.onClearUrlClick.bind(this); + this.onDoneButtonClick = this.onDoneButtonClick.bind(this); + this.onCustomScreenshotUrlChange = + this.onCustomScreenshotUrlChange.bind(this); + this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this); + this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this); + this.validateUrl = this.validateUrl.bind(this); + } + + onLabelChange(event) { + this.setState({ label: event.target.value }); + } + + onUrlChange(event) { + this.setState({ + url: event.target.value, + validationError: false, + }); + } + + onClearUrlClick() { + this.setState({ + url: "", + validationError: false, + }); + } + + onEnableScreenshotUrlForm() { + this.setState({ showCustomScreenshotForm: true }); + } + + _updateCustomScreenshotInput(customScreenshotUrl) { + this.setState({ + customScreenshotUrl, + validationError: false, + }); + this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL }); + } + + onCustomScreenshotUrlChange(event) { + this._updateCustomScreenshotInput(event.target.value); + } + + onClearScreenshotInput() { + this._updateCustomScreenshotInput(""); + } + + onCancelButtonClick(ev) { + ev.preventDefault(); + this.props.onClose(); + } + + onDoneButtonClick(ev) { + ev.preventDefault(); + + if (this.validateForm()) { + const site = { url: this.cleanUrl(this.state.url) }; + const { index } = this.props; + if (this.state.label !== "") { + site.label = this.state.label; + } + + if (this.state.customScreenshotUrl) { + site.customScreenshotURL = this.cleanUrl( + this.state.customScreenshotUrl + ); + } else if (this.props.site && this.props.site.customScreenshotURL) { + // Used to flag that previously cached screenshot should be removed + site.customScreenshotURL = null; + } + this.props.dispatch( + ac.AlsoToMain({ + type: at.TOP_SITES_PIN, + data: { site, index }, + }) + ); + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "TOP_SITES_EDIT", + action_position: index, + }) + ); + + this.props.onClose(); + } + } + + onPreviewButtonClick(event) { + event.preventDefault(); + if (this.validateForm()) { + this.props.dispatch( + ac.AlsoToMain({ + type: at.PREVIEW_REQUEST, + data: { url: this.cleanUrl(this.state.customScreenshotUrl) }, + }) + ); + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "PREVIEW_REQUEST", + }) + ); + } + } + + cleanUrl(url) { + // If we are missing a protocol, prepend http:// + if (!url.startsWith("http:") && !url.startsWith("https:")) { + return `http://${url}`; + } + return url; + } + + _tryParseUrl(url) { + try { + return new URL(url); + } catch (e) { + return null; + } + } + + validateUrl(url) { + const validProtocols = ["http:", "https:"]; + const urlObj = + this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url)); + + return urlObj && validProtocols.includes(urlObj.protocol); + } + + validateCustomScreenshotUrl() { + const { customScreenshotUrl } = this.state; + return !customScreenshotUrl || this.validateUrl(customScreenshotUrl); + } + + validateForm() { + const validate = + this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl(); + + if (!validate) { + this.setState({ validationError: true }); + } + + return validate; + } + + _renderCustomScreenshotInput() { + const { customScreenshotUrl } = this.state; + const requestFailed = this.props.previewResponse === ""; + const validationError = + (this.state.validationError && !this.validateCustomScreenshotUrl()) || + requestFailed; + // Set focus on error if the url field is valid or when the input is first rendered and is empty + const shouldFocus = + (validationError && this.validateUrl(this.state.url)) || + !customScreenshotUrl; + const isLoading = + this.props.previewResponse === null && + customScreenshotUrl && + this.props.previewUrl === this.cleanUrl(customScreenshotUrl); + + if (!this.state.showCustomScreenshotForm) { + return ( + <A11yLinkButton + onClick={this.onEnableScreenshotUrlForm} + className="enable-custom-image-input" + data-l10n-id="newtab-topsites-use-image-link" + /> + ); + } + return ( + <div className="custom-image-input-container"> + <TopSiteFormInput + errorMessageId={ + requestFailed + ? "newtab-topsites-image-validation" + : "newtab-topsites-url-validation" + } + loading={isLoading} + onChange={this.onCustomScreenshotUrlChange} + onClear={this.onClearScreenshotInput} + shouldFocus={shouldFocus} + typeUrl={true} + value={customScreenshotUrl} + validationError={validationError} + titleId="newtab-topsites-image-url-label" + placeholderId="newtab-topsites-url-input" + /> + </div> + ); + } + + render() { + const { customScreenshotUrl } = this.state; + const requestFailed = this.props.previewResponse === ""; + // For UI purposes, editing without an existing link is "add" + const showAsAdd = !this.props.site; + const previous = + (this.props.site && this.props.site.customScreenshotURL) || ""; + const changed = + customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous; + // Preview mode if changes were made to the custom screenshot URL and no preview was received yet + // or the request failed + const previewMode = changed && !this.props.previewResponse; + const previewLink = Object.assign({}, this.props.site); + if (this.props.previewResponse) { + previewLink.screenshot = this.props.previewResponse; + previewLink.customScreenshotURL = this.props.previewUrl; + } + // Handles the form submit so an enter press performs the correct action + const onSubmit = previewMode + ? this.onPreviewButtonClick + : this.onDoneButtonClick; + + const addTopsitesHeaderL10nId = "newtab-topsites-add-shortcut-header"; + const editTopsitesHeaderL10nId = "newtab-topsites-edit-shortcut-header"; + return ( + <form className="topsite-form" onSubmit={onSubmit}> + <div className="form-input-container"> + <h3 + className="section-title grey-title" + data-l10n-id={ + showAsAdd ? addTopsitesHeaderL10nId : editTopsitesHeaderL10nId + } + /> + <div className="fields-and-preview"> + <div className="form-wrapper"> + <TopSiteFormInput + onChange={this.onLabelChange} + value={this.state.label} + titleId="newtab-topsites-title-label" + placeholderId="newtab-topsites-title-input" + autoFocusOnOpen={true} + /> + <TopSiteFormInput + onChange={this.onUrlChange} + shouldFocus={ + this.state.validationError && + !this.validateUrl(this.state.url) + } + value={this.state.url} + onClear={this.onClearUrlClick} + validationError={ + this.state.validationError && + !this.validateUrl(this.state.url) + } + titleId="newtab-topsites-url-label" + typeUrl={true} + placeholderId="newtab-topsites-url-input" + errorMessageId="newtab-topsites-url-validation" + /> + {this._renderCustomScreenshotInput()} + </div> + <TopSiteLink + link={previewLink} + defaultStyle={requestFailed} + title={this.state.label} + /> + </div> + </div> + <section className="actions"> + <button + className="cancel" + type="button" + onClick={this.onCancelButtonClick} + data-l10n-id="newtab-topsites-cancel-button" + /> + {previewMode ? ( + <button + className="done preview" + type="submit" + data-l10n-id="newtab-topsites-preview-button" + /> + ) : ( + <button + className="done" + type="submit" + data-l10n-id={ + showAsAdd + ? "newtab-topsites-add-button" + : "newtab-topsites-save-button" + } + /> + )} + </section> + </form> + ); + } +} + +TopSiteForm.defaultProps = { + site: null, + index: -1, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx new file mode 100644 index 0000000000..c680edc7e4 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx @@ -0,0 +1,111 @@ +/* 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 React from "react"; + +export class TopSiteFormInput extends React.PureComponent { + constructor(props) { + super(props); + this.state = { validationError: this.props.validationError }; + this.onChange = this.onChange.bind(this); + this.onMount = this.onMount.bind(this); + this.onClearIconPress = this.onClearIconPress.bind(this); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.shouldFocus && !this.props.shouldFocus) { + this.input.focus(); + } + if (nextProps.validationError && !this.props.validationError) { + this.setState({ validationError: true }); + } + // If the component is in an error state but the value was cleared by the parent + if (this.state.validationError && !nextProps.value) { + this.setState({ validationError: false }); + } + } + + onClearIconPress(event) { + // If there is input in the URL or custom image URL fields, + // and we hit 'enter' while tabbed over the clear icon, + // we should execute the function to clear the field. + if (event.key === "Enter") { + this.props.onClear(); + } + } + + onChange(ev) { + if (this.state.validationError) { + this.setState({ validationError: false }); + } + this.props.onChange(ev); + } + + onMount(input) { + this.input = input; + } + + renderLoadingOrCloseButton() { + const showClearButton = this.props.value && this.props.onClear; + + if (this.props.loading) { + return ( + <div className="loading-container"> + <div className="loading-animation" /> + </div> + ); + } else if (showClearButton) { + return ( + <button + type="button" + className="icon icon-clear-input icon-button-style" + onClick={this.props.onClear} + onKeyPress={this.onClearIconPress} + /> + ); + } + return null; + } + + render() { + const { typeUrl } = this.props; + const { validationError } = this.state; + + return ( + <label> + <span data-l10n-id={this.props.titleId} /> + <div + className={`field ${typeUrl ? "url" : ""}${ + validationError ? " invalid" : "" + }`} + > + <input + type="text" + value={this.props.value} + ref={this.onMount} + onChange={this.onChange} + data-l10n-id={this.props.placeholderId} + // Set focus on error if the url field is valid or when the input is first rendered and is empty + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={this.props.autoFocusOnOpen} + disabled={this.props.loading} + /> + {this.renderLoadingOrCloseButton()} + {validationError && ( + <aside + className="error-tooltip" + data-l10n-id={this.props.errorMessageId} + /> + )} + </div> + </label> + ); + } +} + +TopSiteFormInput.defaultProps = { + showClearButton: false, + value: "", + validationError: false, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx new file mode 100644 index 0000000000..580809dd57 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx @@ -0,0 +1,149 @@ +/* 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 } from "common/Actions.sys.mjs"; +import React from "react"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +// Per analytical requirement, we set the minimal intersection ratio to +// 0.5, and an impression is identified when the wrapped item has at least +// 50% visibility. +// +// This constant is exported for unit test +export const INTERSECTION_RATIO = 0.5; + +/** + * Impression wrapper for a TopSite tile. + * + * It makses use of the Intersection Observer API to detect the visibility, + * and relies on page visibility to ensure the impression is reported + * only when the component is visible on the page. + */ +export class TopSiteImpressionWrapper extends React.PureComponent { + _dispatchImpressionStats() { + const { actionType, tile } = this.props; + if (!actionType) { + return; + } + + this.props.dispatch( + ac.OnlyToMain({ + type: actionType, + data: { + type: "impression", + ...tile, + }, + }) + ); + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this.setImpressionObserver(); + } else { + // We should only ever send the latest impression stats ping, so remove any + // older listeners. + if (this._onVisibilityChange) { + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + this.setImpressionObserver(); + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * Set an impression observer for the wrapped component. It makes use of + * the Intersection Observer API to detect if the wrapped component is + * visible with a desired ratio, and only sends impression if that's the case. + * + * See more details about Intersection Observer API at: + * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API + */ + setImpressionObserver() { + const { props } = this; + + if (!props.tile) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.topsite_impression_wrapper); + } + + componentDidMount() { + if (this.props.tile) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( + <div + ref={"topsite_impression_wrapper"} + className="topsite-impression-observer" + > + {this.props.children} + </div> + ); + } +} + +TopSiteImpressionWrapper.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + actionType: null, + tile: null, +}; diff --git a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx new file mode 100644 index 0000000000..c69156c514 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx @@ -0,0 +1,213 @@ +/* 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.sys.mjs"; +import { MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE } from "./TopSitesConstants"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { connect } from "react-redux"; +import { ModalOverlayWrapper } from "content-src/components/ModalOverlay/ModalOverlay"; +import React from "react"; +import { SearchShortcutsForm } from "./SearchShortcutsForm"; +import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs"; +import { TopSiteForm } from "./TopSiteForm"; +import { TopSiteList } from "./TopSite"; + +function topSiteIconType(link) { + if (link.customScreenshotURL) { + return "custom_screenshot"; + } + if (link.tippyTopIcon || link.faviconRef === "tippytop") { + return "tippytop"; + } + if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) { + return "rich_icon"; + } + if (link.screenshot) { + return "screenshot"; + } + return "no_image"; +} + +/** + * Iterates through TopSites and counts types of images. + * @param acc Accumulator for reducer. + * @param topsite Entry in TopSites. + */ +function countTopSitesIconsTypes(topSites) { + const countTopSitesTypes = (acc, link) => { + acc[topSiteIconType(link)]++; + return acc; + }; + + return topSites.reduce(countTopSitesTypes, { + custom_screenshot: 0, + screenshot: 0, + tippytop: 0, + rich_icon: 0, + no_image: 0, + }); +} + +export class _TopSites extends React.PureComponent { + constructor(props) { + super(props); + this.onEditFormClose = this.onEditFormClose.bind(this); + this.onSearchShortcutsFormClose = + this.onSearchShortcutsFormClose.bind(this); + } + + /** + * Dispatch session statistics about the quality of TopSites icons and pinned count. + */ + _dispatchTopSitesStats() { + const topSites = this._getVisibleTopSites().filter( + topSite => topSite !== null && topSite !== undefined + ); + const topSitesIconsStats = countTopSitesIconsTypes(topSites); + const topSitesPinned = topSites.filter(site => !!site.isPinned).length; + const searchShortcuts = topSites.filter( + site => !!site.searchTopSite + ).length; + // Dispatch telemetry event with the count of TopSites images types. + this.props.dispatch( + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { + topsites_icon_stats: topSitesIconsStats, + topsites_pinned: topSitesPinned, + topsites_search_shortcuts: searchShortcuts, + }, + }) + ); + } + + /** + * Return the TopSites that are visible based on prefs and window width. + */ + _getVisibleTopSites() { + // We hide 2 sites per row when not in the wide layout. + let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW; + // $break-point-widest = 1072px (from _variables.scss) + if (!global.matchMedia(`(min-width: 1072px)`).matches) { + sitesPerRow -= 2; + } + return this.props.TopSites.rows.slice( + 0, + this.props.TopSitesRows * sitesPerRow + ); + } + + componentDidUpdate() { + this._dispatchTopSitesStats(); + } + + componentDidMount() { + this._dispatchTopSitesStats(); + } + + onEditFormClose() { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "TOP_SITES_EDIT_CLOSE", + }) + ); + this.props.dispatch({ type: at.TOP_SITES_CANCEL_EDIT }); + } + + onSearchShortcutsFormClose() { + this.props.dispatch( + ac.UserEvent({ + source: TOP_SITES_SOURCE, + event: "SEARCH_EDIT_CLOSE", + }) + ); + this.props.dispatch({ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL }); + } + + render() { + const { props } = this; + const { editForm, showSearchShortcutsForm } = props.TopSites; + const extraMenuOptions = ["AddTopSite"]; + const colors = props.Prefs.values["newNewtabExperience.colors"]; + + if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) { + extraMenuOptions.push("AddSearchShortcut"); + } + + return ( + <ComponentPerfTimer + id="topsites" + initialized={props.TopSites.initialized} + dispatch={props.dispatch} + > + <CollapsibleSection + className="top-sites" + id="topsites" + title={props.title || { id: "newtab-section-header-topsites" }} + hideTitle={true} + extraMenuOptions={extraMenuOptions} + showPrefName="feeds.topsites" + eventSource={TOP_SITES_SOURCE} + collapsed={false} + isFixed={props.isFixed} + isFirst={props.isFirst} + isLast={props.isLast} + dispatch={props.dispatch} + > + <TopSiteList + TopSites={props.TopSites} + TopSitesRows={props.TopSitesRows} + dispatch={props.dispatch} + topSiteIconType={topSiteIconType} + colors={colors} + /> + <div className="edit-topsites-wrapper"> + {editForm && ( + <div className="edit-topsites"> + <ModalOverlayWrapper + unstyled={true} + onClose={this.onEditFormClose} + innerClassName="modal" + > + <TopSiteForm + site={props.TopSites.rows[editForm.index]} + onClose={this.onEditFormClose} + dispatch={this.props.dispatch} + {...editForm} + /> + </ModalOverlayWrapper> + </div> + )} + {showSearchShortcutsForm && ( + <div className="edit-search-shortcuts"> + <ModalOverlayWrapper + unstyled={true} + onClose={this.onSearchShortcutsFormClose} + innerClassName="modal" + > + <SearchShortcutsForm + TopSites={props.TopSites} + onClose={this.onSearchShortcutsFormClose} + dispatch={this.props.dispatch} + /> + </ModalOverlayWrapper> + </div> + )} + </div> + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +export const TopSites = connect((state, props) => ({ + TopSites: state.TopSites, + Prefs: state.Prefs, + TopSitesRows: state.Prefs.values.topSitesRows, +}))(_TopSites); diff --git a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js new file mode 100644 index 0000000000..f488896238 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js @@ -0,0 +1,39 @@ +/* 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/. */ + +export const TOP_SITES_SOURCE = "TOP_SITES"; +export const TOP_SITES_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "DeleteUrl", +]; +export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "ShowPrivacyInfo", +]; +export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + "AboutSponsored", +]; +// the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite +export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [ + "CheckPinTopSite", + "Separator", + "BlockUrl", +]; +// minimum size necessary to show a rich icon instead of a screenshot +export const MIN_RICH_FAVICON_SIZE = 96; +// minimum size necessary to show any icon +export const MIN_SMALL_FAVICON_SIZE = 16; diff --git a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss new file mode 100644 index 0000000000..ff2e2df826 --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss @@ -0,0 +1,631 @@ +@use 'sass:math'; + +/* stylelint-disable max-nesting-depth */ + +$top-sites-size: $grid-unit-small; +$top-sites-border-radius: 8px; +$top-sites-icon-border-radius: 4px; +$rich-icon-size: 96px; +$default-icon-wrapper-size: 32px; +$default-icon-size: 32px; +$default-icon-offset: 6px; +$half-base-gutter: math.div($base-gutter, 2); +$hover-transition-duration: 150ms; +$letter-fallback-color: $white; + +.top-sites-list { + list-style: none; + margin: 0 (-$half-base-gutter); + padding: 0; + + a { + text-decoration: none; + } + + &:not(.dnd-active) { + .top-site-outer:is(.active, :focus, :hover) { + background: var(--newtab-element-hover-color); + } + } + + // Two columns + @media (max-width: $break-point-medium) { + > :nth-child(2n+1) { + @include context-menu-open-middle; + } + + > :nth-child(2n) { + @include context-menu-open-left; + } + } + + // Four columns + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(4n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) { + :nth-child(4n+3) { + @include context-menu-open-left; + } + } + + // Six columns + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(6n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) { + :nth-child(6n+5) { + @include context-menu-open-left; + } + } + + // Eight columns + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + :nth-child(8n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) { + :nth-child(8n+7) { + @include context-menu-open-left; + } + } + + .hide-for-narrow { + display: none; + } + + @media (min-width: $break-point-medium) { + .hide-for-narrow { + display: inline-block; + } + } + + @media (min-width: $break-point-large) { + .hide-for-narrow { + display: none; + } + } + + @media (min-width: $break-point-widest) { + .hide-for-narrow { + display: inline-block; + } + } +} + +// container for drop zone +.top-site-outer { + width: 120px; + border-radius: 8px; + display: inline-block; + margin-block-end: 16px; + + // container for context menu + .top-site-inner { + position: relative; + + > a { + padding: 20px $half-base-gutter 4px; + color: inherit; + display: block; + outline: none; + } + } + + &:is(:hover) { + .context-menu-button { + opacity: 1; + } + } + + .context-menu-button { + background-image: url('chrome://global/skin/icons/more.svg'); + border: 0; + border-radius: 4px; + cursor: pointer; + fill: var(--newtab-text-primary-color); + -moz-context-properties: fill; + height: 20px; + width: 20px; + inset-inline-end: 3px; + opacity: 0; + position: absolute; + top: -20px; + transition: opacity 200ms; + + &:is(:active, :focus) { + outline: 0; + opacity: 1; + background-color: var(--newtab-element-hover-color); + fill: var(--newtab-primary-action-background); + } + } + + .tile { + border-radius: $top-sites-border-radius; + box-shadow: $shadow-card; + background-color: var(--newtab-background-color-secondary); + justify-content: center; + margin: 0 auto; + height: $top-sites-size; + width: $top-sites-size; + cursor: pointer; + position: relative; + + // For letter fallback + align-items: center; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 32px; + font-weight: 200; + text-transform: uppercase; + + .icon-wrapper { + border-radius: 4px; + width: 48px; + height: 48px; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + justify-content: center; + + &.letter-fallback::before { + content: attr(data-fallback); + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; + font-size: 64px; + font-weight: 800; + transform: rotate(-10deg); + top: 6px; + position: relative; + color: $letter-fallback-color; + } + } + } + + // Some common styles for all icons (rich and default) in top sites + .top-site-icon { + background-color: var(--newtab-background-color-secondary); + background-position: center center; + background-repeat: no-repeat; + border-radius: $top-sites-icon-border-radius; + position: absolute; + } + + .rich-icon { + background-size: cover; + height: 100%; + inset-inline-start: 0; + top: 0; + width: 100%; + } + + .default-icon, + .search-topsite { + background-size: $default-icon-size; + height: $default-icon-wrapper-size; + width: $default-icon-wrapper-size; + + // for corner letter fallback + align-items: center; + display: flex; + font-size: 20px; + justify-content: center; + + &[data-fallback]::before { + content: attr(data-fallback); + } + } + + .search-topsite { + background-image: url('chrome://global/skin/icons/search-glass.svg'); + background-size: 16px; + background-color: var(--newtab-primary-action-background); + border-radius: $default-icon-wrapper-size; + -moz-context-properties: fill; + fill: var(--newtab-primary-element-text-color); + box-shadow: $shadow-card; + transition-duration: $hover-transition-duration; + transition-property: background-size, bottom, inset-inline-end, height, width; + height: 32px; + width: 32px; + bottom: -$default-icon-offset; + inset-inline-end: -$default-icon-offset; + } + + &.placeholder { + .tile { + box-shadow: $inner-box-shadow; + } + } + + .title { + color: var(--newtab-text-primary-color); + padding-top: 8px; + font: caption; + text-align: center; + position: relative; + + .icon { + margin-inline-end: 2px; + fill: var(--newtab-text-primary-color); + } + + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sponsored-label { + color: var(--newtab-text-secondary-color); + font-size: 0.9em; + } + + &:not(.sponsored) .sponsored-label { + visibility: hidden; + } + } + + // We want all search shortcuts to have a white background in case they have transparency. + &.search-shortcut { + .rich-icon { + background-color: $white; + } + } + + .edit-button { + background-image: url('chrome://global/skin/icons/edit.svg'); + } + + &.dragged { + .tile { + *, + &::before { + display: none; + } + } + + .title { + visibility: hidden; + } + } +} + +.edit-topsites-wrapper { + .top-site-inner > .top-site-button > .tile { + border: 1px solid var(--newtab-border-color); + } + + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: 0 auto; + max-height: calc(100% - 40px); + position: fixed; + right: 0; + top: 40px; + width: $wrapper-default-width; + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + } +} + +.topsite-form { + $form-width: 300px; + $form-spacing: 32px; + + .section-title { + font-size: 16px; + margin: 0 0 16px; + } + + .form-input-container { + max-width: $form-width + 3 * $form-spacing + $rich-icon-size; + margin: 0 auto; + padding: $form-spacing; + + .top-site-outer { + pointer-events: none; + } + } + + .search-shortcuts-container { + max-width: 700px; + margin: 0 auto; + padding: $form-spacing; + + > div { + margin-inline-end: -39px; + } + + .top-site-outer { + margin-inline-start: 0; + margin-inline-end: 39px; + } + } + + .top-site-outer { + padding: 0; + margin: 24px 0 0; + margin-inline-start: $form-spacing; + } + + .fields-and-preview { + display: flex; + } + + label { + font-size: $section-title-font-size; + } + + .form-wrapper { + width: 100%; + + .field { + position: relative; + + .icon-clear-input { + position: absolute; + transform: translateY(-50%); + top: 50%; + inset-inline-end: 8px; + } + } + + .url { + input:dir(ltr) { + padding-right: 32px; + } + + input:dir(rtl) { + padding-left: 32px; + + &:not(:placeholder-shown) { + direction: ltr; + text-align: right; + } + } + } + + .enable-custom-image-input { + display: inline-block; + font-size: 13px; + margin-top: 4px; + cursor: pointer; + } + + .custom-image-input-container { + margin-top: 4px; + + .loading-container { + width: 16px; + height: 16px; + overflow: hidden; + position: absolute; + transform: translateY(-50%); + top: 50%; + inset-inline-end: 8px; + } + + // This animation is derived from Firefox's tab loading animation + // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216 + .loading-animation { + @keyframes tab-throbber-animation { + 100% { transform: translateX(-960px); } + } + + @keyframes tab-throbber-animation-rtl { + 100% { transform: translateX(960px); } + } + + width: 960px; + height: 16px; + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + background-image: url('chrome://browser/skin/tabbrowser/loading.svg'); + animation: tab-throbber-animation 1.05s steps(60) infinite; + + &:dir(rtl) { + animation-name: tab-throbber-animation-rtl; + } + } + } + + input { + &[type='text'] { + background-color: var(--newtab-background-color-secondary); + border: $input-border; + margin: 8px 0; + padding: 0 8px; + height: 32px; + width: 100%; + font-size: 15px; + + &[disabled] { + border: $input-border; + box-shadow: none; + opacity: 0.4; + } + } + } + + .invalid { + input { + &[type='text'] { + border: $input-error-border; + box-shadow: $input-error-boxshadow; + } + } + } + + .error-tooltip { + animation: fade-up-tt 450ms; + background: var(--newtab-status-error); + border-radius: 2px; + color: $white; + inset-inline-start: 3px; + padding: 5px 12px; + position: absolute; + top: 44px; + z-index: 1; + + // tooltip caret + &::before { + background: var(--newtab-status-error); + bottom: -8px; + content: '.'; + height: 16px; + inset-inline-start: 12px; + position: absolute; + text-indent: -999px; + top: -7px; + transform: rotate(45deg); + white-space: nowrap; + width: 16px; + z-index: -1; + } + } + } + + .actions { + justify-content: flex-end; + + button { + margin-inline-start: 10px; + margin-inline-end: 0; + } + } + + @media (max-width: $break-point-medium) { + .fields-and-preview { + flex-direction: column; + + .top-site-outer { + margin-inline-start: 0; + } + } + } + + // prevent text selection of keyword label when clicking to select + .title { + user-select: none; + } + + // CSS styled checkbox + [type='checkbox']:not(:checked), + [type='checkbox']:checked { + inset-inline-start: -9999px; + position: absolute; + } + + [type='checkbox']:not(:checked) + label, + [type='checkbox']:checked + label { + cursor: pointer; + display: block; + position: relative; + } + + $checkbox-offset: -8px; + + [type='checkbox']:not(:checked) + label::before, + [type='checkbox']:checked + label::before { + background: var(--newtab-background-color); + border: $input-border; + border-radius: $border-radius; + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + z-index: 1; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // checkmark + [type='checkbox']:not(:checked) + label::after, + [type='checkbox']:checked + label::after { + background: url('chrome://global/skin/icons/check.svg') no-repeat center center; + content: ''; + height: 21px; + left: $checkbox-offset; + position: absolute; + top: $checkbox-offset; + width: 21px; + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + z-index: 2; + + [dir='rtl'] & { + left: auto; + right: $checkbox-offset; + } + } + + // when selected, highlight the tile + [type='checkbox']:checked + label { + .tile { + box-shadow: $shadow-focus; + } + } + + // checkmark changes + [type='checkbox']:not(:checked) + label::after { + opacity: 0; + } + + [type='checkbox']:checked + label::after { + opacity: 1; + } + + // accessibility + [type='checkbox']:checked:focus + label::before, + [type='checkbox']:not(:checked):focus + label::before { + border: 1px dotted var(--newtab-primary-action-background); + } +} + +// used for tooltips below form element +@keyframes fade-up-tt { + 0% { + opacity: 0; + transform: translateY(15px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +// used for TopSites impression wrapper +.topsite-impression-observer { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} |