diff options
Diffstat (limited to 'browser/components/newtab/content-src/components')
87 files changed, 14022 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx new file mode 100644 index 0000000000..3aab52cdff --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/A11yLinkButton.jsx @@ -0,0 +1,18 @@ +/* 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 function A11yLinkButton(props) { + // function for merging classes, if necessary + let className = "a11y-link-button"; + if (props.className) { + className += ` ${props.className}`; + } + return ( + <button type="button" {...props} className={className}> + {props.children} + </button> + ); +} diff --git a/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss new file mode 100644 index 0000000000..c87fc93b60 --- /dev/null +++ b/browser/components/newtab/content-src/components/A11yLinkButton/_A11yLinkButton.scss @@ -0,0 +1,13 @@ + +.a11y-link-button { + border: 0; + padding: 0; + cursor: pointer; + text-align: unset; + color: var(--newtab-primary-action-background); + + &:hover, + &:focus { + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx new file mode 100644 index 0000000000..3762be9c99 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.jsx @@ -0,0 +1,1967 @@ +/* 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 { ASRouterUtils } from "../../asrouter/asrouter-utils"; +import { connect } from "react-redux"; +import React from "react"; +import { SimpleHashRouter } from "./SimpleHashRouter"; +import { CopyButton } from "./CopyButton"; + +const Row = props => ( + <tr className="message-item" {...props}> + {props.children} + </tr> +); + +function relativeTime(timestamp) { + if (!timestamp) { + return ""; + } + const seconds = Math.floor((Date.now() - timestamp) / 1000); + const minutes = Math.floor((Date.now() - timestamp) / 60000); + if (seconds < 2) { + return "just now"; + } else if (seconds < 60) { + return `${seconds} seconds ago`; + } else if (minutes === 1) { + return "1 minute ago"; + } else if (minutes < 600) { + return `${minutes} minutes ago`; + } + return new Date(timestamp).toLocaleString(); +} + +const LAYOUT_VARIANTS = { + basic: "Basic default layout (on by default in nightly)", + staging_spocs: "A layout with all spocs shown", + "dev-test-all": + "A little bit of everything. Good layout for testing all components", + "dev-test-feeds": "Stress testing for slow feeds", +}; + +export class ToggleStoryButton extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.onClick(this.props.story); + } + + render() { + return <button onClick={this.handleClick}>collapse/open</button>; + } +} + +export class ToggleMessageJSON extends React.PureComponent { + constructor(props) { + super(props); + this.handleClick = this.handleClick.bind(this); + } + + handleClick() { + this.props.toggleJSON(this.props.msgId); + } + + render() { + let iconName = this.props.isCollapsed + ? "icon icon-arrowhead-forward-small" + : "icon icon-arrowhead-down-small"; + return ( + <button className="clearButton" onClick={this.handleClick}> + <span className={iconName} /> + </button> + ); + } +} + +export class TogglePrefCheckbox extends React.PureComponent { + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + onChange(event) { + this.props.onChange(this.props.pref, event.target.checked); + } + + render() { + return ( + <> + <input + type="checkbox" + checked={this.props.checked} + onChange={this.onChange} + disabled={this.props.disabled} + />{" "} + {this.props.pref}{" "} + </> + ); + } +} + +export class Personalization extends React.PureComponent { + constructor(props) { + super(props); + this.togglePersonalization = this.togglePersonalization.bind(this); + } + + togglePersonalization() { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_PERSONALIZATION_TOGGLE, + }) + ); + } + + render() { + const { lastUpdated, initialized } = this.props.state.Personalization; + return ( + <React.Fragment> + <table> + <tbody> + <Row> + <td colSpan="2"> + <TogglePrefCheckbox + checked={this.props.personalized} + pref="personalized" + onChange={this.togglePersonalization} + /> + </td> + </Row> + <Row> + <td className="min">Personalization Last Updated</td> + <td>{relativeTime(lastUpdated) || "(no data)"}</td> + </Row> + <Row> + <td className="min">Personalization Initialized</td> + <td>{initialized ? "true" : "false"}</td> + </Row> + </tbody> + </table> + </React.Fragment> + ); + } +} + +export class DiscoveryStreamAdmin extends React.PureComponent { + constructor(props) { + super(props); + this.restorePrefDefaults = this.restorePrefDefaults.bind(this); + this.setConfigValue = this.setConfigValue.bind(this); + this.expireCache = this.expireCache.bind(this); + this.refreshCache = this.refreshCache.bind(this); + this.idleDaily = this.idleDaily.bind(this); + this.systemTick = this.systemTick.bind(this); + this.syncRemoteSettings = this.syncRemoteSettings.bind(this); + this.changeEndpointVariant = this.changeEndpointVariant.bind(this); + this.onStoryToggle = this.onStoryToggle.bind(this); + this.state = { + toggledStories: {}, + }; + } + + setConfigValue(name, value) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_SET_VALUE, + data: { name, value }, + }) + ); + } + + restorePrefDefaults(event) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_RESET_DEFAULTS, + }) + ); + } + + refreshCache() { + const { config } = this.props.state.DiscoveryStream; + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_CONFIG_CHANGE, + data: config, + }) + ); + } + + dispatchSimpleAction(type) { + this.props.dispatch( + ac.OnlyToMain({ + type, + }) + ); + } + + systemTick() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYSTEM_TICK); + } + + expireCache() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_EXPIRE_CACHE); + } + + idleDaily() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_IDLE_DAILY); + } + + syncRemoteSettings() { + this.dispatchSimpleAction(at.DISCOVERY_STREAM_DEV_SYNC_RS); + } + + changeEndpointVariant(event) { + const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; + if (endpoint) { + this.setConfigValue( + "layout_endpoint", + endpoint.replace( + /layout_variant=.+/, + `layout_variant=${event.target.value}` + ) + ); + } + } + + renderComponent(width, component) { + return ( + <table> + <tbody> + <Row> + <td className="min">Type</td> + <td>{component.type}</td> + </Row> + <Row> + <td className="min">Width</td> + <td>{width}</td> + </Row> + {component.feed && this.renderFeed(component.feed)} + </tbody> + </table> + ); + } + + isCurrentVariant(id) { + const endpoint = this.props.state.DiscoveryStream.config.layout_endpoint; + const isMatch = endpoint && !!endpoint.match(`layout_variant=${id}`); + return isMatch; + } + + renderFeedData(url) { + const { feeds } = this.props.state.DiscoveryStream; + const feed = feeds.data[url].data; + return ( + <React.Fragment> + <h4>Feed url: {url}</h4> + <table> + <tbody> + {feed.recommendations?.map(story => this.renderStoryData(story))} + </tbody> + </table> + </React.Fragment> + ); + } + + renderFeedsData() { + const { feeds } = this.props.state.DiscoveryStream; + return ( + <React.Fragment> + {Object.keys(feeds.data).map(url => this.renderFeedData(url))} + </React.Fragment> + ); + } + + renderSpocs() { + const { spocs } = this.props.state.DiscoveryStream; + let spocsData = []; + if (spocs.data && spocs.data.spocs && spocs.data.spocs.items) { + spocsData = spocs.data.spocs.items || []; + } + + return ( + <React.Fragment> + <table> + <tbody> + <Row> + <td className="min">spocs_endpoint</td> + <td>{spocs.spocs_endpoint}</td> + </Row> + <Row> + <td className="min">Data last fetched</td> + <td>{relativeTime(spocs.lastUpdated)}</td> + </Row> + </tbody> + </table> + <h4>Spoc data</h4> + <table> + <tbody>{spocsData.map(spoc => this.renderStoryData(spoc))}</tbody> + </table> + <h4>Spoc frequency caps</h4> + <table> + <tbody> + {spocs.frequency_caps.map(spoc => this.renderStoryData(spoc))} + </tbody> + </table> + </React.Fragment> + ); + } + + onStoryToggle(story) { + const { toggledStories } = this.state; + this.setState({ + toggledStories: { + ...toggledStories, + [story.id]: !toggledStories[story.id], + }, + }); + } + + renderStoryData(story) { + let storyData = ""; + if (this.state.toggledStories[story.id]) { + storyData = JSON.stringify(story, null, 2); + } + return ( + <tr className="message-item" key={story.id}> + <td className="message-id"> + <span> + {story.id} <br /> + </span> + <ToggleStoryButton story={story} onClick={this.onStoryToggle} /> + </td> + <td className="message-summary"> + <pre>{storyData}</pre> + </td> + </tr> + ); + } + + renderFeed(feed) { + const { feeds } = this.props.state.DiscoveryStream; + if (!feed.url) { + return null; + } + return ( + <React.Fragment> + <Row> + <td className="min">Feed url</td> + <td>{feed.url}</td> + </Row> + <Row> + <td className="min">Data last fetched</td> + <td> + {relativeTime( + feeds.data[feed.url] ? feeds.data[feed.url].lastUpdated : null + ) || "(no data)"} + </td> + </Row> + </React.Fragment> + ); + } + + render() { + const prefToggles = "enabled hardcoded_layout show_spocs collapsible".split( + " " + ); + const { config, lastUpdated, layout } = this.props.state.DiscoveryStream; + const personalized = + this.props.otherPrefs["discoverystream.personalization.enabled"]; + return ( + <div> + <button className="button" onClick={this.restorePrefDefaults}> + Restore Pref Defaults + </button>{" "} + <button className="button" onClick={this.refreshCache}> + Refresh Cache + </button> + <br /> + <button className="button" onClick={this.expireCache}> + Expire Cache + </button>{" "} + <button className="button" onClick={this.systemTick}> + Trigger System Tick + </button>{" "} + <button className="button" onClick={this.idleDaily}> + Trigger Idle Daily + </button> + <br /> + <button className="button" onClick={this.syncRemoteSettings}> + Sync Remote Settings + </button> + <table> + <tbody> + {prefToggles.map(pref => ( + <Row key={pref}> + <td> + <TogglePrefCheckbox + checked={config[pref]} + pref={pref} + onChange={this.setConfigValue} + /> + </td> + </Row> + ))} + </tbody> + </table> + <h3>Endpoint variant</h3> + <p> + You can also change this manually by changing this pref:{" "} + <code>browser.newtabpage.activity-stream.discoverystream.config</code> + </p> + <table + style={ + config.enabled && !config.hardcoded_layout ? null : { opacity: 0.5 } + } + > + <tbody> + {Object.keys(LAYOUT_VARIANTS).map(id => ( + <Row key={id}> + <td className="min"> + <input + type="radio" + value={id} + checked={this.isCurrentVariant(id)} + onChange={this.changeEndpointVariant} + /> + </td> + <td className="min">{id}</td> + <td>{LAYOUT_VARIANTS[id]}</td> + </Row> + ))} + </tbody> + </table> + <h3>Caching info</h3> + <table style={config.enabled ? null : { opacity: 0.5 }}> + <tbody> + <Row> + <td className="min">Data last fetched</td> + <td>{relativeTime(lastUpdated) || "(no data)"}</td> + </Row> + </tbody> + </table> + <h3>Layout</h3> + {layout.map((row, rowIndex) => ( + <div key={`row-${rowIndex}`}> + {row.components.map((component, componentIndex) => ( + <div key={`component-${componentIndex}`} className="ds-component"> + {this.renderComponent(row.width, component)} + </div> + ))} + </div> + ))} + <h3>Personalization</h3> + <Personalization + personalized={personalized} + dispatch={this.props.dispatch} + state={{ + Personalization: this.props.state.Personalization, + }} + /> + <h3>Spocs</h3> + {this.renderSpocs()} + <h3>Feeds Data</h3> + {this.renderFeedsData()} + </div> + ); + } +} + +export class ASRouterAdminInner extends React.PureComponent { + constructor(props) { + super(props); + this.handleEnabledToggle = this.handleEnabledToggle.bind(this); + this.handleUserPrefToggle = this.handleUserPrefToggle.bind(this); + this.onChangeMessageFilter = this.onChangeMessageFilter.bind(this); + this.onChangeMessageGroupsFilter = + this.onChangeMessageGroupsFilter.bind(this); + this.unblockAll = this.unblockAll.bind(this); + this.handleClearAllImpressionsByProvider = + this.handleClearAllImpressionsByProvider.bind(this); + this.handleExpressionEval = this.handleExpressionEval.bind(this); + this.onChangeTargetingParameters = + this.onChangeTargetingParameters.bind(this); + this.onChangeAttributionParameters = + this.onChangeAttributionParameters.bind(this); + this.setAttribution = this.setAttribution.bind(this); + this.onCopyTargetingParams = this.onCopyTargetingParams.bind(this); + this.onNewTargetingParams = this.onNewTargetingParams.bind(this); + this.handleOpenPB = this.handleOpenPB.bind(this); + this.selectPBMessage = this.selectPBMessage.bind(this); + this.resetPBJSON = this.resetPBJSON.bind(this); + this.resetPBMessageState = this.resetPBMessageState.bind(this); + this.toggleJSON = this.toggleJSON.bind(this); + this.toggleAllMessages = this.toggleAllMessages.bind(this); + this.resetGroups = this.resetGroups.bind(this); + this.onMessageFromParent = this.onMessageFromParent.bind(this); + this.setStateFromParent = this.setStateFromParent.bind(this); + this.setState = this.setState.bind(this); + this.state = { + messageFilter: "all", + messageGroupsFilter: "all", + collapsedMessages: [], + modifiedMessages: [], + selectedPBMessage: "", + evaluationStatus: {}, + stringTargetingParameters: null, + newStringTargetingParameters: null, + copiedToClipboard: false, + attributionParameters: { + source: "addons.mozilla.org", + medium: "referral", + campaign: "non-fx-button", + content: `rta:${btoa("uBlock0@raymondhill.net")}`, + experiment: "ua-onboarding", + variation: "chrome", + ua: "Google Chrome 123", + dltoken: "00000000-0000-0000-0000-000000000000", + }, + }; + } + + onMessageFromParent({ type, data }) { + // These only exists due to onPrefChange events in ASRouter + switch (type) { + case "UpdateAdminState": { + this.setStateFromParent(data); + break; + } + } + } + + setStateFromParent(data) { + this.setState(data); + if (!this.state.stringTargetingParameters) { + const stringTargetingParameters = {}; + for (const param of Object.keys(data.targetingParameters)) { + stringTargetingParameters[param] = JSON.stringify( + data.targetingParameters[param], + null, + 2 + ); + } + this.setState({ stringTargetingParameters }); + } + } + + componentWillMount() { + ASRouterUtils.addListener(this.onMessageFromParent); + const endpoint = ASRouterUtils.getPreviewEndpoint(); + ASRouterUtils.sendMessage({ + type: "ADMIN_CONNECT_STATE", + data: { endpoint }, + }).then(this.setStateFromParent); + } + + handleBlock(msg) { + return () => ASRouterUtils.blockById(msg.id); + } + + handleUnblock(msg) { + return () => ASRouterUtils.unblockById(msg.id); + } + + resetJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + // remove the message from the list of modified IDs + let index = this.state.modifiedMessages.indexOf(msg.id); + this.setState(prevState => ({ + modifiedMessages: [ + ...prevState.modifiedMessages.slice(0, index), + ...prevState.modifiedMessages.slice(index + 1), + ], + })); + } + + handleOverride(id) { + return () => + ASRouterUtils.overrideMessage(id).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + resetPBMessageState() { + // Iterate over Private Browsing messages and block/unblock each one to clear impressions + const PBMessages = this.state.messages.filter( + message => message.template === "pb_newtab" + ); // messages from state go here + + PBMessages.forEach(message => { + if (message?.id) { + ASRouterUtils.blockById(message.id); + ASRouterUtils.unblockById(message.id); + } + }); + // Clear the selected messages & radio buttons + document.getElementById("clear radio").checked = true; + this.selectPBMessage("clear"); + } + + resetPBJSON(msg) { + // reset the displayed JSON for the given message + document.getElementById(`${msg.id}-textarea`).value = JSON.stringify( + msg, + null, + 2 + ); + } + + handleOpenPB() { + ASRouterUtils.sendMessage({ + type: "FORCE_PRIVATE_BROWSING_WINDOW", + data: { message: { content: this.state.selectedPBMessage } }, + }); + } + + expireCache() { + ASRouterUtils.sendMessage({ type: "EXPIRE_QUERY_CACHE" }); + } + + resetPref() { + ASRouterUtils.sendMessage({ type: "RESET_PROVIDER_PREF" }); + } + + resetGroups(id, value) { + ASRouterUtils.sendMessage({ + type: "RESET_GROUPS_STATE", + }).then(this.setStateFromParent); + } + + handleExpressionEval() { + const context = {}; + for (const param of Object.keys(this.state.stringTargetingParameters)) { + const value = this.state.stringTargetingParameters[param]; + context[param] = value ? JSON.parse(value) : null; + } + ASRouterUtils.sendMessage({ + type: "EVALUATE_JEXL_EXPRESSION", + data: { + expression: this.refs.expressionInput.value, + context, + }, + }).then(this.setStateFromParent); + } + + onChangeTargetingParameters(event) { + const { name } = event.target; + const { value } = event.target; + + this.setState(({ stringTargetingParameters }) => { + let targetingParametersError = null; + const updatedParameters = { ...stringTargetingParameters }; + updatedParameters[name] = value; + try { + JSON.parse(value); + } catch (e) { + console.error(`Error parsing value of parameter ${name}`); + targetingParametersError = { id: name }; + } + + return { + copiedToClipboard: false, + evaluationStatus: {}, + stringTargetingParameters: updatedParameters, + targetingParametersError, + }; + }); + } + + unblockAll() { + return ASRouterUtils.sendMessage({ + type: "UNBLOCK_ALL", + }).then(this.setStateFromParent); + } + + handleClearAllImpressionsByProvider() { + const providerId = this.state.messageFilter; + if (!providerId) { + return; + } + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + providerId in userPrefInfo ? userPrefInfo[providerId] : true; + + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: providerId, + }); + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: providerId, value: true }, + }); + } + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: providerId, + }); + } + + handleEnabledToggle(event) { + const provider = this.state.providerPrefs.find( + p => p.id === event.target.dataset.provider + ); + const userPrefInfo = this.state.userPrefs; + + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = provider.enabled; + const isEnabling = event.target.checked; + + if (isEnabling) { + if (!isUserEnabled) { + ASRouterUtils.sendMessage({ + type: "SET_PROVIDER_USER_PREF", + data: { id: provider.id, value: true }, + }); + } + if (!isSystemEnabled) { + ASRouterUtils.sendMessage({ + type: "ENABLE_PROVIDER", + data: provider.id, + }); + } + } else { + ASRouterUtils.sendMessage({ + type: "DISABLE_PROVIDER", + data: provider.id, + }); + } + + this.setState({ messageFilter: "all" }); + } + + handleUserPrefToggle(event) { + const action = { + type: "SET_PROVIDER_USER_PREF", + data: { id: event.target.dataset.provider, value: event.target.checked }, + }; + ASRouterUtils.sendMessage(action); + this.setState({ messageFilter: "all" }); + } + + onChangeMessageFilter(event) { + this.setState({ messageFilter: event.target.value }); + } + + onChangeMessageGroupsFilter(event) { + this.setState({ messageGroupsFilter: event.target.value }); + } + + // Simulate a copy event that sets to clipboard all targeting paramters and values + onCopyTargetingParams(event) { + const stringTargetingParameters = { + ...this.state.stringTargetingParameters, + }; + for (const key of Object.keys(stringTargetingParameters)) { + // If the value is not set the parameter will be lost when we stringify + if (stringTargetingParameters[key] === undefined) { + stringTargetingParameters[key] = null; + } + } + const setClipboardData = e => { + e.preventDefault(); + e.clipboardData.setData( + "text", + JSON.stringify(stringTargetingParameters, null, 2) + ); + document.removeEventListener("copy", setClipboardData); + this.setState({ copiedToClipboard: true }); + }; + + document.addEventListener("copy", setClipboardData); + + document.execCommand("copy"); + } + + onNewTargetingParams(event) { + this.setState({ newStringTargetingParameters: event.target.value }); + event.target.classList.remove("errorState"); + this.refs.targetingParamsEval.innerText = ""; + + try { + const stringTargetingParameters = JSON.parse(event.target.value); + this.setState({ stringTargetingParameters }); + } catch (e) { + event.target.classList.add("errorState"); + this.refs.targetingParamsEval.innerText = e.message; + } + } + + toggleJSON(msgId) { + if (this.state.collapsedMessages.includes(msgId)) { + let index = this.state.collapsedMessages.indexOf(msgId); + this.setState(prevState => ({ + collapsedMessages: [ + ...prevState.collapsedMessages.slice(0, index), + ...prevState.collapsedMessages.slice(index + 1), + ], + })); + } else { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msgId), + })); + } + } + + handleChange(msgId) { + if (!this.state.modifiedMessages.includes(msgId)) { + this.setState(prevState => ({ + modifiedMessages: prevState.modifiedMessages.concat(msgId), + })); + } + } + + renderMessageItem(msg) { + const isBlockedByGroup = this.state.groups + .filter(group => msg.groups.includes(group.id)) + .some(group => !group.enabled); + const msgProvider = + this.state.providers.find(provider => provider.id === msg.provider) || {}; + const isProviderExcluded = + msgProvider.exclude && msgProvider.exclude.includes(msg.id); + const isMessageBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const isBlocked = + isMessageBlocked || isBlockedByGroup || isProviderExcluded; + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + const isModified = this.state.modifiedMessages.includes(msg.id); + const aboutMessagePreviewSupported = [ + "infobar", + "spotlight", + "cfr_doorhanger", + ].includes(msg.template); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}> + <td className="message-id"> + <span> + {msg.id} <br /> + </span> + </td> + <td> + <ToggleMessageJSON + msgId={`${msg.id}`} + toggleJSON={this.toggleJSON} + isCollapsed={isCollapsed} + /> + </td> + <td className="button-column"> + <button + className={`button ${isBlocked ? "" : " primary"}`} + onClick={ + isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + } + > + {isBlocked ? "Unblock" : "Block"} + </button> + { + // eslint-disable-next-line no-nested-ternary + isBlocked ? null : isModified ? ( + <button + className="button restore" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.resetJSON(msg)} + > + Reset + </button> + ) : ( + <button + className="button show" + onClick={this.handleOverride(msg.id)} + > + Show + </button> + ) + } + {isBlocked ? null : ( + <button + className="button modify" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.modifyJson(msg)} + > + Modify + </button> + )} + {aboutMessagePreviewSupported ? ( + <CopyButton + transformer={text => + `about:messagepreview?json=${encodeURIComponent(btoa(text))}` + } + label="Share" + copiedLabel="Copied!" + inputSelector={`#${msg.id}-textarea`} + className={"button share"} + /> + ) : null} + <br />({impressions} impressions) + </td> + <td className="message-summary"> + {isBlocked && ( + <tr> + Block reason: + {isBlockedByGroup && " Blocked by group"} + {isProviderExcluded && " Excluded by provider"} + {isMessageBlocked && " Message blocked"} + </tr> + )} + <tr> + <pre className={isCollapsed ? "collapsed" : "expanded"}> + <textarea + id={`${msg.id}-textarea`} + name={msg.id} + className="general-textarea" + disabled={isBlocked} + // eslint-disable-next-line react/jsx-no-bind + onChange={e => this.handleChange(msg.id)} + > + {JSON.stringify(msg, null, 2)} + </textarea> + </pre> + </tr> + </td> + </tr> + ); + } + + selectPBMessage(msgId) { + if (msgId === "clear") { + this.setState({ + selectedPBMessage: "", + }); + } else { + let selected = document.getElementById(`${msgId} radio`); + let msg = JSON.parse(document.getElementById(`${msgId}-textarea`).value); + + if (selected.checked) { + this.setState({ + selectedPBMessage: msg?.content, + }); + } else { + this.setState({ + selectedPBMessage: "", + }); + } + } + } + + modifyJson(content) { + const message = JSON.parse( + document.getElementById(`${content.id}-textarea`).value + ); + return ASRouterUtils.modifyMessageJson(message).then(state => { + this.setStateFromParent(state); + this.props.notifyContent({ + message: state.message, + }); + }); + } + + renderPBMessageItem(msg) { + const isBlocked = + this.state.messageBlockList.includes(msg.id) || + this.state.messageBlockList.includes(msg.campaign); + const impressions = this.state.messageImpressions[msg.id] + ? this.state.messageImpressions[msg.id].length + : 0; + + const isCollapsed = this.state.collapsedMessages.includes(msg.id); + + let itemClassName = "message-item"; + if (isBlocked) { + itemClassName += " blocked"; + } + + return ( + <tr className={itemClassName} key={`${msg.id}-${msg.provider}`}> + <td className="message-id"> + <span> + {msg.id} <br /> + <br />({impressions} impressions) + </span> + </td> + <td> + <ToggleMessageJSON + msgId={`${msg.id}`} + toggleJSON={this.toggleJSON} + isCollapsed={isCollapsed} + /> + </td> + <td> + <input + type="radio" + id={`${msg.id} radio`} + name="PB_message_radio" + style={{ marginBottom: 20 }} + onClick={() => this.selectPBMessage(msg.id)} + disabled={isBlocked} + /> + <button + className={`button ${isBlocked ? "" : " primary"}`} + onClick={ + isBlocked ? this.handleUnblock(msg) : this.handleBlock(msg) + } + > + {isBlocked ? "Unblock" : "Block"} + </button> + <button + className="ASRouterButton slim button" + onClick={e => this.resetPBJSON(msg)} + > + Reset JSON + </button> + </td> + <td className={`message-summary`}> + <pre className={isCollapsed ? "collapsed" : "expanded"}> + <textarea + id={`${msg.id}-textarea`} + className="wnp-textarea" + name={msg.id} + > + {JSON.stringify(msg, null, 2)} + </textarea> + </pre> + </td> + </tr> + ); + } + + toggleAllMessages(messagesToShow) { + if (this.state.collapsedMessages.length) { + this.setState({ + collapsedMessages: [], + }); + } else { + Array.prototype.forEach.call(messagesToShow, msg => { + this.setState(prevState => ({ + collapsedMessages: prevState.collapsedMessages.concat(msg.id), + })); + }); + } + } + + renderMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageFilter === "all" + ? this.state.messages + : this.state.messages.filter( + message => + message.provider === this.state.messageFilter && + message.template !== "pb_newtab" + ); + + return ( + <div> + <button + className="ASRouterButton slim" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.toggleAllMessages(messagesToShow)} + > + Collapse/Expand All + </button> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To modify a message, change the JSON and click 'Modify' to see your + changes. Click 'Reset' to restore the JSON to the original. Click + 'Share' to copy a link to the clipboard that can be used to preview + the message by opening the link in Nightly/local builds. + </span> + </p> + <table> + <tbody> + {messagesToShow.map(msg => this.renderMessageItem(msg))} + </tbody> + </table> + </div> + ); + } + + renderMessagesByGroup() { + if (!this.state.messages) { + return null; + } + const messagesToShow = + this.state.messageGroupsFilter === "all" + ? this.state.messages.filter(m => m.groups.length) + : this.state.messages.filter(message => + message.groups.includes(this.state.messageGroupsFilter) + ); + + return ( + <table> + <tbody>{messagesToShow.map(msg => this.renderMessageItem(msg))}</tbody> + </table> + ); + } + + renderPBMessages() { + if (!this.state.messages) { + return null; + } + const messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + return ( + <table> + <tbody> + {messagesToShow.map(msg => this.renderPBMessageItem(msg))} + </tbody> + </table> + ); + } + + renderMessageFilter() { + if (!this.state.providers) { + return null; + } + + return ( + <p> + <button + className="unblock-all ASRouterButton test-only" + onClick={this.unblockAll} + > + Unblock All Snippets + </button> + Show messages from{" "} + <select + value={this.state.messageFilter} + onChange={this.onChangeMessageFilter} + > + <option value="all">all providers</option> + {this.state.providers.map(provider => ( + <option key={provider.id} value={provider.id}> + {provider.id} + </option> + ))} + </select> + {this.state.messageFilter !== "all" && + !this.state.messageFilter.includes("_local_testing") ? ( + <button + className="button messages-reset" + onClick={this.handleClearAllImpressionsByProvider} + > + Reset All + </button> + ) : null} + </p> + ); + } + + renderMessageGroupsFilter() { + if (!this.state.groups) { + return null; + } + + return ( + <p> + Show messages from {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select + value={this.state.messageGroupsFilter} + onChange={this.onChangeMessageGroupsFilter} + > + <option value="all">all groups</option> + {this.state.groups.map(group => ( + <option key={group.id} value={group.id}> + {group.id} + </option> + ))} + </select> + </p> + ); + } + + renderTableHead() { + return ( + <thead> + <tr className="message-item"> + <td className="min" /> + <td className="min">Provider ID</td> + <td>Source</td> + <td className="min">Cohort</td> + <td className="min">Last Updated</td> + </tr> + </thead> + ); + } + + renderProviders() { + const providersConfig = this.state.providerPrefs; + const providerInfo = this.state.providers; + const userPrefInfo = this.state.userPrefs; + + return ( + <table> + {this.renderTableHead()} + <tbody> + {providersConfig.map((provider, i) => { + const isTestProvider = provider.id.includes("_local_testing"); + const info = providerInfo.find(p => p.id === provider.id) || {}; + const isUserEnabled = + provider.id in userPrefInfo ? userPrefInfo[provider.id] : true; + const isSystemEnabled = isTestProvider || provider.enabled; + + let label = "local"; + if (provider.type === "remote") { + label = ( + <span> + endpoint ( + <a + className="providerUrl" + target="_blank" + href={info.url} + rel="noopener noreferrer" + > + {info.url} + </a> + ) + </span> + ); + } else if (provider.type === "remote-settings") { + label = `remote settings (${provider.collection})`; + } else if (provider.type === "remote-experiments") { + label = ( + <span> + remote settings ( + <a + className="providerUrl" + target="_blank" + href="https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/nimbus-desktop-experiments/records" + rel="noopener noreferrer" + > + nimbus-desktop-experiments + </a> + ) + </span> + ); + } + + let reasonsDisabled = []; + if (!isSystemEnabled) { + reasonsDisabled.push("system pref"); + } + if (!isUserEnabled) { + reasonsDisabled.push("user pref"); + } + if (reasonsDisabled.length) { + label = `disabled via ${reasonsDisabled.join(", ")}`; + } + + return ( + <tr className="message-item" key={i}> + <td> + {isTestProvider ? ( + <input + type="checkbox" + disabled={true} + readOnly={true} + checked={true} + /> + ) : ( + <input + type="checkbox" + data-provider={provider.id} + checked={isUserEnabled && isSystemEnabled} + onChange={this.handleEnabledToggle} + /> + )} + </td> + <td>{provider.id}</td> + <td> + <span + className={`sourceLabel${ + isUserEnabled && isSystemEnabled ? "" : " isDisabled" + }`} + > + {label} + </span> + </td> + <td>{provider.cohort}</td> + <td style={{ whiteSpace: "nowrap" }}> + {info.lastUpdated + ? new Date(info.lastUpdated).toLocaleString() + : ""} + </td> + </tr> + ); + })} + </tbody> + </table> + ); + } + + renderTargetingParameters() { + // There was no error and the result is truthy + const success = + this.state.evaluationStatus.success && + !!this.state.evaluationStatus.result; + const result = + JSON.stringify(this.state.evaluationStatus.result, null, 2) || + "(Empty result)"; + + return ( + <table> + <tbody> + <tr> + <td> + <h2>Evaluate JEXL expression</h2> + </td> + </tr> + <tr> + <td> + <p> + <textarea + ref="expressionInput" + rows="10" + cols="60" + placeholder="Evaluate JEXL expressions and mock parameters by changing their values below" + /> + </p> + <p> + Status:{" "} + <span ref="evaluationStatus"> + {success ? "✅" : "❌"}, Result: {result} + </span> + </p> + </td> + <td> + <button + className="ASRouterButton secondary" + onClick={this.handleExpressionEval} + > + Evaluate + </button> + </td> + </tr> + <tr> + <td> + <h2>Modify targeting parameters</h2> + </td> + </tr> + <tr> + <td> + <button + className="ASRouterButton secondary" + onClick={this.onCopyTargetingParams} + disabled={this.state.copiedToClipboard} + > + {this.state.copiedToClipboard + ? "Parameters copied!" + : "Copy parameters"} + </button> + </td> + </tr> + {this.state.stringTargetingParameters && + Object.keys(this.state.stringTargetingParameters).map( + (param, i) => { + const value = this.state.stringTargetingParameters[param]; + const errorState = + this.state.targetingParametersError && + this.state.targetingParametersError.id === param; + const className = errorState ? "errorState" : ""; + const inputComp = + (value && value.length) > 30 ? ( + <textarea + name={param} + className={className} + value={value} + rows="10" + cols="60" + onChange={this.onChangeTargetingParameters} + /> + ) : ( + <input + name={param} + className={className} + value={value} + onChange={this.onChangeTargetingParameters} + /> + ); + + return ( + <tr key={i}> + <td>{param}</td> + <td>{inputComp}</td> + </tr> + ); + } + )} + </tbody> + </table> + ); + } + + onChangeAttributionParameters(event) { + const { name, value } = event.target; + + this.setState(({ attributionParameters }) => { + const updatedParameters = { ...attributionParameters }; + updatedParameters[name] = value; + + return { attributionParameters: updatedParameters }; + }); + } + + setAttribution(e) { + ASRouterUtils.sendMessage({ + type: "FORCE_ATTRIBUTION", + data: this.state.attributionParameters, + }).then(this.setStateFromParent); + } + + _getGroupImpressionsCount(id, frequency) { + if (frequency) { + return this.state.groupImpressions[id] + ? this.state.groupImpressions[id].length + : 0; + } + + return "n/a"; + } + + renderDiscoveryStream() { + const { config } = this.props.DiscoveryStream; + + return ( + <div> + <table> + <tbody> + <tr className="message-item"> + <td className="min">Enabled</td> + <td>{config.enabled ? "yes" : "no"}</td> + </tr> + <tr className="message-item"> + <td className="min">Endpoint</td> + <td>{config.endpoint || "(empty)"}</td> + </tr> + </tbody> + </table> + </div> + ); + } + + renderAttributionParamers() { + return ( + <div> + <h2> Attribution Parameters </h2> + <p> + {" "} + This forces the browser to set some attribution parameters, useful for + testing the Return To AMO feature. Clicking on 'Force Attribution', + with the default values in each field, will demo the Return To AMO + flow with the addon called 'uBlock Origin'. If you wish to try + different attribution parameters, enter them in the text boxes. If you + wish to try a different addon with the Return To AMO flow, make sure + the 'content' text box has a string that is 'rta:base64(addonID)', the + base64 string of the addonID prefixed with 'rta:'. The addon must + currently be a recommended addon on AMO. Then click 'Force + Attribution'. Clicking on 'Force Attribution' with blank text boxes + reset attribution data. + </p> + <table> + <tr> + <td> + <b> Source </b> + </td> + <td> + {" "} + <input + type="text" + name="source" + placeholder="addons.mozilla.org" + value={this.state.attributionParameters.source} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Medium </b> + </td> + <td> + {" "} + <input + type="text" + name="medium" + placeholder="referral" + value={this.state.attributionParameters.medium} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Campaign </b> + </td> + <td> + {" "} + <input + type="text" + name="campaign" + placeholder="non-fx-button" + value={this.state.attributionParameters.campaign} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Content </b> + </td> + <td> + {" "} + <input + type="text" + name="content" + placeholder={`rta:${btoa("uBlock0@raymondhill.net")}`} + value={this.state.attributionParameters.content} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Experiment </b> + </td> + <td> + {" "} + <input + type="text" + name="experiment" + placeholder="ua-onboarding" + value={this.state.attributionParameters.experiment} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Variation </b> + </td> + <td> + {" "} + <input + type="text" + name="variation" + placeholder="chrome" + value={this.state.attributionParameters.variation} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> User Agent </b> + </td> + <td> + {" "} + <input + type="text" + name="ua" + placeholder="Google Chrome 123" + value={this.state.attributionParameters.ua} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + <b> Download Token </b> + </td> + <td> + {" "} + <input + type="text" + name="dltoken" + placeholder="00000000-0000-0000-0000-000000000000" + value={this.state.attributionParameters.dltoken} + onChange={this.onChangeAttributionParameters} + />{" "} + </td> + </tr> + <tr> + <td> + {" "} + <button + className="ASRouterButton primary button" + onClick={this.setAttribution} + > + {" "} + Force Attribution{" "} + </button>{" "} + </td> + </tr> + </table> + </div> + ); + } + + renderErrorMessage({ id, errors }) { + const providerId = <td rowSpan={errors.length}>{id}</td>; + // .reverse() so that the last error (most recent) is first + return errors + .map(({ error, timestamp }, cellKey) => ( + <tr key={cellKey}> + {cellKey === errors.length - 1 ? providerId : null} + <td>{error.message}</td> + <td>{relativeTime(timestamp)}</td> + </tr> + )) + .reverse(); + } + + renderErrors() { + const providersWithErrors = + this.state.providers && + this.state.providers.filter(p => p.errors && p.errors.length); + + if (providersWithErrors && providersWithErrors.length) { + return ( + <table className="errorReporting"> + <thead> + <tr> + <th>Provider ID</th> + <th>Message</th> + <th>Timestamp</th> + </tr> + </thead> + <tbody>{providersWithErrors.map(this.renderErrorMessage)}</tbody> + </table> + ); + } + + return <p>No errors</p>; + } + + renderPBTab() { + if (!this.state.messages) { + return null; + } + let messagesToShow = this.state.messages.filter( + message => message.template === "pb_newtab" + ); + + return ( + <div> + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + To view an available message, select its radio button and click + "Open a Private Browsing Window". + <br /> + To modify a message, make changes to the JSON first, then select the + radio button. (To make new changes, click "Reset Message State", + make your changes, and reselect the radio button.) + <br /> + Click "Reset Message State" to clear all message impressions and + view messages in a clean state. + <br /> + Note that ContentSearch functions do not work in debug mode. + </span> + </p> + <div> + <button + className="ASRouterButton primary button" + onClick={this.handleOpenPB} + > + Open a Private Browsing Window + </button> + <button + className="ASRouterButton primary button" + style={{ marginInlineStart: 12 }} + onClick={this.resetPBMessageState} + > + Reset Message State + </button> + <br /> + <input + type="radio" + id={`clear radio`} + name="PB_message_radio" + value="clearPBMessage" + style={{ display: "none" }} + /> + <h2>Messages</h2> + <button + className="ASRouterButton slim button" + // eslint-disable-next-line react/jsx-no-bind + onClick={e => this.toggleAllMessages(messagesToShow)} + > + Collapse/Expand All + </button> + {this.renderPBMessages()} + </div> + </div> + ); + } + + getSection() { + const [section] = this.props.location.routes; + switch (section) { + case "private": + return ( + <React.Fragment> + <h2>Private Browsing Messages</h2> + {this.renderPBTab()} + </React.Fragment> + ); + case "targeting": + return ( + <React.Fragment> + <h2>Targeting Utilities</h2> + <button className="button" onClick={this.expireCache}> + Expire Cache + </button>{" "} + (This expires the cache in ASR Targeting for bookmarks and top + sites) + {this.renderTargetingParameters()} + {this.renderAttributionParamers()} + </React.Fragment> + ); + case "groups": + return ( + <React.Fragment> + <h2>Message Groups</h2> + <button className="button" onClick={this.resetGroups}> + Reset group impressions + </button> + <table> + <thead> + <tr className="message-item"> + <td>Enabled</td> + <td>Impressions count</td> + <td>Custom frequency</td> + <td>User preferences</td> + </tr> + </thead> + <tbody> + {this.state.groups && + this.state.groups.map( + ( + { id, enabled, frequency, userPreferences = [] }, + index + ) => ( + <Row key={id}> + <td> + <TogglePrefCheckbox + checked={enabled} + pref={id} + disabled={true} + /> + </td> + <td>{this._getGroupImpressionsCount(id, frequency)}</td> + <td>{JSON.stringify(frequency, null, 2)}</td> + <td>{userPreferences.join(", ")}</td> + </Row> + ) + )} + </tbody> + </table> + {this.renderMessageGroupsFilter()} + {this.renderMessagesByGroup()} + </React.Fragment> + ); + case "ds": + return ( + <React.Fragment> + <h2>Discovery Stream</h2> + <DiscoveryStreamAdmin + state={{ + DiscoveryStream: this.props.DiscoveryStream, + Personalization: this.props.Personalization, + }} + otherPrefs={this.props.Prefs.values} + dispatch={this.props.dispatch} + /> + </React.Fragment> + ); + case "errors": + return ( + <React.Fragment> + <h2>ASRouter Errors</h2> + {this.renderErrors()} + </React.Fragment> + ); + default: + return ( + <React.Fragment> + <h2> + Message Providers{" "} + <button + title="Restore all provider settings that ship with Firefox" + className="button" + onClick={this.resetPref} + > + Restore default prefs + </button> + </h2> + {this.state.providers ? this.renderProviders() : null} + <h2>Messages</h2> + {this.renderMessageFilter()} + {this.renderMessages()} + </React.Fragment> + ); + } + } + + render() { + return ( + <div + className={`asrouter-admin ${ + this.props.collapsed ? "collapsed" : "expanded" + }`} + > + <aside className="sidebar"> + <ul> + <li> + <a href="#devtools">General</a> + </li> + <li> + <a href="#devtools-private">Private Browsing</a> + </li> + <li> + <a href="#devtools-targeting">Targeting</a> + </li> + <li> + <a href="#devtools-groups">Message Groups</a> + </li> + <li> + <a href="#devtools-ds">Discovery Stream</a> + </li> + <li> + <a href="#devtools-errors">Errors</a> + </li> + </ul> + </aside> + <main className="main-panel"> + <h1>AS Router Admin</h1> + + <p className="helpLink"> + <span className="icon icon-small-spacer icon-info" />{" "} + <span> + Need help using these tools? Check out our{" "} + <a + target="blank" + href="https://firefox-source-docs.mozilla.org/browser/components/newtab/content-src/asrouter/docs/debugging-docs.html" + > + documentation + </a> + </span> + </p> + + {this.getSection()} + </main> + </div> + ); + } +} + +export class CollapseToggle extends React.PureComponent { + constructor(props) { + super(props); + this.onCollapseToggle = this.onCollapseToggle.bind(this); + this.state = { collapsed: false }; + } + + get renderAdmin() { + const { props } = this; + return ( + props.location.hash && + (props.location.hash.startsWith("#asrouter") || + props.location.hash.startsWith("#devtools")) + ); + } + + onCollapseToggle(e) { + e.preventDefault(); + this.setState(state => ({ collapsed: !state.collapsed })); + } + + setBodyClass() { + if (this.renderAdmin && !this.state.collapsed) { + global.document.body.classList.add("no-scroll"); + } else { + global.document.body.classList.remove("no-scroll"); + } + } + + componentDidMount() { + this.setBodyClass(); + } + + componentDidUpdate() { + this.setBodyClass(); + } + + componentWillUnmount() { + global.document.body.classList.remove("no-scroll"); + ASRouterUtils.removeListener(this.onMessageFromParent); + } + + render() { + const { props } = this; + const { renderAdmin } = this; + const isCollapsed = this.state.collapsed || !renderAdmin; + const label = `${isCollapsed ? "Expand" : "Collapse"} devtools`; + return ( + <React.Fragment> + <a + href="#devtools" + title={label} + aria-label={label} + className={`asrouter-toggle ${ + isCollapsed ? "collapsed" : "expanded" + }`} + onClick={this.renderAdmin ? this.onCollapseToggle : null} + > + <span className="icon icon-devtools" /> + </a> + {renderAdmin ? ( + <ASRouterAdminInner {...props} collapsed={this.state.collapsed} /> + ) : null} + </React.Fragment> + ); + } +} + +const _ASRouterAdmin = props => ( + <SimpleHashRouter> + <CollapseToggle {...props} /> + </SimpleHashRouter> +); + +export const ASRouterAdmin = connect(state => ({ + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Personalization: state.Personalization, + Prefs: state.Prefs, +}))(_ASRouterAdmin); diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss new file mode 100644 index 0000000000..b095b94ef6 --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/ASRouterAdmin.scss @@ -0,0 +1,280 @@ +.asrouter-toggle { + position: fixed; + top: 50px; + inset-inline-end: 15px; + border: 0; + background: none; + z-index: 1; + border-radius: 2px; + + .icon-devtools { + background-image: url('chrome://global/skin/icons/developer.svg'); + padding: 15px; + } + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &.expanded { + background: $black-20; + } +} + +.asrouter-admin { + $border-color: var(--newtab-border-color); + $monospace: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Droid Sans Mono', + 'Source Code Pro', monospace; + $sidebar-width: 240px; + + position: fixed; + top: 0; + inset-inline-start: 0; + width: 100%; + background: var(--newtab-background-color); + height: 100%; + overflow-y: scroll; + margin: 0 auto; + font-size: 14px; + padding-inline-start: $sidebar-width; + color: var(--newtab-text-primary-color); + + &.collapsed { + display: none; + } + + .sidebar { + inset-inline-start: 0; + position: fixed; + width: $sidebar-width; + padding: 30px 20px; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + + li a { + padding: 10px 34px; + display: block; + color: var(--lwt-sidebar-text-color); + + &:hover { + background: var(--newtab-background-color-secondary); + } + } + } + + h1 { + font-weight: 200; + font-size: 32px; + } + + h2 .button, + p .button { + font-size: 14px; + padding: 6px 12px; + margin-inline-start: 5px; + margin-bottom: 0; + } + + .general-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .wnp-textarea { + direction: ltr; + width: 740px; + height: 500px; + overflow: auto; + resize: none; + border-radius: 4px; + display: flex; + } + + .json-button { + display: inline-flex; + font-size: 10px; + padding: 4px 10px; + margin-bottom: 6px; + margin-inline-end: 4px; + + &:hover { + background-color: var(--newtab-element-hover-color); + box-shadow: none; + } + } + + table { + border-collapse: collapse; + width: 100%; + + &.minimal-table { + border-collapse: collapse; + border: 1px solid $border-color; + + td { + padding: 8px; + } + + td:first-child { + width: 1%; + white-space: nowrap; + } + + td:not(:first-child) { + font-family: $monospace; + } + } + + &.errorReporting { + tr { + border: 1px solid var(--newtab-background-color-secondary); + } + + td { + padding: 4px; + + &[rowspan] { + border: 1px solid var(--newtab-background-color-secondary); + } + } + } + } + + .sourceLabel { + background: var(--newtab-background-color-secondary); + padding: 2px 5px; + border-radius: 3px; + + &.isDisabled { + background: $email-input-invalid; + color: var(--newtab-status-error); + } + } + + .message-item { + &:first-child td { + border-top: 1px solid $border-color; + } + + td { + vertical-align: top; + padding: 8px; + border-bottom: 1px solid $border-color; + + &.min { + width: 1%; + white-space: nowrap; + } + + &.message-summary { + width: 60%; + } + + &.button-column { + width: 15%; + } + + &:first-child { + border-inline-start: 1px solid $border-color; + } + + &:last-child { + border-inline-end: 1px solid $border-color; + } + } + + &.blocked { + .message-id, + .message-summary { + opacity: 0.5; + } + + .message-id { + opacity: 0.5; + } + } + + .message-id { + font-family: $monospace; + font-size: 12px; + } + } + + .providerUrl { + font-size: 12px; + } + + pre { + background: var(--newtab-background-color-secondary); + margin: 0; + padding: 8px; + font-size: 12px; + max-width: 750px; + overflow: auto; + font-family: $monospace; + } + + .errorState { + border: $input-error-border; + } + + .helpLink { + padding: 10px; + display: flex; + background: $black-10; + border-radius: 3px; + align-items: center; + + a { + text-decoration: underline; + } + + .icon { + min-width: 18px; + min-height: 18px; + } + } + + .ds-component { + margin-bottom: 20px; + } + + .modalOverlayInner { + height: 80%; + } + + .clearButton { + border: 0; + padding: 4px; + border-radius: 4px; + display: flex; + + &:hover { + background: var(--newtab-element-hover-color); + } + } + + .collapsed { + display: none; + } + + .icon { + display: inline-table; + cursor: pointer; + width: 18px; + height: 18px; + } +} diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx new file mode 100644 index 0000000000..07ce12d7bf --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/CopyButton.jsx @@ -0,0 +1,31 @@ +/* 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, { useState, useRef, useCallback } from "react"; + +export const CopyButton = ({ + className, + label, + copiedLabel, + inputSelector, + transformer, + ...props +}) => { + const [copied, setCopied] = useState(false); + const timeout = useRef(null); + const onClick = useCallback(() => { + let text = document.querySelector(inputSelector).value; + if (transformer) text = transformer(text); + navigator.clipboard.writeText(text); + + clearTimeout(timeout.current); + setCopied(true); + timeout.current = setTimeout(() => setCopied(false), 1500); + }, [inputSelector, transformer]); + return ( + <button className={className} onClick={e => onClick()} {...props}> + {(copied && copiedLabel) || label} + </button> + ); +}; diff --git a/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx b/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx new file mode 100644 index 0000000000..9c3fd8579c --- /dev/null +++ b/browser/components/newtab/content-src/components/ASRouterAdmin/SimpleHashRouter.jsx @@ -0,0 +1,35 @@ +/* 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 SimpleHashRouter extends React.PureComponent { + constructor(props) { + super(props); + this.onHashChange = this.onHashChange.bind(this); + this.state = { hash: global.location.hash }; + } + + onHashChange() { + this.setState({ hash: global.location.hash }); + } + + componentWillMount() { + global.addEventListener("hashchange", this.onHashChange); + } + + componentWillUnmount() { + global.removeEventListener("hashchange", this.onHashChange); + } + + render() { + const [, ...routes] = this.state.hash.split("-"); + return React.cloneElement(this.props.children, { + location: { + hash: this.state.hash, + routes, + }, + }); + } +} diff --git a/browser/components/newtab/content-src/components/Base/Base.jsx b/browser/components/newtab/content-src/components/Base/Base.jsx new file mode 100644 index 0000000000..8845ad87cd --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/Base.jsx @@ -0,0 +1,273 @@ +/* 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 { ASRouterAdmin } from "content-src/components/ASRouterAdmin/ASRouterAdmin"; +import { ASRouterUISurface } from "../../asrouter/asrouter-content"; +import { ConfirmDialog } from "content-src/components/ConfirmDialog/ConfirmDialog"; +import { connect } from "react-redux"; +import { DiscoveryStreamBase } from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase"; +import { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { CustomizeMenu } from "content-src/components/CustomizeMenu/CustomizeMenu"; +import React from "react"; +import { Search } from "content-src/components/Search/Search"; +import { Sections } from "content-src/components/Sections/Sections"; + +export const PrefsButton = ({ onClick, icon }) => ( + <div className="prefs-button"> + <button + className={`icon ${icon || "icon-settings"}`} + onClick={onClick} + data-l10n-id="newtab-settings-button" + /> + </div> +); + +// Returns a function will not be continuously triggered when called. The +// function will be triggered if called again after `wait` milliseconds. +function debounce(func, wait) { + let timer; + return (...args) => { + if (timer) { + return; + } + + let wakeUp = () => { + timer = null; + }; + + timer = setTimeout(wakeUp, wait); + func.apply(this, args); + }; +} + +export class _Base extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + message: {}, + }; + this.notifyContent = this.notifyContent.bind(this); + } + + notifyContent(state) { + this.setState(state); + } + + componentWillUnmount() { + this.updateTheme(); + } + + componentWillUpdate() { + this.updateTheme(); + } + + updateTheme() { + const bodyClassName = [ + "activity-stream", + // If we skipped the about:welcome overlay and removed the CSS classes + // we don't want to add them back to the Activity Stream view + document.body.classList.contains("inline-onboarding") + ? "inline-onboarding" + : "", + ] + .filter(v => v) + .join(" "); + global.document.body.className = bodyClassName; + } + + render() { + const { props } = this; + const { App } = props; + const isDevtoolsEnabled = props.Prefs.values["asrouter.devtoolsEnabled"]; + + if (!App.initialized) { + return null; + } + + return ( + <ErrorBoundary className="base-content-fallback"> + <React.Fragment> + <BaseContent {...this.props} adminContent={this.state} /> + {isDevtoolsEnabled ? ( + <ASRouterAdmin notifyContent={this.notifyContent} /> + ) : null} + </React.Fragment> + </ErrorBoundary> + ); + } +} + +export class BaseContent extends React.PureComponent { + constructor(props) { + super(props); + this.openPreferences = this.openPreferences.bind(this); + this.openCustomizationMenu = this.openCustomizationMenu.bind(this); + this.closeCustomizationMenu = this.closeCustomizationMenu.bind(this); + this.handleOnKeyDown = this.handleOnKeyDown.bind(this); + this.onWindowScroll = debounce(this.onWindowScroll.bind(this), 5); + this.setPref = this.setPref.bind(this); + this.state = { fixedSearch: false }; + } + + componentDidMount() { + global.addEventListener("scroll", this.onWindowScroll); + global.addEventListener("keydown", this.handleOnKeyDown); + } + + componentWillUnmount() { + global.removeEventListener("scroll", this.onWindowScroll); + global.removeEventListener("keydown", this.handleOnKeyDown); + } + + onWindowScroll() { + const prefs = this.props.Prefs.values; + const SCROLL_THRESHOLD = prefs["logowordmark.alwaysVisible"] ? 179 : 34; + if (global.scrollY > SCROLL_THRESHOLD && !this.state.fixedSearch) { + this.setState({ fixedSearch: true }); + } else if (global.scrollY <= SCROLL_THRESHOLD && this.state.fixedSearch) { + this.setState({ fixedSearch: false }); + } + } + + openPreferences() { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + this.props.dispatch(ac.UserEvent({ event: "OPEN_NEWTAB_PREFS" })); + } + + openCustomizationMenu() { + this.props.dispatch({ type: at.SHOW_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "SHOW_PERSONALIZE" })); + } + + closeCustomizationMenu() { + if (this.props.App.customizeMenuVisible) { + this.props.dispatch({ type: at.HIDE_PERSONALIZE }); + this.props.dispatch(ac.UserEvent({ event: "HIDE_PERSONALIZE" })); + } + } + + handleOnKeyDown(e) { + if (e.key === "Escape") { + this.closeCustomizationMenu(); + } + } + + setPref(pref, value) { + this.props.dispatch(ac.SetPref(pref, value)); + } + + render() { + const { props } = this; + const { App } = props; + const { initialized, customizeMenuVisible } = App; + const prefs = props.Prefs.values; + + const isDiscoveryStream = + props.DiscoveryStream.config && props.DiscoveryStream.config.enabled; + let filteredSections = props.Sections.filter( + section => section.id !== "topstories" + ); + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + const noSectionsEnabled = + !prefs["feeds.topsites"] && + !pocketEnabled && + filteredSections.filter(section => section.enabled).length === 0; + const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"]; + const enabledSections = { + topSitesEnabled: prefs["feeds.topsites"], + pocketEnabled: prefs["feeds.section.topstories"], + highlightsEnabled: prefs["feeds.section.highlights"], + showSponsoredTopSitesEnabled: prefs.showSponsoredTopSites, + showSponsoredPocketEnabled: prefs.showSponsored, + showRecentSavesEnabled: prefs.showRecentSaves, + topSitesRowsCount: prefs.topSitesRows, + }; + + const pocketRegion = prefs["feeds.system.topstories"]; + const { mayHaveSponsoredTopSites } = prefs; + + const outerClassName = [ + "outer-wrapper", + isDiscoveryStream && pocketEnabled && "ds-outer-wrapper-search-alignment", + isDiscoveryStream && "ds-outer-wrapper-breakpoint-override", + prefs.showSearch && + this.state.fixedSearch && + !noSectionsEnabled && + "fixed-search", + prefs.showSearch && noSectionsEnabled && "only-search", + prefs["logowordmark.alwaysVisible"] && "visible-logo", + ] + .filter(v => v) + .join(" "); + + const hasSnippet = + prefs["feeds.snippets"] && + this.props.adminContent && + this.props.adminContent.message && + this.props.adminContent.message.id; + + return ( + <div> + <CustomizeMenu + onClose={this.closeCustomizationMenu} + onOpen={this.openCustomizationMenu} + openPreferences={this.openPreferences} + setPref={this.setPref} + enabledSections={enabledSections} + pocketRegion={pocketRegion} + mayHaveSponsoredTopSites={mayHaveSponsoredTopSites} + showing={customizeMenuVisible} + /> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions*/} + <div className={outerClassName} onClick={this.closeCustomizationMenu}> + <main className={hasSnippet ? "has-snippet" : ""}> + {prefs.showSearch && ( + <div className="non-collapsible-section"> + <ErrorBoundary> + <Search + showLogo={ + noSectionsEnabled || prefs["logowordmark.alwaysVisible"] + } + handoffEnabled={searchHandoffEnabled} + {...props.Search} + /> + </ErrorBoundary> + </div> + )} + <ASRouterUISurface + adminContent={this.props.adminContent} + appUpdateChannel={this.props.Prefs.values.appUpdateChannel} + fxaEndpoint={this.props.Prefs.values.fxa_endpoint} + dispatch={this.props.dispatch} + /> + <div className={`body-wrapper${initialized ? " on" : ""}`}> + {isDiscoveryStream ? ( + <ErrorBoundary className="borderless-error"> + <DiscoveryStreamBase locale={props.App.locale} /> + </ErrorBoundary> + ) : ( + <Sections /> + )} + </div> + <ConfirmDialog /> + </main> + </div> + </div> + ); + } +} + +export const Base = connect(state => ({ + App: state.App, + Prefs: state.Prefs, + Sections: state.Sections, + DiscoveryStream: state.DiscoveryStream, + Search: state.Search, +}))(_Base); diff --git a/browser/components/newtab/content-src/components/Base/_Base.scss b/browser/components/newtab/content-src/components/Base/_Base.scss new file mode 100644 index 0000000000..7e13fe9a90 --- /dev/null +++ b/browser/components/newtab/content-src/components/Base/_Base.scss @@ -0,0 +1,143 @@ +.outer-wrapper { + color: var(--newtab-text-primary-color); + display: flex; + flex-grow: 1; + min-height: 100vh; + padding: ($section-spacing + $section-vertical-padding) $base-gutter $base-gutter; + + &.ds-outer-wrapper-breakpoint-override { + padding: 30px 0 32px; + + @media(min-width: $break-point-medium) { + padding: 30px 32px 32px; + } + } + + &.only-search { + display: block; + padding-top: 134px; + } + + a { + color: var(--newtab-primary-action-background); + } +} + +main { + margin: auto; + width: $wrapper-default-width; + padding: 0; + + section { + margin-bottom: $section-spacing; + position: relative; + } + + .hide-main & { + visibility: hidden; + } + + @media (min-width: $break-point-medium) { + width: $wrapper-max-width-medium; + } + + @media (min-width: $break-point-large) { + width: $wrapper-max-width-large; + } + + @media (min-width: $break-point-widest) { + width: $wrapper-max-width-widest; + } + + &.has-snippet { + // Offset the snippets container so things at the bottom of the page are still + // visible when snippets are visible. Adjust for other spacing. + padding-bottom: $snippets-container-height - $section-spacing - $base-gutter; + } +} + +.below-search-snippet.withButton { + margin: auto; + width: 100%; +} + +.ds-outer-wrapper-search-alignment { + main { + // This override is to ensure while Discovery Stream loads, + // the search bar does not jump around. (it sticks to the top) + margin: 0 auto; + } +} + +.ds-outer-wrapper-breakpoint-override { + main { + width: 266px; + padding-bottom: 0; + + @media (min-width: $break-point-medium) { + width: 510px; + } + + @media (min-width: $break-point-large) { + width: 746px; + } + + @media (min-width: $break-point-widest) { + width: 986px; + } + + &.has-snippet { + // Offset the snippets container so things at the bottom of the page are still + // visible when snippets are visible. Adjust for other spacing. + padding-bottom: $snippets-container-height - $section-spacing - $base-gutter; + } + } +} + +.base-content-fallback { + // Make the error message be centered against the viewport + height: 100vh; +} + +.body-wrapper { + // Hide certain elements so the page structure is fixed, e.g., placeholders, + // while avoiding flashes of changing content, e.g., icons and text + $selectors-to-hide: '.section-title, .sections-list .section:last-of-type, .topics'; + + #{$selectors-to-hide} { + opacity: 0; + } + + &.on { + #{$selectors-to-hide} { + opacity: 1; + } + } +} + +.non-collapsible-section { + padding: 0 $section-horizontal-padding; +} + +.prefs-button { + button { + background-color: transparent; + border: 0; + border-radius: 2px; + cursor: pointer; + inset-inline-end: 15px; + padding: 15px; + position: fixed; + top: 15px; + z-index: 1000; + + &:hover, + &:focus { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/Card.jsx b/browser/components/newtab/content-src/components/Card/Card.jsx new file mode 100644 index 0000000000..9d03377f1b --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/Card.jsx @@ -0,0 +1,362 @@ +/* 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 { cardContextTypes } from "./types"; +import { connect } from "react-redux"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import React from "react"; +import { ScreenshotUtils } from "content-src/lib/screenshot-utils"; + +// Keep track of pending image loads to only request once +const gImageLoading = new Map(); + +/** + * Card component. + * Cards are found within a Section component and contain information about a link such + * as preview image, page title, page description, and some context about if the page + * was visited, bookmarked, trending etc... + * Each Section can make an unordered list of Cards which will create one instane of + * this class. Each card will then get a context menu which reflects the actions that + * can be done on this Card. + */ +export class _Card extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + activeCard: null, + imageLoaded: false, + cardImage: null, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + } + + /** + * Helper to conditionally load an image and update state when it loads. + */ + async maybeLoadImage() { + // No need to load if it's already loaded or no image + const { cardImage } = this.state; + if (!cardImage) { + return; + } + + const imageUrl = cardImage.url; + if (!this.state.imageLoaded) { + // Initialize a promise to share a load across multiple card updates + if (!gImageLoading.has(imageUrl)) { + const loaderPromise = new Promise((resolve, reject) => { + const loader = new Image(); + loader.addEventListener("load", resolve); + loader.addEventListener("error", reject); + loader.src = imageUrl; + }); + + // Save and remove the promise only while it's pending + gImageLoading.set(imageUrl, loaderPromise); + loaderPromise + .catch(ex => ex) + .then(() => gImageLoading.delete(imageUrl)) + .catch(); + } + + // Wait for the image whether just started loading or reused promise + try { + await gImageLoading.get(imageUrl); + } catch (ex) { + // Ignore the failed image without changing state + return; + } + + // Only update state if we're still waiting to load the original image + if ( + ScreenshotUtils.isRemoteImageLocal( + this.state.cardImage, + this.props.link.image + ) && + !this.state.imageLoaded + ) { + this.setState({ imageLoaded: true }); + } + } + } + + /** + * 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 { image } = nextProps.link; + const imageInState = ScreenshotUtils.isRemoteImageLocal( + prevState.cardImage, + image + ); + let nextState = null; + + // Image is updating. + if (!imageInState && nextProps.link) { + nextState = { imageLoaded: false }; + } + + if (imageInState) { + return nextState; + } + + // Since image was updated, attempt to revoke old image blob URL, if it exists. + ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.cardImage); + + nextState = nextState || {}; + nextState.cardImage = ScreenshotUtils.createLocalImageObject(image); + + return nextState; + } + + onMenuButtonUpdate(isOpen) { + if (isOpen) { + this.setState({ activeCard: this.props.index }); + } else { + this.setState({ activeCard: null }); + } + } + + /** + * Report to telemetry additional information about the item. + */ + _getTelemetryInfo() { + // Filter out "history" type for being the default + if (this.props.link.type !== "history") { + return { value: { card_type: this.props.link.type } }; + } + + return null; + } + + onLinkClick(event) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + if (this.props.link.type === "download") { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: Object.assign(this.props.link, { + event: { button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } else { + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: Object.assign(this.props.link, { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + }), + }) + ); + } + if (this.props.isWebExtension) { + this.props.dispatch( + ac.WebExtEvent(at.WEBEXT_CLICK, { + source: this.props.eventSource, + url: this.props.link.url, + action_position: this.props.index, + }) + ); + } else { + this.props.dispatch( + ac.UserEvent( + Object.assign( + { + event: "CLICK", + source: this.props.eventSource, + action_position: this.props.index, + }, + this._getTelemetryInfo() + ) + ) + ); + + if (this.props.shouldSendImpressionStats) { + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.eventSource, + click: 0, + tiles: [{ id: this.props.link.guid, pos: this.props.index }], + }) + ); + } + } + } + + componentDidMount() { + this.maybeLoadImage(); + } + + componentDidUpdate() { + this.maybeLoadImage(); + } + + // 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 = _Card.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 = _Card.getNextStateFromProps(nextProps, this.state); + if (nextState) { + this.setState(nextState); + } + } + + componentWillUnmount() { + ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.cardImage); + } + + render() { + const { + index, + className, + link, + dispatch, + contextMenuOptions, + eventSource, + shouldSendImpressionStats, + } = this.props; + const { props } = this; + const title = link.title || link.hostname; + const isContextMenuOpen = this.state.activeCard === index; + // Display "now" as "trending" until we have new strings #3402 + const { icon, fluentID } = + cardContextTypes[link.type === "now" ? "trending" : link.type] || {}; + const hasImage = this.state.cardImage || link.hasImage; + const imageStyle = { + backgroundImage: this.state.cardImage + ? `url(${this.state.cardImage.url})` + : "none", + }; + const outerClassName = [ + "card-outer", + className, + isContextMenuOpen && "active", + props.placeholder && "placeholder", + ] + .filter(v => v) + .join(" "); + + return ( + <li className={outerClassName}> + <a + href={link.type === "pocket" ? link.open_url : link.url} + onClick={!props.placeholder ? this.onLinkClick : undefined} + > + <div className="card"> + <div className="card-preview-image-outer"> + {hasImage && ( + <div + className={`card-preview-image${ + this.state.imageLoaded ? " loaded" : "" + }`} + style={imageStyle} + /> + )} + </div> + <div className="card-details"> + {link.type === "download" && ( + <div + className="card-host-name alternate" + data-l10n-id="newtab-menu-open-file" + /> + )} + {link.hostname && ( + <div className="card-host-name"> + {link.hostname.slice(0, 100)} + {link.type === "download" && ` \u2014 ${link.description}`} + </div> + )} + <div + className={[ + "card-text", + icon ? "" : "no-context", + link.description ? "" : "no-description", + link.hostname ? "" : "no-host-name", + ].join(" ")} + > + <h4 className="card-title" dir="auto"> + {link.title} + </h4> + <p className="card-description" dir="auto"> + {link.description} + </p> + </div> + <div className="card-context"> + {icon && !link.context && ( + <span + aria-haspopup="true" + className={`card-context-icon icon icon-${icon}`} + /> + )} + {link.icon && link.context && ( + <span + aria-haspopup="true" + className="card-context-icon icon" + style={{ backgroundImage: `url('${link.icon}')` }} + /> + )} + {fluentID && !link.context && ( + <div className="card-context-label" data-l10n-id={fluentID} /> + )} + {link.context && ( + <div className="card-context-label">{link.context}</div> + )} + </div> + </div> + </div> + </a> + {!props.placeholder && ( + <ContextMenuButton + tooltip="newtab-menu-content-tooltip" + tooltipArgs={{ title }} + onUpdate={this.onMenuButtonUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={eventSource} + options={link.contextMenuOptions || contextMenuOptions} + site={link} + siteInfo={this._getTelemetryInfo()} + shouldSendImpressionStats={shouldSendImpressionStats} + /> + </ContextMenuButton> + )} + </li> + ); + } +} +_Card.defaultProps = { link: {} }; +export const Card = connect(state => ({ + platform: state.Prefs.values.platform, +}))(_Card); +export const PlaceholderCard = props => ( + <Card placeholder={true} className={props.className} /> +); diff --git a/browser/components/newtab/content-src/components/Card/_Card.scss b/browser/components/newtab/content-src/components/Card/_Card.scss new file mode 100644 index 0000000000..0c363b52d8 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/_Card.scss @@ -0,0 +1,331 @@ +@use 'sass:math'; + +.card-outer { + @include context-menu-button; + + background: var(--newtab-background-color-secondary); + border-radius: $border-radius-new; + display: inline-block; + height: $card-height; + margin-inline-end: $base-gutter; + position: relative; + width: 100%; + + &:is(:focus):not(.placeholder) { + @include ds-focus; + + transition: none; + } + + &:hover { + box-shadow: none; + transition: none; + } + + &.placeholder { + background: transparent; + + .card-preview-image-outer, + .card-context { + display: none; + } + } + + .card { + border-radius: $border-radius-new; + box-shadow: $shadow-card; + height: 100%; + } + + > a { + color: inherit; + display: block; + height: 100%; + outline: none; + position: absolute; + width: 100%; + + &:is(:focus) { + .card { + @include ds-focus; + } + } + + &:is(.active, :focus) { + .card { + @include fade-in-card; + } + + .card-title { + color: var(--newtab-primary-action-background); + } + } + } + + &:is(:hover, :focus, .active):not(.placeholder) { + @include context-menu-button-hover; + + outline: none; + + .card-title { + color: var(--newtab-primary-action-background); + } + + .alternate ~ .card-host-name { + display: none; + } + + .card-host-name.alternate { + display: block; + } + } + + .card-preview-image-outer { + background-color: var(--newtab-element-secondary-color); + border-radius: $border-radius-new $border-radius-new 0 0; + height: $card-preview-image-height; + overflow: hidden; + position: relative; + + &::after { + border-bottom: 1px solid var(--newtab-card-hairline-color); + bottom: 0; + content: ''; + position: absolute; + width: 100%; + } + + .card-preview-image { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + height: 100%; + opacity: 0; + transition: opacity 1s $photon-easing; + width: 100%; + + &.loaded { + opacity: 1; + } + } + } + + .card-details { + padding: 15px 16px 12px; + } + + .card-text { + max-height: 4 * $card-text-line-height + $card-title-margin; + overflow: hidden; + + &.no-host-name, + &.no-context { + max-height: 5 * $card-text-line-height + $card-title-margin; + } + + &.no-host-name.no-context { + max-height: 6 * $card-text-line-height + $card-title-margin; + } + + &:not(.no-description) .card-title { + max-height: 3 * $card-text-line-height; + overflow: hidden; + } + } + + .card-host-name { + color: var(--newtab-text-secondary-color); + font-size: 10px; + overflow: hidden; + padding-bottom: 4px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .card-host-name.alternate { display: none; } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $card-text-line-height; + margin: 0 0 $card-title-margin; + word-wrap: break-word; + } + + .card-description { + font-size: 12px; + line-height: $card-text-line-height; + margin: 0; + overflow: hidden; + word-wrap: break-word; + } + + .card-context { + bottom: 0; + color: var(--newtab-text-secondary-color); + display: flex; + font-size: 11px; + inset-inline-start: 0; + padding: 9px 16px 9px 14px; + position: absolute; + } + + .card-context-icon { + fill: var(--newtab-text-secondary-color); + height: 22px; + margin-inline-end: 6px; + } + + .card-context-label { + flex-grow: 1; + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.normal-cards { + .card-outer { + // Wide layout styles + @media (min-width: $break-point-widest) { + $line-height: 23px; + + height: $card-height-large; + + .card-preview-image-outer { + height: $card-preview-image-height-large; + } + + .card-details { + padding: 13px 16px 12px; + } + + .card-text { + max-height: 6 * $line-height + $card-title-margin; + } + + .card-host-name { + font-size: 12px; + padding-bottom: 5px; + } + + .card-title { + font-size: 17px; + line-height: $line-height; + margin-bottom: 0; + } + + .card-text:not(.no-description) { + .card-title { + max-height: 3 * $line-height; + } + } + + .card-description { + font-size: 15px; + line-height: $line-height; + } + + .card-context { + bottom: 4px; + font-size: 14px; + } + } + } +} + +.compact-cards { + $card-detail-vertical-spacing: 12px; + $card-title-font-size: 12px; + + .card-outer { + height: $card-height-compact; + + .card-preview-image-outer { + height: $card-preview-image-height-compact; + } + + .card-details { + padding: $card-detail-vertical-spacing 16px; + } + + .card-host-name { + line-height: 10px; + } + + .card-text { + .card-title, + &:not(.no-description) .card-title { + font-size: $card-title-font-size; + line-height: $card-title-font-size + 1; + max-height: $card-title-font-size + 5; + overflow: hidden; + padding: 0 0 4px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .card-description { + display: none; + } + + .card-context { + $icon-size: 16px; + $container-size: 32px; + + background-color: var(--newtab-background-color-secondary); + border-radius: math.div($container-size, 2); + clip-path: inset(-1px -1px $container-size - ($card-height-compact - $card-preview-image-height-compact - 2 * $card-detail-vertical-spacing)); + height: $container-size; + width: $container-size; + padding: math.div($container-size - $icon-size, 2); + // The -1 at the end is so both opacity borders don't overlap, which causes bug 1629483 + top: $card-preview-image-height-compact - math.div($container-size, 2) - 1; + inset-inline-end: 12px; + inset-inline-start: auto; + + &::after { + border: 1px solid var(--newtab-card-hairline-color); + border-bottom: 0; + border-radius: math.div($container-size, 2) + 1 math.div($container-size, 2) + 1 0 0; + content: ''; + position: absolute; + height: math.div($container-size + 2, 2); + width: $container-size + 2; + top: -1px; + left: -1px; + } + + .card-context-icon { + margin-inline-end: 0; + height: $icon-size; + width: $icon-size; + + &.icon-bookmark-added { + fill: $bookmark-icon-fill; + } + + &.icon-download { + fill: $download-icon-fill; + } + + &.icon-pocket { + fill: $pocket-icon-fill; + } + } + + .card-context-label { + display: none; + } + } + } + + @media not all and (min-width: $break-point-widest) { + .hide-for-narrow { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/Card/types.js b/browser/components/newtab/content-src/components/Card/types.js new file mode 100644 index 0000000000..0b17eea408 --- /dev/null +++ b/browser/components/newtab/content-src/components/Card/types.js @@ -0,0 +1,30 @@ +/* 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 cardContextTypes = { + history: { + fluentID: "newtab-label-visited", + icon: "history-item", + }, + removedBookmark: { + fluentID: "newtab-label-removed-bookmark", + icon: "bookmark-removed", + }, + bookmark: { + fluentID: "newtab-label-bookmarked", + icon: "bookmark-added", + }, + trending: { + fluentID: "newtab-label-recommended", + icon: "trending", + }, + pocket: { + fluentID: "newtab-label-saved", + icon: "pocket", + }, + download: { + fluentID: "newtab-label-download", + icon: "download", + }, +}; diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx new file mode 100644 index 0000000000..679e8e137f --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/CollapsibleSection.jsx @@ -0,0 +1,116 @@ +/* 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 { ErrorBoundary } from "content-src/components/ErrorBoundary/ErrorBoundary"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import React from "react"; +import { connect } from "react-redux"; + +/** + * A section that can collapse. As of bug 1710937, it can no longer collapse. + * See bug 1727365 for follow-up work to simplify this component. + */ +export class _CollapsibleSection extends React.PureComponent { + constructor(props) { + super(props); + this.onBodyMount = this.onBodyMount.bind(this); + this.onMenuButtonMouseEnter = this.onMenuButtonMouseEnter.bind(this); + this.onMenuButtonMouseLeave = this.onMenuButtonMouseLeave.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.state = { + menuButtonHover: false, + showContextMenu: false, + }; + this.setContextMenuButtonRef = this.setContextMenuButtonRef.bind(this); + } + + setContextMenuButtonRef(element) { + this.contextMenuButtonRef = element; + } + + onBodyMount(node) { + this.sectionBody = node; + } + + onMenuButtonMouseEnter() { + this.setState({ menuButtonHover: true }); + } + + onMenuButtonMouseLeave() { + this.setState({ menuButtonHover: false }); + } + + onMenuUpdate(showContextMenu) { + this.setState({ showContextMenu }); + } + + render() { + const { isAnimating, maxHeight, menuButtonHover, showContextMenu } = + this.state; + const { id, collapsed, learnMore, title, subTitle } = this.props; + const active = menuButtonHover || showContextMenu; + let bodyStyle; + if (isAnimating && !collapsed) { + bodyStyle = { maxHeight }; + } else if (!isAnimating && collapsed) { + bodyStyle = { display: "none" }; + } + let titleStyle; + if (this.props.hideTitle) { + titleStyle = { visibility: "hidden" }; + } + const hasSubtitleClassName = subTitle ? `has-subtitle` : ``; + return ( + <section + className={`collapsible-section ${this.props.className}${ + active ? " active" : "" + }`} + // Note: data-section-id is used for web extension api tests in mozilla central + data-section-id={id} + > + <div className="section-top-bar"> + <h3 + className={`section-title-container ${hasSubtitleClassName}`} + style={titleStyle} + > + <span className="section-title"> + <FluentOrText message={title} /> + </span> + <span className="learn-more-link-wrapper"> + {learnMore && ( + <span className="learn-more-link"> + <FluentOrText message={learnMore.link.message}> + <a href={learnMore.link.href} /> + </FluentOrText> + </span> + )} + </span> + {subTitle && ( + <span className="section-sub-title"> + <FluentOrText message={subTitle} /> + </span> + )} + </h3> + </div> + <ErrorBoundary className="section-body-fallback"> + <div ref={this.onBodyMount} style={bodyStyle}> + {this.props.children} + </div> + </ErrorBoundary> + </section> + ); + } +} + +_CollapsibleSection.defaultProps = { + document: global.document || { + addEventListener: () => {}, + removeEventListener: () => {}, + visibilityState: "hidden", + }, +}; + +export const CollapsibleSection = connect(state => ({ + Prefs: state.Prefs, +}))(_CollapsibleSection); diff --git a/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss new file mode 100644 index 0000000000..9811339b27 --- /dev/null +++ b/browser/components/newtab/content-src/components/CollapsibleSection/_CollapsibleSection.scss @@ -0,0 +1,106 @@ +.collapsible-section { + padding: $section-vertical-padding $section-horizontal-padding; + + .section-title-container { + margin: 0; + + &.has-subtitle { + display: flex; + flex-direction: column; + + @media (min-width: $break-point-large) { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + } + } + } + + .section-title { + font-size: $section-title-font-size; + font-weight: 600; + color: var(--newtab-text-primary-color); + + &.grey-title { + color: var(--newtab-text-primary-color); + display: inline-block; + fill: var(--newtab-text-primary-color); + vertical-align: middle; + } + + .section-title-contents { + // Center "What's Pocket?" for "mobile" viewport + @media (max-width: $break-point-medium - 1) { + display: block; + + .learn-more-link-wrapper { + display: block; + text-align: center; + + .learn-more-link { + margin-inline-start: 0; + } + } + } + + vertical-align: top; + } + } + + .section-sub-title { + font-size: 14px; + line-height: 16px; + color: var(--newtab-text-secondary-color); + opacity: 0.3; + } + + .section-top-bar { + min-height: 19px; + margin-bottom: 13px; + position: relative; + } + + &.active { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .learn-more-link { + font-size: 13px; + margin-inline-start: 12px; + + a { + color: var(--newtab-primary-action-background); + } + } + + .section-body-fallback { + height: $card-height; + } + + .section-body { + // This is so the top sites favicon and card dropshadows don't get clipped during animation: + $horizontal-padding: 7px; + + margin: 0 (-$horizontal-padding); + padding: 0 $horizontal-padding; + + &.animating { + overflow: hidden; + pointer-events: none; + } + } + + &[data-section-id='topsites'] { + .section-top-bar { + display: none; + } + } + + // Hide first story card for the medium breakpoint to prevent orphaned third story + &[data-section-id='topstories'] .card-outer:first-child { + @media (min-width: $break-point-medium) and (max-width: $break-point-large - 1) { + display: none; + } + } +} diff --git a/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx new file mode 100644 index 0000000000..4efd8c712e --- /dev/null +++ b/browser/components/newtab/content-src/components/ComponentPerfTimer/ComponentPerfTimer.jsx @@ -0,0 +1,177 @@ +/* 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 { perfService as perfSvc } from "content-src/lib/perf-service"; +import React from "react"; + +// Currently record only a fixed set of sections. This will prevent data +// from custom sections from showing up or from topstories. +const RECORDED_SECTIONS = ["highlights", "topsites"]; + +export class ComponentPerfTimer extends React.Component { + constructor(props) { + super(props); + // Just for test dependency injection: + this.perfSvc = this.props.perfSvc || perfSvc; + + this._sendBadStateEvent = this._sendBadStateEvent.bind(this); + this._sendPaintedEvent = this._sendPaintedEvent.bind(this); + this._reportMissingData = false; + this._timestampHandled = false; + this._recordedFirstRender = false; + } + + componentDidMount() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + componentDidUpdate() { + if (!RECORDED_SECTIONS.includes(this.props.id)) { + return; + } + + this._maybeSendPaintedEvent(); + } + + /** + * Call the given callback after the upcoming frame paints. + * + * @note Both setTimeout and requestAnimationFrame are throttled when the page + * is hidden, so this callback may get called up to a second or so after the + * requestAnimationFrame "paint" for hidden tabs. + * + * Newtabs hidden while loading will presumably be fairly rare (other than + * preloaded tabs, which we will be filtering out on the server side), so such + * cases should get lost in the noise. + * + * If we decide that it's important to find out when something that's hidden + * has "painted", however, another option is to post a message to this window. + * That should happen even faster than setTimeout, and, at least as of this + * writing, it's not throttled in hidden windows in Firefox. + * + * @param {Function} callback + * + * @returns void + */ + _afterFramePaint(callback) { + requestAnimationFrame(() => setTimeout(callback, 0)); + } + + _maybeSendBadStateEvent() { + // Follow up bugs: + // https://github.com/mozilla/activity-stream/issues/3691 + if (!this.props.initialized) { + // Remember to report back when data is available. + this._reportMissingData = true; + } else if (this._reportMissingData) { + this._reportMissingData = false; + // Report how long it took for component to become initialized. + this._sendBadStateEvent(); + } + } + + _maybeSendPaintedEvent() { + // If we've already handled a timestamp, don't do it again. + if (this._timestampHandled || !this.props.initialized) { + return; + } + + // And if we haven't, we're doing so now, so remember that. Even if + // something goes wrong in the callback, we can't try again, as we'd be + // sending back the wrong data, and we have to do it here, so that other + // calls to this method while waiting for the next frame won't also try to + // handle it. + this._timestampHandled = true; + this._afterFramePaint(this._sendPaintedEvent); + } + + /** + * Triggered by call to render. Only first call goes through due to + * `_recordedFirstRender`. + */ + _ensureFirstRenderTsRecorded() { + // Used as t0 for recording how long component took to initialize. + if (!this._recordedFirstRender) { + this._recordedFirstRender = true; + // topsites_first_render_ts, highlights_first_render_ts. + const key = `${this.props.id}_first_render_ts`; + this.perfSvc.mark(key); + } + } + + /** + * Creates `SAVE_SESSION_PERF_DATA` with timestamp in ms + * of how much longer the data took to be ready for display than it would + * have been the ideal case. + * https://github.com/mozilla/ping-centre/issues/98 + */ + _sendBadStateEvent() { + // highlights_data_ready_ts, topsites_data_ready_ts. + const dataReadyKey = `${this.props.id}_data_ready_ts`; + this.perfSvc.mark(dataReadyKey); + + try { + const firstRenderKey = `${this.props.id}_first_render_ts`; + // value has to be Int32. + const value = parseInt( + this.perfSvc.getMostRecentAbsMarkStartByName(dataReadyKey) - + this.perfSvc.getMostRecentAbsMarkStartByName(firstRenderKey), + 10 + ); + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + // highlights_data_late_by_ms, topsites_data_late_by_ms. + data: { [`${this.props.id}_data_late_by_ms`]: value }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. + } + } + + _sendPaintedEvent() { + // Record first_painted event but only send if topsites. + if (this.props.id !== "topsites") { + return; + } + + // topsites_first_painted_ts. + const key = `${this.props.id}_first_painted_ts`; + this.perfSvc.mark(key); + + try { + const data = {}; + data[key] = this.perfSvc.getMostRecentAbsMarkStartByName(key); + + this.props.dispatch( + ac.OnlyToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up, and should continue + // to set this._timestampHandled to avoid going through this again. + } + } + + render() { + if (RECORDED_SECTIONS.includes(this.props.id)) { + this._ensureFirstRenderTsRecorded(); + this._maybeSendBadStateEvent(); + } + return this.props.children; + } +} diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx new file mode 100644 index 0000000000..f69e540079 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/ConfirmDialog.jsx @@ -0,0 +1,103 @@ +/* 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 } from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import React from "react"; + +/** + * ConfirmDialog component. + * One primary action button, one cancel button. + * + * Content displayed is controlled by `data` prop the component receives. + * Example: + * data: { + * // Any sort of data needed to be passed around by actions. + * payload: site.url, + * // Primary button AlsoToMain action. + * action: "DELETE_HISTORY_URL", + * // Primary button USerEvent action. + * userEvent: "DELETE", + * // Array of locale ids to display. + * message_body: ["confirm_history_delete_p1", "confirm_history_delete_notice_p2"], + * // Text for primary button. + * confirm_button_string_id: "menu_action_delete" + * }, + */ +export class _ConfirmDialog extends React.PureComponent { + constructor(props) { + super(props); + this._handleCancelBtn = this._handleCancelBtn.bind(this); + this._handleConfirmBtn = this._handleConfirmBtn.bind(this); + } + + _handleCancelBtn() { + this.props.dispatch({ type: actionTypes.DIALOG_CANCEL }); + this.props.dispatch( + ac.UserEvent({ + event: actionTypes.DIALOG_CANCEL, + source: this.props.data.eventSource, + }) + ); + } + + _handleConfirmBtn() { + this.props.data.onConfirm.forEach(this.props.dispatch); + } + + _renderModalMessage() { + const message_body = this.props.data.body_string_id; + + if (!message_body) { + return null; + } + + return ( + <span> + {message_body.map(msg => ( + <p key={msg} data-l10n-id={msg} /> + ))} + </span> + ); + } + + render() { + if (!this.props.visible) { + return null; + } + + return ( + <div className="confirmation-dialog"> + <div + className="modal-overlay" + onClick={this._handleCancelBtn} + role="presentation" + /> + <div className="modal"> + <section className="modal-message"> + {this.props.data.icon && ( + <span + className={`icon icon-spacer icon-${this.props.data.icon}`} + /> + )} + {this._renderModalMessage()} + </section> + <section className="actions"> + <button + onClick={this._handleCancelBtn} + data-l10n-id={this.props.data.cancel_button_string_id} + /> + <button + className="done" + onClick={this._handleConfirmBtn} + data-l10n-id={this.props.data.confirm_button_string_id} + /> + </section> + </div> + </div> + ); + } +} + +export const ConfirmDialog = connect(state => state.Dialog)(_ConfirmDialog); diff --git a/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss new file mode 100644 index 0000000000..ca9940ffc5 --- /dev/null +++ b/browser/components/newtab/content-src/components/ConfirmDialog/_ConfirmDialog.scss @@ -0,0 +1,68 @@ +.confirmation-dialog { + .modal { + box-shadow: $shadow-secondary; + left: 0; + margin: auto; + position: fixed; + right: 0; + top: 20%; + width: 400px; + } + + section { + margin: 0; + } + + .modal-message { + display: flex; + padding: 16px; + padding-bottom: 0; + + p { + margin: 0; + margin-bottom: 16px; + } + } + + .actions { + border: 0; + display: flex; + flex-wrap: nowrap; + padding: 0 16px; + + button { + margin-inline-end: 16px; + padding-inline-end: 18px; + padding-inline-start: 18px; + white-space: normal; + width: 50%; + + &.done { + margin-inline-end: 0; + margin-inline-start: 0; + } + } + } + + .icon { + margin-inline-end: 16px; + } +} + +.modal-overlay { + background: var(--newtab-overlay-color); + height: 100%; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 11001; +} + +.modal { + background: var(--newtab-background-color-secondary); + border: $border-secondary; + border-radius: 5px; + font-size: 15px; + z-index: 11002; +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx new file mode 100644 index 0000000000..5ea6a57f71 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenu.jsx @@ -0,0 +1,176 @@ +/* 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"; +import { connect } from "react-redux"; + +export class ContextMenu extends React.PureComponent { + constructor(props) { + super(props); + this.hideContext = this.hideContext.bind(this); + this.onShow = this.onShow.bind(this); + this.onClick = this.onClick.bind(this); + } + + hideContext() { + this.props.onUpdate(false); + } + + onShow() { + if (this.props.onShow) { + this.props.onShow(); + } + } + + componentDidMount() { + this.onShow(); + setTimeout(() => { + global.addEventListener("click", this.hideContext); + }, 0); + } + + componentWillUnmount() { + global.removeEventListener("click", this.hideContext); + } + + onClick(event) { + // Eat all clicks on the context menu so they don't bubble up to window. + // This prevents the context menu from closing when clicking disabled items + // or the separators. + event.stopPropagation(); + } + + render() { + // Disabling focus on the menu span allows the first tab to focus on the first menu item instead of the wrapper. + return ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus + <span className="context-menu"> + <ul + role="menu" + onClick={this.onClick} + onKeyDown={this.onClick} + className="context-menu-list" + > + {this.props.options.map((option, i) => + option.type === "separator" ? ( + <li key={i} className="separator" role="separator" /> + ) : ( + option.type !== "empty" && ( + <ContextMenuItem + key={i} + option={option} + hideContext={this.hideContext} + keyboardAccess={this.props.keyboardAccess} + /> + ) + ) + )} + </ul> + </span> + ); + } +} + +export class _ContextMenuItem extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.focusFirst = this.focusFirst.bind(this); + } + + onClick(event) { + this.props.hideContext(); + this.props.option.onClick(event); + } + + // Focus the first menu item if the menu was accessed via the keyboard. + focusFirst(button) { + if (this.props.keyboardAccess && button) { + button.focus(); + } + } + + // This selects the correct node based on the key pressed + focusSibling(target, key) { + const parent = target.parentNode; + const closestSiblingSelector = + key === "ArrowUp" ? "previousSibling" : "nextSibling"; + if (!parent[closestSiblingSelector]) { + return; + } + if (parent[closestSiblingSelector].firstElementChild) { + parent[closestSiblingSelector].firstElementChild.focus(); + } else { + parent[closestSiblingSelector][ + closestSiblingSelector + ].firstElementChild.focus(); + } + } + + onKeyDown(event) { + const { option } = this.props; + switch (event.key) { + case "Tab": + // tab goes down in context menu, shift + tab goes up in context menu + // if we're on the last item, one more tab will close the context menu + // similarly, if we're on the first item, one more shift + tab will close it + if ( + (event.shiftKey && option.first) || + (!event.shiftKey && option.last) + ) { + this.props.hideContext(); + } + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + this.focusSibling(event.target, event.key); + break; + case "Enter": + case " ": + event.preventDefault(); + this.props.hideContext(); + option.onClick(); + break; + case "Escape": + this.props.hideContext(); + break; + } + } + + // Prevents the default behavior of spacebar + // scrolling the page & auto-triggering buttons. + onKeyUp(event) { + if (event.key === " ") { + event.preventDefault(); + } + } + + render() { + const { option } = this.props; + return ( + <li role="presentation" className="context-menu-item"> + <button + className={option.disabled ? "disabled" : ""} + role="menuitem" + onClick={this.onClick} + onKeyDown={this.onKeyDown} + onKeyUp={this.onKeyUp} + ref={option.first ? this.focusFirst : null} + aria-haspopup={ + option.id === "newtab-menu-edit-topsites" ? "dialog" : null + } + > + <span data-l10n-id={option.string_id || option.id} /> + </button> + </li> + ); + } +} + +export const ContextMenuItem = connect(state => ({ + Prefs: state.Prefs, +}))(_ContextMenuItem); diff --git a/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx new file mode 100644 index 0000000000..0364f5386a --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/ContextMenuButton.jsx @@ -0,0 +1,72 @@ +/* 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 ContextMenuButton extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showContextMenu: false, + contextMenuKeyboard: false, + }; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onUpdate = this.onUpdate.bind(this); + } + + openContextMenu(isKeyBoard, event) { + if (this.props.onUpdate) { + this.props.onUpdate(true); + } + this.setState({ + showContextMenu: true, + contextMenuKeyboard: isKeyBoard, + }); + } + + onClick(event) { + event.preventDefault(); + this.openContextMenu(false, event); + } + + onKeyDown(event) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.openContextMenu(true, event); + } + } + + onUpdate(showContextMenu) { + if (this.props.onUpdate) { + this.props.onUpdate(showContextMenu); + } + this.setState({ showContextMenu }); + } + + render() { + const { tooltipArgs, tooltip, children, refFunction } = this.props; + const { showContextMenu, contextMenuKeyboard } = this.state; + + return ( + <React.Fragment> + <button + aria-haspopup="true" + data-l10n-id={tooltip} + data-l10n-args={tooltipArgs ? JSON.stringify(tooltipArgs) : null} + className="context-menu-button icon" + onKeyDown={this.onKeyDown} + onClick={this.onClick} + ref={refFunction} + /> + {showContextMenu + ? React.cloneElement(children, { + keyboardAccess: contextMenuKeyboard, + onUpdate: this.onUpdate, + }) + : null} + </React.Fragment> + ); + } +} diff --git a/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss new file mode 100644 index 0000000000..c0074128e6 --- /dev/null +++ b/browser/components/newtab/content-src/components/ContextMenu/_ContextMenu.scss @@ -0,0 +1,57 @@ +@use 'sass:math'; + +.context-menu { + background: var(--newtab-background-color-secondary); + border-radius: $context-menu-border-radius; + box-shadow: $context-menu-shadow; + display: block; + font-size: $context-menu-font-size; + margin-inline-start: 5px; + inset-inline-start: 100%; + position: absolute; + top: math.div($context-menu-button-size, 4); + z-index: 8; + + > ul { + list-style: none; + margin: 0; + padding: $context-menu-outer-padding 0; + + > li { + margin: 0; + width: 100%; + + &.separator { + border-bottom: $border-secondary; + margin: $context-menu-outer-padding 0; + } + + > a, + > button { + align-items: center; + color: inherit; + cursor: pointer; + display: flex; + width: 100%; + line-height: 16px; + outline: none; + border: 0; + padding: $context-menu-item-padding; + white-space: nowrap; + + &:is(:focus, :hover) { + background: var(--newtab-element-secondary-hover-color); + } + + &:active { + background: var(--newtab-element-secondary-active-color); + } + + &.disabled { + opacity: 0.4; + pointer-events: none; + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx new file mode 100644 index 0000000000..522ea6841f --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection.jsx @@ -0,0 +1,11 @@ +/* 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 BackgroundsSection extends React.PureComponent { + render() { + return <div />; + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx new file mode 100644 index 0000000000..423fd131e2 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/ContentSection/ContentSection.jsx @@ -0,0 +1,308 @@ +/* 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"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +export class ContentSection extends React.PureComponent { + constructor(props) { + super(props); + this.onPreferenceSelect = this.onPreferenceSelect.bind(this); + + // Refs are necessary for dynamically measuring drawer heights for slide animations + this.topSitesDrawerRef = React.createRef(); + this.pocketDrawerRef = React.createRef(); + } + + inputUserEvent(eventSource, status) { + this.props.dispatch( + ac.UserEvent({ + event: "PREF_CHANGED", + source: eventSource, + value: { status, menu_source: "CUSTOMIZE_MENU" }, + }) + ); + } + + onPreferenceSelect(e) { + let prefName = e.target.getAttribute("preference"); + const eventSource = e.target.getAttribute("eventSource"); // TOP_SITES, TOP_STORIES, HIGHLIGHTS + let value; + if (e.target.nodeName === "SELECT") { + value = parseInt(e.target.value, 10); + } else if (e.target.nodeName === "INPUT") { + value = e.target.checked; + if (eventSource) { + this.inputUserEvent(eventSource, value); + } + } + this.props.setPref(prefName, value); + } + + componentDidMount() { + this.setDrawerMargins(); + } + + componentDidUpdate() { + this.setDrawerMargins(); + } + + setDrawerMargins() { + this.setDrawerMargin( + `TOP_SITES`, + this.props.enabledSections.topSitesEnabled + ); + this.setDrawerMargin( + `TOP_STORIES`, + this.props.enabledSections.pocketEnabled + ); + } + + setDrawerMargin(drawerID, isOpen) { + let drawerRef; + + if (drawerID === `TOP_SITES`) { + drawerRef = this.topSitesDrawerRef.current; + } else if (drawerID === `TOP_STORIES`) { + drawerRef = this.pocketDrawerRef.current; + } else { + return; + } + + let drawerHeight; + + if (drawerRef) { + drawerHeight = parseFloat(window.getComputedStyle(drawerRef)?.height); + + if (isOpen) { + drawerRef.style.marginTop = `0`; + } else { + drawerRef.style.marginTop = `-${drawerHeight}px`; + } + } + } + + render() { + const { + enabledSections, + mayHaveSponsoredTopSites, + pocketRegion, + mayHaveSponsoredStories, + mayHaveRecentSaves, + openPreferences, + } = this.props; + const { + topSitesEnabled, + pocketEnabled, + highlightsEnabled, + showSponsoredTopSitesEnabled, + showSponsoredPocketEnabled, + showRecentSavesEnabled, + topSitesRowsCount, + } = enabledSections; + + return ( + <div className="home-section"> + <div id="shortcuts-section" className="section"> + <label className="switch"> + <input + id="shortcuts-toggle" + checked={topSitesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.topsites" + aria-labelledby="custom-shortcuts-title" + aria-describedby="custom-shortcuts-subtitle" + eventSource="TOP_SITES" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-shortcuts-title" className="title"> + <label + htmlFor="shortcuts-toggle" + data-l10n-id="newtab-custom-shortcuts-title" + ></label> + </h2> + <p + id="custom-shortcuts-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-shortcuts-subtitle" + ></p> + <div className="more-info-top-wrapper"> + <div className="more-information" ref={this.topSitesDrawerRef}> + <select + id="row-selector" + className="selector" + name="row-count" + preference="topSitesRows" + value={topSitesRowsCount} + onChange={this.onPreferenceSelect} + disabled={!topSitesEnabled} + aria-labelledby="custom-shortcuts-title" + > + <option + value="1" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 1}' + /> + <option + value="2" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 2}' + /> + <option + value="3" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 3}' + /> + <option + value="4" + data-l10n-id="newtab-custom-row-selector" + data-l10n-args='{"num": 4}' + /> + </select> + {mayHaveSponsoredTopSites && ( + <div className="check-wrapper" role="presentation"> + <input + id="sponsored-shortcuts" + className="sponsored-checkbox" + disabled={!topSitesEnabled} + checked={showSponsoredTopSitesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="showSponsoredTopSites" + eventSource="SPONSORED_TOP_SITES" + /> + <label + className="sponsored" + htmlFor="sponsored-shortcuts" + data-l10n-id="newtab-custom-sponsored-sites" + /> + </div> + )} + </div> + </div> + </div> + </div> + + {pocketRegion && ( + <div id="pocket-section" className="section"> + <label className="switch"> + <input + id="pocket-toggle" + checked={pocketEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.section.topstories" + aria-labelledby="custom-pocket-title" + aria-describedby="custom-pocket-subtitle" + eventSource="TOP_STORIES" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-pocket-title" className="title"> + <label + htmlFor="pocket-toggle" + data-l10n-id="newtab-custom-pocket-title" + ></label> + </h2> + <p + id="custom-pocket-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-pocket-subtitle" + /> + {(mayHaveSponsoredStories || mayHaveRecentSaves) && ( + <div className="more-info-pocket-wrapper"> + <div className="more-information" ref={this.pocketDrawerRef}> + {mayHaveSponsoredStories && ( + <div className="check-wrapper" role="presentation"> + <input + id="sponsored-pocket" + className="sponsored-checkbox" + disabled={!pocketEnabled} + checked={showSponsoredPocketEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="showSponsored" + eventSource="POCKET_SPOCS" + /> + <label + className="sponsored" + htmlFor="sponsored-pocket" + data-l10n-id="newtab-custom-pocket-sponsored" + /> + </div> + )} + {mayHaveRecentSaves && ( + <div className="check-wrapper" role="presentation"> + <input + id="recent-saves-pocket" + className="sponsored-checkbox" + disabled={!pocketEnabled} + checked={showRecentSavesEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="showRecentSaves" + eventSource="POCKET_RECENT_SAVES" + /> + <label + className="sponsored" + htmlFor="recent-saves-pocket" + data-l10n-id="newtab-custom-pocket-show-recent-saves" + /> + </div> + )} + </div> + </div> + )} + </div> + </div> + )} + + <div id="recent-section" className="section"> + <label className="switch"> + <input + id="highlights-toggle" + checked={highlightsEnabled} + type="checkbox" + onChange={this.onPreferenceSelect} + preference="feeds.section.highlights" + eventSource="HIGHLIGHTS" + aria-labelledby="custom-recent-title" + aria-describedby="custom-recent-subtitle" + /> + <span className="slider" role="presentation"></span> + </label> + <div> + <h2 id="custom-recent-title" className="title"> + <label + htmlFor="highlights-toggle" + data-l10n-id="newtab-custom-recent-title" + ></label> + </h2> + + <p + id="custom-recent-subtitle" + className="subtitle" + data-l10n-id="newtab-custom-recent-subtitle" + /> + </div> + </div> + + <span className="divider" role="separator"></span> + + <div> + <button + id="settings-link" + className="external-link" + onClick={openPreferences} + data-l10n-id="newtab-custom-settings" + /> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx new file mode 100644 index 0000000000..2b16c7fd39 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/CustomizeMenu.jsx @@ -0,0 +1,87 @@ +/* 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 { BackgroundsSection } from "content-src/components/CustomizeMenu/BackgroundsSection/BackgroundsSection"; +import { ContentSection } from "content-src/components/CustomizeMenu/ContentSection/ContentSection"; +import { connect } from "react-redux"; +import React from "react"; +import { CSSTransition } from "react-transition-group"; + +export class _CustomizeMenu extends React.PureComponent { + constructor(props) { + super(props); + this.onEntered = this.onEntered.bind(this); + this.onExited = this.onExited.bind(this); + } + + onEntered() { + if (this.closeButton) { + this.closeButton.focus(); + } + } + + onExited() { + if (this.openButton) { + this.openButton.focus(); + } + } + + render() { + return ( + <span> + <CSSTransition + timeout={300} + classNames="personalize-animate" + in={!this.props.showing} + appear={true} + > + <button + className="icon icon-settings personalize-button" + onClick={() => this.props.onOpen()} + data-l10n-id="newtab-personalize-icon-label" + ref={c => (this.openButton = c)} + /> + </CSSTransition> + <CSSTransition + timeout={250} + classNames="customize-animate" + in={this.props.showing} + onEntered={this.onEntered} + onExited={this.onExited} + appear={true} + > + <div + className="customize-menu" + role="dialog" + data-l10n-id="newtab-personalize-dialog-label" + > + <button + onClick={() => this.props.onClose()} + className="close-button" + data-l10n-id="newtab-custom-close-button" + ref={c => (this.closeButton = c)} + /> + <BackgroundsSection /> + <ContentSection + openPreferences={this.props.openPreferences} + setPref={this.props.setPref} + enabledSections={this.props.enabledSections} + pocketRegion={this.props.pocketRegion} + mayHaveSponsoredTopSites={this.props.mayHaveSponsoredTopSites} + mayHaveSponsoredStories={ + this.props.DiscoveryStream.config.show_spocs + } + mayHaveRecentSaves={this.props.DiscoveryStream.recentSavesEnabled} + dispatch={this.props.dispatch} + /> + </div> + </CSSTransition> + </span> + ); + } +} + +export const CustomizeMenu = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_CustomizeMenu); diff --git a/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss new file mode 100644 index 0000000000..98046c2326 --- /dev/null +++ b/browser/components/newtab/content-src/components/CustomizeMenu/_CustomizeMenu.scss @@ -0,0 +1,311 @@ +@media (max-height: 701px) { + .personalize-button { + position: absolute; + top: 16px; + inset-inline-end: 16px; + } +} + +@media (min-height: 700px) { + .personalize-button { + position: fixed; + top: 16px; + inset-inline-end: 16px; + z-index: 1000; + } +} + +.personalize-button { + border: 0; + border-radius: 4px; + background-color: transparent; + padding: 15px; + + &:hover { + background-color: var(--newtab-element-hover-color); + } + + &:active { + background-color: var(--newtab-element-active-color); + } + + &:focus-visible { + @include ds-focus; + } + + &.personalize-animate-exit-done { + visibility: hidden; + } +} + +.customize-menu { + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color-secondary); + width: 432px; + height: 100%; + position: fixed; + inset-block: 0; + inset-inline-end: 0; + z-index: 1001; + padding: 16px; + overflow: auto; + transform: translateX(435px); + visibility: hidden; + cursor: default; + + @media (prefers-reduced-motion: no-preference) { + transition: transform 250ms $customize-menu-slide-bezier, visibility 250ms; + } + + @media (forced-colors: active) { + border-inline-start: solid 1px; + } + + &:dir(rtl) { + transform: translateX(-435px); + } + + &.customize-animate-enter-done, + &.customize-animate-enter-active { + box-shadow: $shadow-large; + visibility: visible; + transform: translateX(0); + } + + &.customize-animate-exit-active { + box-shadow: $shadow-large; + } + + .close-button { + margin-inline-start: auto; + margin-bottom: 28px; + white-space: nowrap; + display: block; + background-color: var(--newtab-element-secondary-color); + padding: 8px 10px; + border: $customize-menu-border-tint; + border-radius: 4px; + color: var(--newtab-text-primary-color); + font-size: 13px; + font-weight: 600; + } + + .close-button:hover { + background-color: var(--newtab-element-secondary-hover-color); + } + + .close-button:hover:active { + background-color: var(--newtab-element-secondary-active-color); + } +} + +.grid-skip { + display: contents; +} + +.home-section { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(4, auto); + grid-row-gap: 32px; + padding: 0 16px; + + .section { + display: grid; + grid-template-rows: auto; + grid-template-columns: auto 26px; + + & > div { + grid-area: 1; + } + + .title { + grid-column: 1 / -1; + margin: 0; + font-weight: 600; + font-size: 16px; + margin-bottom: 10px; + } + + .subtitle { + margin: 0; + font-size: 14px; + } + + .sponsored { + font-size: 14px; + margin-inline-start: 5px; + } + + .check-wrapper { + position: relative; + } + + .sponsored-checkbox { + margin-inline-start: 2px; + width: 16px; + height: 16px; + vertical-align: middle; + border: $customize-menu-border-tint; + box-sizing: border-box; + border-radius: 4px; + appearance: none; + background-color: var(--newtab-element-secondary-color); + } + + .sponsored-checkbox:checked { + -moz-context-properties: fill; + fill: var(--newtab-primary-element-text-color); + background: url('chrome://global/skin/icons/check.svg') center no-repeat; + background-color: var(--newtab-primary-action-background); + + @media (forced-colors: active) { + fill: $black; + } + } + + .sponsored-checkbox:active + .checkmark { + fill: var(--newtab-element-secondary-color); + } + + .selector { + color: var(--newtab-text-primary-color); + width: 118px; + display: block; + border: 1px solid var(--newtab-border-color); + border-radius: 4px; + appearance: none; + padding-block: 7px; + padding-inline: 10px 13px; + margin-inline-start: 2px; + margin-bottom: 2px; + -moz-context-properties: fill; + fill: var(--newtab-text-primary-color); + background: url('chrome://global/skin/icons/arrow-down-12.svg') right no-repeat; + background-size: 8px; + background-origin: content-box; + background-color: var(--newtab-background-color-secondary); + + &:dir(rtl) { + background-position-x: left; + } + } + + .switch { + position: relative; + display: inline-block; + width: 26px; + height: 16px; + grid-column: 2; + margin-top: 2px; + } + + .switch input { + opacity: 0; + width: 0; + height: 0; + } + + .slider { + position: absolute; + inset: 0; + transition: transform 250ms; + border-radius: 13px; + border: $customize-menu-border-tint; + background-color: var(--newtab-element-secondary-color); + + &::before { + position: absolute; + content: ''; + height: 8px; + width: 8px; + inset-inline-start: 3px; + bottom: 3px; + background-color: var(--newtab-primary-element-text-color); + transition: transform 250ms; + border-radius: 50%; + outline: $customize-menu-border-tint; + } + } + + .switch input:focus-visible + .slider { + outline: 0; + box-shadow: $shadow-focus; + } + + .switch input:not(:checked):focus-visible + .slider { + border: 1px solid var(--newtab-primary-action-background); + } + + input:checked + .slider { + background-color: var(--newtab-primary-action-background); + } + + input:checked + .slider::before { + transform: translateX(10px); + } + + input:checked + .slider:dir(rtl)::before { + transform: translateX(-10px); + } + + .more-info-top-wrapper, + .more-info-pocket-wrapper { + margin-inline-start: -2px; + overflow: hidden; + + .more-information { + padding-top: 12px; + position: relative; + transition: margin-top 250ms $customize-menu-expand-bezier; + } + } + + .more-info-top-wrapper { + .check-wrapper { + margin-top: 10px; + } + } + } + + .divider { + border-top: 1px var(--newtab-border-color) solid; + margin: 0 -16px; + } + + .external-link { + font-size: 14px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 4px; + -moz-context-properties: fill; + fill: var(--newtab-text-primary-color); + background: url('chrome://global/skin/icons/settings.svg') left no-repeat; + background-size: 16px; + padding-inline-start: 21px; + margin-bottom: 20px; + text-decoration: underline; + + @media (forced-colors: active) { + padding: 8px 10px; + padding-inline-start: 21px; + } + + &:dir(rtl) { + background-position-x: right; + } + } + + .external-link:hover { + text-decoration: none; + } +} + +.home-section .section .sponsored-checkbox:focus-visible, +.selector:focus-visible, +.external-link:focus-visible, +.close-button:focus-visible { + border: 1px solid var(--newtab-primary-action-background); + outline: 0; + box-shadow: $shadow-focus; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx new file mode 100644 index 0000000000..9acfef211b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -0,0 +1,384 @@ +/* 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 { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { CollectionCardGrid } from "content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { connect } from "react-redux"; +import { DSMessage } from "content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage"; +import { DSPrivacyModal } from "content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal"; +import { DSSignup } from "content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup"; +import { DSTextPromo } from "content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo"; +import { Highlights } from "content-src/components/DiscoveryStreamComponents/Highlights/Highlights"; +import { HorizontalRule } from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule"; +import { Navigation } from "content-src/components/DiscoveryStreamComponents/Navigation/Navigation"; +import { PrivacyLink } from "content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink"; +import React from "react"; +import { SectionTitle } from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle"; +import { selectLayoutRender } from "content-src/lib/selectLayoutRender"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const ALLOWED_CSS_URL_PREFIXES = [ + "chrome://", + "resource://", + "https://img-getpocket.cdn.mozilla.net/", +]; +const DUMMY_CSS_SELECTOR = "DUMMY#CSS.SELECTOR"; + +/** + * Validate a CSS declaration. The values are assumed to be normalized by CSSOM. + */ +export function isAllowedCSS(property, value) { + // Bug 1454823: INTERNAL properties, e.g., -moz-context-properties, are + // exposed but their values aren't resulting in getting nothing. Fortunately, + // we don't care about validating the values of the current set of properties. + if (value === undefined) { + return true; + } + + // Make sure all urls are of the allowed protocols/prefixes + const urls = value.match(/url\("[^"]+"\)/g); + return ( + !urls || + urls.every(url => + ALLOWED_CSS_URL_PREFIXES.some(prefix => url.slice(5).startsWith(prefix)) + ) + ); +} + +export class _DiscoveryStreamBase extends React.PureComponent { + constructor(props) { + super(props); + this.onStyleMount = this.onStyleMount.bind(this); + } + + onStyleMount(style) { + // Unmounting style gets rid of old styles, so nothing else to do + if (!style) { + return; + } + + const { sheet } = style; + const styles = JSON.parse(style.dataset.styles); + styles.forEach((row, rowIndex) => { + row.forEach((component, componentIndex) => { + // Nothing to do without optional styles overrides + if (!component) { + return; + } + + Object.entries(component).forEach(([selectors, declarations]) => { + // Start with a dummy rule to validate declarations and selectors + sheet.insertRule(`${DUMMY_CSS_SELECTOR} {}`); + const [rule] = sheet.cssRules; + + // Validate declarations and remove any offenders. CSSOM silently + // discards invalid entries, so here we apply extra restrictions. + rule.style = declarations; + [...rule.style].forEach(property => { + const value = rule.style[property]; + if (!isAllowedCSS(property, value)) { + console.error(`Bad CSS declaration ${property}: ${value}`); + rule.style.removeProperty(property); + } + }); + + // Set the actual desired selectors scoped to the component + const prefix = `.ds-layout > .ds-column:nth-child(${ + rowIndex + 1 + }) .ds-column-grid > :nth-child(${componentIndex + 1})`; + // NB: Splitting on "," doesn't work with strings with commas, but + // we're okay with not supporting those selectors + rule.selectorText = selectors + .split(",") + .map( + selector => + prefix + + // Assume :pseudo-classes are for component instead of descendant + (selector[0] === ":" ? "" : " ") + + selector + ) + .join(","); + + // CSSOM silently ignores bad selectors, so we'll be noisy instead + if (rule.selectorText === DUMMY_CSS_SELECTOR) { + console.error(`Bad CSS selector ${selectors}`); + } + }); + }); + }); + } + + renderComponent(component, embedWidth) { + switch (component.type) { + case "Highlights": + return <Highlights />; + case "TopSites": + return ( + <div className="ds-top-sites"> + <TopSites isFixed={true} title={component.header?.title} /> + </div> + ); + case "TextPromo": + return ( + <DSTextPromo + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Signup": + return ( + <DSSignup + dispatch={this.props.dispatch} + type={component.type} + data={component.data} + /> + ); + case "Message": + return ( + <DSMessage + title={component.header && component.header.title} + subtitle={component.header && component.header.subtitle} + link_text={component.header && component.header.link_text} + link_url={component.header && component.header.link_url} + icon={component.header && component.header.icon} + essentialReadsHeader={component.essentialReadsHeader} + editorsPicksHeader={component.editorsPicksHeader} + /> + ); + case "SectionTitle": + return <SectionTitle header={component.header} />; + case "Navigation": + return ( + <Navigation + dispatch={this.props.dispatch} + links={component.properties.links} + extraLinks={component.properties.extraLinks} + alignment={component.properties.alignment} + explore_topics={component.properties.explore_topics} + header={component.header} + locale={this.props.App.locale} + newFooterSection={component.newFooterSection} + privacyNoticeURL={component.properties.privacyNoticeURL} + /> + ); + case "CollectionCardGrid": + const { DiscoveryStream } = this.props; + return ( + <CollectionCardGrid + data={component.data} + feed={component.feed} + spocs={DiscoveryStream.spocs} + placement={component.placement} + type={component.type} + items={component.properties.items} + dismissible={this.props.DiscoveryStream.isCollectionDismissible} + dispatch={this.props.dispatch} + /> + ); + case "CardGrid": + return ( + <CardGrid + title={component.header && component.header.title} + data={component.data} + feed={component.feed} + widgets={component.widgets} + type={component.type} + dispatch={this.props.dispatch} + items={component.properties.items} + hybridLayout={component.properties.hybridLayout} + hideCardBackground={component.properties.hideCardBackground} + fourCardLayout={component.properties.fourCardLayout} + compactGrid={component.properties.compactGrid} + essentialReadsHeader={component.properties.essentialReadsHeader} + onboardingExperience={component.properties.onboardingExperience} + editorsPicksHeader={component.properties.editorsPicksHeader} + recentSavesEnabled={this.props.DiscoveryStream.recentSavesEnabled} + hideDescriptions={this.props.DiscoveryStream.hideDescriptions} + /> + ); + case "HorizontalRule": + return <HorizontalRule />; + case "PrivacyLink": + return <PrivacyLink properties={component.properties} />; + default: + return <div>{component.type}</div>; + } + } + + renderStyles(styles) { + // Use json string as both the key and styles to render so React knows when + // to unmount and mount a new instance for new styles. + const json = JSON.stringify(styles); + return <style key={json} data-styles={json} ref={this.onStyleMount} />; + } + + render() { + const { locale } = this.props; + // Select layout render data by adding spocs and position to recommendations + const { layoutRender } = selectLayoutRender({ + state: this.props.DiscoveryStream, + prefs: this.props.Prefs.values, + locale, + }); + const { config } = this.props.DiscoveryStream; + + // Allow rendering without extracting special components + if (!config.collapsible) { + return this.renderLayout(layoutRender); + } + + // Find the first component of a type and remove it from layout + const extractComponent = type => { + for (const [rowIndex, row] of Object.entries(layoutRender)) { + for (const [index, component] of Object.entries(row.components)) { + if (component.type === type) { + // Remove the row if it was the only component or the single item + if (row.components.length === 1) { + layoutRender.splice(rowIndex, 1); + } else { + row.components.splice(index, 1); + } + return component; + } + } + } + return null; + }; + + // Get "topstories" Section state for default values + const topStories = this.props.Sections.find(s => s.id === "topstories"); + + if (!topStories) { + return null; + } + + // Extract TopSites to render before the rest and Message to use for header + const topSites = extractComponent("TopSites"); + const sponsoredCollection = extractComponent("CollectionCardGrid"); + const message = extractComponent("Message") || { + header: { + link_text: topStories.learnMore.link.message, + link_url: topStories.learnMore.link.href, + title: topStories.title, + }, + }; + + const privacyLinkComponent = extractComponent("PrivacyLink"); + let learnMore = { + link: { + href: message.header.link_url, + message: message.header.link_text, + }, + }; + let sectionTitle = message.header.title; + let subTitle = ""; + + // If we're in one of these experiments, override the default message. + // For now this is English only. + if (message.essentialReadsHeader || message.editorsPicksHeader) { + learnMore = null; + subTitle = "Recommended By Pocket"; + if (message.essentialReadsHeader) { + sectionTitle = "Today’s Essential Reads"; + } else if (message.editorsPicksHeader) { + sectionTitle = "Editor’s Picks"; + } + } + + // Render a DS-style TopSites then the rest if any in a collapsible section + return ( + <React.Fragment> + {this.props.DiscoveryStream.isPrivacyInfoModalVisible && ( + <DSPrivacyModal dispatch={this.props.dispatch} /> + )} + {topSites && + this.renderLayout([ + { + width: 12, + components: [topSites], + }, + ])} + {sponsoredCollection && + this.renderLayout([ + { + width: 12, + components: [sponsoredCollection], + }, + ])} + {!!layoutRender.length && ( + <CollapsibleSection + className="ds-layout" + collapsed={topStories.pref.collapsed} + dispatch={this.props.dispatch} + id={topStories.id} + isFixed={true} + learnMore={learnMore} + privacyNoticeURL={topStories.privacyNoticeURL} + showPrefName={topStories.pref.feed} + title={sectionTitle} + subTitle={subTitle} + eventSource="CARDGRID" + > + {this.renderLayout(layoutRender)} + </CollapsibleSection> + )} + {this.renderLayout([ + { + width: 12, + components: [{ type: "Highlights" }], + }, + ])} + {privacyLinkComponent && + this.renderLayout([ + { + width: 12, + components: [privacyLinkComponent], + }, + ])} + </React.Fragment> + ); + } + + renderLayout(layoutRender) { + const styles = []; + return ( + <div className="discovery-stream ds-layout"> + {layoutRender.map((row, rowIndex) => ( + <div + key={`row-${rowIndex}`} + className={`ds-column ds-column-${row.width}`} + > + <div className="ds-column-grid"> + {row.components.map((component, componentIndex) => { + if (!component) { + return null; + } + styles[rowIndex] = [ + ...(styles[rowIndex] || []), + component.styles, + ]; + return ( + <div key={`component-${componentIndex}`}> + {this.renderComponent(component, row.width)} + </div> + ); + })} + </div> + </div> + ))} + {this.renderStyles(styles)} + </div> + ); + } +} + +export const DiscoveryStreamBase = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, + Prefs: state.Prefs, + Sections: state.Sections, + document: global.document, + App: state.App, +}))(_DiscoveryStreamBase); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss new file mode 100644 index 0000000000..015cb57a8b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamBase/_DiscoveryStreamBase.scss @@ -0,0 +1,67 @@ +$ds-width: 936px; + +.discovery-stream.ds-layout { + $columns: 12; + --gridColumnGap: 48px; + --gridRowGap: 24px; + + grid-template-columns: repeat($columns, 1fr); + grid-column-gap: var(--gridColumnGap); + grid-row-gap: var(--gridRowGap); + margin: 0 auto; + + @while $columns > 0 { + .ds-column-#{$columns} { + grid-column-start: auto; + grid-column-end: span $columns; + } + + $columns: $columns - 1; + } + + .ds-column-grid { + display: grid; + grid-row-gap: var(--gridRowGap); + + // We want to completely hide components with no content, + // otherwise, it creates grid-row-gap gaps around nothing. + > div:empty { + display: none; + } + } +} + +.ds-header { + margin: 8px 0; + + .ds-context { + font-weight: 400; + } +} + +.ds-header, +.ds-layout .section-title span { + color: var(--newtab-text-primary-color); + font-size: $section-title-font-size; + font-weight: 600; + line-height: 20px; + + .icon { + fill: var(--newtab-text-secondary-color); + } +} + +.collapsible-section.ds-layout { + margin: auto; + + .section-top-bar { + .learn-more-link a { + color: var(--newtab-primary-action-background); + font-weight: 500; + + &:is(:focus, :hover) { + text-decoration: none; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx new file mode 100644 index 0000000000..09dc657cbb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -0,0 +1,536 @@ +/* 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 { DSCard, PlaceholderDSCard } from "../DSCard/DSCard.jsx"; +import { DSEmptyState } from "../DSEmptyState/DSEmptyState.jsx"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { TopicsWidget } from "../TopicsWidget/TopicsWidget.jsx"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { connect, useSelector } from "react-redux"; +const PREF_ONBOARDING_EXPERIENCE_DISMISSED = + "discoverystream.onboardingExperience.dismissed"; +const INTERSECTION_RATIO = 0.5; +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const WIDGET_IDS = { + TOPICS: 1, +}; + +export function DSSubHeader({ children }) { + return ( + <div className="section-top-bar ds-sub-header"> + <h3 className="section-title-container">{children}</h3> + </div> + ); +} + +export function OnboardingExperience({ + children, + dispatch, + windowObj = global, +}) { + const [dismissed, setDismissed] = useState(false); + const [maxHeight, setMaxHeight] = useState(null); + const heightElement = useRef(null); + + const onDismissClick = useCallback(() => { + // We update this as state and redux. + // The state update is for this newtab, + // and the redux update is for other tabs, offscreen tabs, and future tabs. + // We need the state update for this tab to support the transition. + setDismissed(true); + dispatch(ac.SetPref(PREF_ONBOARDING_EXPERIENCE_DISMISSED, true)); + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "BLOCK", + source: "POCKET_ONBOARDING", + }) + ); + }, [dispatch]); + + useEffect(() => { + const resizeObserver = new windowObj.ResizeObserver(() => { + if (heightElement.current) { + setMaxHeight(heightElement.current.offsetHeight); + } + }); + + const options = { threshold: INTERSECTION_RATIO }; + const intersectionObserver = new windowObj.IntersectionObserver(entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "IMPRESSION", + source: "POCKET_ONBOARDING", + }) + ); + // Once we have observed an impression, we can stop for this instance of newtab. + intersectionObserver.unobserve(heightElement.current); + } + }, options); + + const onVisibilityChange = () => { + intersectionObserver.observe(heightElement.current); + windowObj.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + }; + + if (heightElement.current) { + resizeObserver.observe(heightElement.current); + // Check visibility or setup a visibility event to make + // sure we don't fire this for off screen pre loaded tabs. + if (windowObj.document.visibilityState === VISIBLE) { + intersectionObserver.observe(heightElement.current); + } else { + windowObj.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + } + setMaxHeight(heightElement.current.offsetHeight); + } + + // Return unmount callback to clean up observers. + return () => { + resizeObserver?.disconnect(); + intersectionObserver?.disconnect(); + windowObj.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + onVisibilityChange + ); + }; + }, [dispatch, windowObj]); + + const style = {}; + if (dismissed) { + style.maxHeight = "0"; + style.opacity = "0"; + style.transition = "max-height 0.26s ease, opacity 0.26s ease"; + } else if (maxHeight) { + style.maxHeight = `${maxHeight}px`; + } + + return ( + <div style={style}> + <div className="ds-onboarding-ref" ref={heightElement}> + <div className="ds-onboarding-container"> + <DSDismiss + onDismissClick={onDismissClick} + extraClasses={`ds-onboarding`} + > + <div> + <header> + <span className="icon icon-pocket" /> + <span data-l10n-id="newtab-pocket-onboarding-discover" /> + </header> + <p data-l10n-id="newtab-pocket-onboarding-cta" /> + </div> + <div className="ds-onboarding-graphic" /> + </DSDismiss> + </div> + </div> + </div> + ); +} + +export function IntersectionObserver({ + children, + windowObj = window, + onIntersecting, +}) { + const intersectionElement = useRef(null); + + useEffect(() => { + let observer; + if (!observer && onIntersecting && intersectionElement.current) { + observer = new windowObj.IntersectionObserver(entries => { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + // Stop observing since element has been seen + if (observer && intersectionElement.current) { + observer.unobserve(intersectionElement.current); + } + + onIntersecting(); + } + }); + observer.observe(intersectionElement.current); + } + // Cleanup + return () => observer?.disconnect(); + }, [windowObj, onIntersecting]); + + return <div ref={intersectionElement}>{children}</div>; +} + +export function RecentSavesContainer({ + gridClassName = "", + dispatch, + windowObj = window, + items = 3, + source = "CARDGRID_RECENT_SAVES", +}) { + const { + recentSavesData, + isUserLoggedIn, + experimentData: { utmCampaign, utmContent, utmSource }, + } = useSelector(state => state.DiscoveryStream); + + const [visible, setVisible] = useState(false); + const onIntersecting = useCallback(() => setVisible(true), []); + + useEffect(() => { + if (visible) { + dispatch( + ac.AlsoToMain({ + type: at.DISCOVERY_STREAM_POCKET_STATE_INIT, + }) + ); + } + }, [visible, dispatch]); + + // The user has not yet scrolled to this section, + // so wait before potentially requesting Pocket data. + if (!visible) { + return ( + <IntersectionObserver + windowObj={windowObj} + onIntersecting={onIntersecting} + /> + ); + } + + // Intersection observer has finished, but we're not yet logged in. + if (visible && !isUserLoggedIn) { + return null; + } + + let queryParams = `?utm_source=${utmSource}`; + // We really only need to add these params to urls we own. + if (utmCampaign && utmContent) { + queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`; + } + + function renderCard(rec, index) { + const url = new URL(rec.url); + const urlSearchParams = new URLSearchParams(queryParams); + if (rec?.id && !url.href.match(/getpocket\.com\/read/)) { + url.href = `https://getpocket.com/read/${rec.id}`; + } + + for (let [key, val] of urlSearchParams.entries()) { + url.searchParams.set(key, val); + } + + return ( + <DSCard + key={`dscard-${rec?.id || index}`} + id={rec.id} + pos={index} + type={source} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + excerpt={rec.excerpt} + url={url.href} + source={rec.domain} + isRecentSave={true} + dispatch={dispatch} + /> + ); + } + + function onMyListClicked() { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: `${source}_VIEW_LIST`, + }) + ); + } + + const recentSavesCards = []; + // We fill the cards with a for loop over an inline map because + // we want empty placeholders if there are not enough cards. + for (let index = 0; index < items; index++) { + const recentSave = recentSavesData[index]; + if (!recentSave) { + recentSavesCards.push(<PlaceholderDSCard key={`dscard-${index}`} />); + } else { + recentSavesCards.push( + renderCard( + { + id: recentSave.id, + image_src: recentSave.top_image_url, + raw_image_src: recentSave.top_image_url, + word_count: recentSave.word_count, + time_to_read: recentSave.time_to_read, + title: recentSave.resolved_title || recentSave.given_title, + url: recentSave.resolved_url || recentSave.given_url, + domain: recentSave.domain_metadata?.name, + excerpt: recentSave.excerpt, + }, + index + ) + ); + } + } + + // We are visible and logged in. + return ( + <> + <DSSubHeader> + <span className="section-title"> + <FluentOrText message="Recently Saved to your List" /> + </span> + <SafeAnchor + onLinkClick={onMyListClicked} + className="section-sub-link" + url={`https://getpocket.com/a${queryParams}`} + > + <FluentOrText message="View My List" /> + </SafeAnchor> + </DSSubHeader> + <div className={`ds-card-grid-recent-saves ${gridClassName}`}> + {recentSavesCards} + </div> + </> + ); +} + +export class _CardGrid extends React.PureComponent { + renderCards() { + const prefs = this.props.Prefs.values; + const { + items, + hybridLayout, + hideCardBackground, + fourCardLayout, + compactGrid, + essentialReadsHeader, + editorsPicksHeader, + onboardingExperience, + widgets, + recentSavesEnabled, + hideDescriptions, + DiscoveryStream, + } = this.props; + const { saveToPocketCard } = DiscoveryStream; + const showRecentSaves = prefs.showRecentSaves && recentSavesEnabled; + const isOnboardingExperienceDismissed = + prefs[PREF_ONBOARDING_EXPERIENCE_DISMISSED]; + + const recs = this.props.data.recommendations.slice(0, items); + const cards = []; + let essentialReadsCards = []; + let editorsPicksCards = []; + + for (let index = 0; index < items; index++) { + const rec = recs[index]; + cards.push( + !rec || rec.placeholder ? ( + <PlaceholderDSCard key={`dscard-${index}`} /> + ) : ( + <DSCard + key={`dscard-${rec.id}`} + pos={rec.pos} + flightId={rec.flight_id} + image_src={rec.image_src} + raw_image_src={rec.raw_image_src} + word_count={rec.word_count} + time_to_read={rec.time_to_read} + title={rec.title} + excerpt={rec.excerpt} + url={rec.url} + id={rec.id} + shim={rec.shim} + type={this.props.type} + context={rec.context} + sponsor={rec.sponsor} + sponsored_by_override={rec.sponsored_by_override} + dispatch={this.props.dispatch} + source={rec.domain} + pocket_id={rec.pocket_id} + context_type={rec.context_type} + bookmarkGuid={rec.bookmarkGuid} + is_collection={this.props.is_collection} + saveToPocketCard={saveToPocketCard} + /> + ) + ); + } + + if (widgets?.positions?.length && widgets?.data?.length) { + let positionIndex = 0; + const source = "CARDGRID_WIDGET"; + + for (const widget of widgets.data) { + let widgetComponent = null; + const position = widgets.positions[positionIndex]; + + // Stop if we run out of positions to place widgets. + if (!position) { + break; + } + + switch (widget?.type) { + case "TopicsWidget": + widgetComponent = ( + <TopicsWidget + position={position.index} + dispatch={this.props.dispatch} + source={source} + id={WIDGET_IDS.TOPICS} + /> + ); + break; + } + + if (widgetComponent) { + // We found a widget, so up the position for next try. + positionIndex++; + // We replace an existing card with the widget. + cards.splice(position.index, 1, widgetComponent); + } + } + } + + let moreRecsHeader = ""; + // For now this is English only. + if (showRecentSaves || (essentialReadsHeader && editorsPicksHeader)) { + let spliceAt = 6; + // For 4 card row layouts, second row is 8 cards, and regular it is 6 cards. + if (fourCardLayout) { + spliceAt = 8; + } + // If we have a custom header, ensure the more recs section also has a header. + moreRecsHeader = "More Recommendations"; + // Put the first 2 rows into essentialReadsCards. + essentialReadsCards = [...cards.splice(0, spliceAt)]; + // Put the rest into editorsPicksCards. + if (essentialReadsHeader && editorsPicksHeader) { + editorsPicksCards = [...cards.splice(0, cards.length)]; + } + } + + const hideCardBackgroundClass = hideCardBackground + ? `ds-card-grid-hide-background` + : ``; + const fourCardLayoutClass = fourCardLayout + ? `ds-card-grid-four-card-variant` + : ``; + const hideDescriptionsClassName = !hideDescriptions + ? `ds-card-grid-include-descriptions` + : ``; + const compactGridClassName = compactGrid ? `ds-card-grid-compact` : ``; + const hybridLayoutClassName = hybridLayout + ? `ds-card-grid-hybrid-layout` + : ``; + + const gridClassName = `ds-card-grid ${hybridLayoutClassName} ${hideCardBackgroundClass} ${fourCardLayoutClass} ${hideDescriptionsClassName} ${compactGridClassName}`; + + return ( + <> + {!isOnboardingExperienceDismissed && onboardingExperience && ( + <OnboardingExperience dispatch={this.props.dispatch} /> + )} + {essentialReadsCards?.length > 0 && ( + <div className={gridClassName}>{essentialReadsCards}</div> + )} + {showRecentSaves && ( + <RecentSavesContainer + gridClassName={gridClassName} + dispatch={this.props.dispatch} + /> + )} + {editorsPicksCards?.length > 0 && ( + <> + <DSSubHeader> + <span className="section-title"> + <FluentOrText message="Editor’s Picks" /> + </span> + </DSSubHeader> + <div className={gridClassName}>{editorsPicksCards}</div> + </> + )} + {cards?.length > 0 && ( + <> + {moreRecsHeader && ( + <DSSubHeader> + <span className="section-title"> + <FluentOrText message={moreRecsHeader} /> + </span> + </DSSubHeader> + )} + <div className={gridClassName}>{cards}</div> + </> + )} + </> + ); + } + + render() { + const { data } = this.props; + + // Handle a render before feed has been fetched by displaying nothing + if (!data) { + return null; + } + + // Handle the case where a user has dismissed all recommendations + const isEmpty = data.recommendations.length === 0; + + return ( + <div> + {this.props.title && ( + <div className="ds-header"> + <div className="title">{this.props.title}</div> + {this.props.context && ( + <FluentOrText message={this.props.context}> + <div className="ds-context" /> + </FluentOrText> + )} + </div> + )} + {isEmpty ? ( + <div className="ds-card-grid empty"> + <DSEmptyState + status={data.status} + dispatch={this.props.dispatch} + feed={this.props.feed} + /> + </div> + ) : ( + this.renderCards() + )} + </div> + ); + } +} + +_CardGrid.defaultProps = { + items: 4, // Number of stories to display +}; + +export const CardGrid = connect(state => ({ + Prefs: state.Prefs, + DiscoveryStream: state.DiscoveryStream, +}))(_CardGrid); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss new file mode 100644 index 0000000000..fb838f4628 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CardGrid/_CardGrid.scss @@ -0,0 +1,355 @@ +$col4-header-line-height: 20; +$col4-header-font-size: 14; + +.ds-onboarding-container, +.ds-card-grid .ds-card { + @include dark-theme-only { + background: none; + } + + background: $white; + border-radius: 4px; + + &:not(.placeholder) { + @include dark-theme-only { + background: var(--newtab-background-color-secondary); + } + + border-radius: $border-radius-new; + box-shadow: $shadow-card; + + .img-wrapper .img { + img, + .placeholder-image { + border-radius: $border-radius-new $border-radius-new 0 0; + } + } + } +} + +.ds-onboarding-container { + padding-inline-start: 16px; + padding-inline-end: 16px; + + @media (min-width: $break-point-medium) { + padding-inline-end: 48px; + } + + @media (min-width: $break-point-large) { + padding-inline-end: 56px; + } + + margin-bottom: 24px; + // This is to position the dismiss button to the right most of this element. + position: relative; + + .ds-onboarding { + position: static; + display: flex; + + .ds-dismiss-button { + inset-inline-end: 8px; + top: 8px; + } + } + + header { + @include dark-theme-only { + color: var(--newtab-background-color-primary); + } + + display: flex; + margin: 32px 0 8px; + + @media (min-width: $break-point-medium) { + margin: 16px 0 8px; + display: block; + height: 24px; + } + + font-size: 17px; + line-height: 23.8px; + font-weight: 600; + color: $pocket-icon-fill; + } + + p { + margin: 8px 0 16px; + font-size: 13px; + line-height: 19.5px; + } + + .icon-pocket { + @include dark-theme-only { + @media (forced-colors: active) { + fill: CurrentColor; + } + fill: var(--newtab-text-primary-color); + } + @media (forced-colors: active) { + fill: CurrentColor; + } + + fill: $pocket-icon-fill; + margin-top: 3px; + margin-inline-end: 8px; + height: 22px; + width: 22px; + background-image: url('chrome://global/skin/icons/pocket.svg'); + + @media (min-width: $break-point-medium) { + margin-top: -5px; + margin-inline-start: -2px; + margin-inline-end: 15px; + height: 30px; + width: 30px; + } + + background-size: contain; + } + + .ds-onboarding-graphic { + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding.avif'); + + @media (min-resolution: 2x) { + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-onboarding@2x.avif'); + } + + border-radius: 8px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + height: 120px; + width: 200px; + margin-top: 16px; + margin-bottom: 16px; + margin-inline-start: 54px; + flex-shrink: 0; + display: none; + + @media (min-width: $break-point-large) { + display: block; + } + } +} + +.ds-card-grid { + display: grid; + grid-gap: 24px; + + &.ds-card-grid-compact { + grid-gap: 20px; + } + + &.ds-card-grid-recent-saves { + .ds-card { + // Hide the second row orphan on narrow screens. + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + &:last-child:nth-child(2n - 1) { + display: none; + } + } + } + } + + .ds-card-link:focus { + @include ds-focus; + + transition: none; + border-radius: $border-radius-new; + } + + // "2/3 width layout" + .ds-column-5 &, + .ds-column-6 &, + .ds-column-7 &, + .ds-column-8 & { + grid-template-columns: repeat(2, 1fr); + } + + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + .title { + font-size: 17px; + line-height: 24px; + } + + .excerpt { + @include limit-visible-lines(3, 24, 15); + } + } + + &.empty { + grid-template-columns: auto; + } + + @mixin small-cards { + .ds-card { + &.placeholder { + min-height: 247px; + } + + .meta { + .story-footer { + margin-top: 8px; + } + + .source, + .story-sponsored-label, + .status-message .story-context-label { + color: var(--newtab-text-secondary-color); + -webkit-line-clamp: 2; + } + + .source, + .story-sponsored-label { + font-size: 13px; + } + + .status-message .story-context-label { + font-size: 11.7px; + } + + .story-badge-icon { + margin-inline-end: 2px; + margin-bottom: 2px; + height: 14px; + width: 14px; + background-size: 14px; + } + + .title { + font-size: 14px; + line-height: 20px; + } + + .info-wrap { + flex-grow: 0; + } + } + } + } + + &.ds-card-grid-four-card-variant { + // "Full width layout" + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + @media (min-width: $break-point-widest) { + grid-template-columns: repeat(4, 1fr); + } + } + + @include small-cards; + } + + &.ds-card-grid-hybrid-layout { + .ds-column-9 &, + .ds-column-10 &, + .ds-column-11 &, + .ds-column-12 & { + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(3, 1fr); + } + + @media (max-height: 1065px) { + .excerpt { + display: none; + } + } + + @media (max-width: $break-point-widest) { + @include small-cards; + } + + @media (min-width: $break-point-widest) and (max-height: 964px) { + @include small-cards; + + grid-template-columns: repeat(4, 1fr); + } + } + } +} + +.outer-wrapper .ds-card-grid.ds-card-grid-hide-background .ds-card, +.outer-wrapper.newtab-experience .ds-card-grid.ds-card-grid-hide-background .ds-card { + &:not(.placeholder) { + box-shadow: none; + background: none; + + .ds-card-link:focus { + box-shadow: none; + + .img-wrapper .img img { + @include ds-focus; + } + } + + .img-wrapper .img img { + border-radius: 8px; + box-shadow: $shadow-card; + } + + .meta { + padding: 12px 0 0; + } + } +} + +.ds-layout { + .ds-sub-header { + margin-top: 24px; + + .section-title-container { + flex-direction: row; + align-items: baseline; + justify-content: space-between; + display: flex; + } + + .section-sub-link { + color: var(--newtab-primary-action-background); + font-size: 14px; + line-height: 16px; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + + &:active { + color: var(--newtab-primary-element-active-color); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx new file mode 100644 index 0000000000..d089a5c8ab --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/CollectionCardGrid.jsx @@ -0,0 +1,139 @@ +/* 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 { CardGrid } from "content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid"; +import { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; + +export class CollectionCardGrid extends React.PureComponent { + constructor(props) { + super(props); + this.onDismissClick = this.onDismissClick.bind(this); + this.state = { + dismissed: false, + }; + } + + onDismissClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + this.setState({ + dismissed: true, + }); + const pos = 0; + const source = this.props.type.toUpperCase(); + // Grab the available items in the array to dismiss. + // This fires a ping for all items available, even if below the fold. + const spocsData = data.spocs.map(item => ({ + url: item.url, + guid: item.id, + shim: item.shim, + flight_id: item.flightId, + })); + + const blockUrlOption = LinkMenuOptions.BlockUrls(spocsData, pos, source); + const { action, impression, userEvent } = blockUrlOption; + this.props.dispatch(action); + + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: userEvent, + source, + action_position: pos, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data, dismissible, pocket_button_enabled } = this.props; + if ( + this.state.dismissed || + !data || + !data.spocs || + !data.spocs[0] || + // We only display complete collections. + data.spocs.length < 3 + ) { + return null; + } + const { spocs, placement, feed } = this.props; + // spocs.data is spocs state data, and not an array of spocs. + const { title, context, sponsored_by_override, sponsor } = + spocs.data[placement.name] || {}; + // Just in case of bad data, don't display a broken collection. + if (!title) { + return null; + } + + let sponsoredByMessage = ""; + + // If override is not false or an empty string. + if (sponsored_by_override || sponsored_by_override === "") { + // We specifically want to display nothing if the server returns an empty string. + // So the server can turn off the label. + // This is to support the use cases where the sponsored context is displayed elsewhere. + sponsoredByMessage = sponsored_by_override; + } else if (sponsor) { + sponsoredByMessage = { + id: `newtab-label-sponsored-by`, + values: { sponsor }, + }; + } else if (context) { + sponsoredByMessage = context; + } + + // Generally a card grid displays recs with spocs already injected. + // Normally it doesn't care which rec is a spoc and which isn't, + // it just displays content in a grid. + // For collections, we're only displaying a list of spocs. + // We don't need to tell the card grid that our list of cards are spocs, + // it shouldn't need to care. So we just pass our spocs along as recs. + // Think of it as injecting all rec positions with spocs. + // Consider maybe making recommendations in CardGrid use a more generic name. + const recsData = { + recommendations: data.spocs, + }; + + // All cards inside of a collection card grid have a slightly different type. + // For the case of interactions to the card grid, we use the type "COLLECTIONCARDGRID". + // Example, you dismiss the whole collection, we use the type "COLLECTIONCARDGRID". + // For interactions inside the card grid, example, you dismiss a single card in the collection, + // we use the type "COLLECTIONCARDGRID_CARD". + const type = `${this.props.type}_card`; + + const collectionGrid = ( + <div className="ds-collection-card-grid"> + <CardGrid + pocket_button_enabled={pocket_button_enabled} + title={title} + context={sponsoredByMessage} + data={recsData} + feed={feed} + type={type} + is_collection={true} + dispatch={this.props.dispatch} + items={this.props.items} + /> + </div> + ); + + if (dismissible) { + return ( + <DSDismiss + onDismissClick={this.onDismissClick} + extraClasses={`ds-dismiss-ds-collection`} + > + {collectionGrid} + </DSDismiss> + ); + } + return collectionGrid; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss new file mode 100644 index 0000000000..f4778f3b95 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/CollectionCardGrid/_CollectionCardGrid.scss @@ -0,0 +1,38 @@ +.ds-dismiss.ds-dismiss-ds-collection { + .ds-dismiss-button { + margin: 15px 0 0; + inset-inline-end: 25px; + } + + &.hovering { + background: var(--newtab-element-hover-color); + } +} + +.ds-collection-card-grid { + padding: 10px 25px 25px; + margin: 0 0 20px; + + .story-footer { + display: none; + } + + .ds-header { + padding: 0 40px 0 0; + margin-bottom: 12px; + + .title { + color: var(--newtab-text-primary-color); + font-weight: 600; + font-size: 17px; + line-height: 24px; + } + + .ds-context { + color: var(--newtab-text-secondary-color); + font-weight: normal; + font-size: 13px; + line-height: 24px; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx new file mode 100644 index 0000000000..561da8e2fa --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/DSCard.jsx @@ -0,0 +1,491 @@ +/* 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 { DSImage } from "../DSImage/DSImage.jsx"; +import { DSLinkMenu } from "../DSLinkMenu/DSLinkMenu"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { + DSContextFooter, + SponsorLabel, + DSMessageFooter, +} from "../DSContextFooter/DSContextFooter.jsx"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import { connect } from "react-redux"; + +const READING_WPM = 220; + +/** + * READ TIME FROM WORD COUNT + * @param {int} wordCount number of words in an article + * @returns {int} number of words per minute in minutes + */ +export function readTimeFromWordCount(wordCount) { + if (!wordCount) return false; + return Math.ceil(parseInt(wordCount, 10) / READING_WPM); +} + +export const DSSource = ({ + source, + timeToRead, + newSponsoredLabel, + context, + sponsor, + sponsored_by_override, +}) => { + // First try to display sponsored label or time to read here. + if (newSponsoredLabel) { + // If we can display something for spocs, do so. + if (sponsored_by_override || sponsor || context) { + return ( + <SponsorLabel + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + newSponsoredLabel="new-sponsored-label" + /> + ); + } + } + + // If we are not a spoc, and can display a time to read value. + if (source && timeToRead) { + return ( + <p className="source clamp time-to-read"> + <FluentOrText + message={{ + id: `newtab-label-source-read-time`, + values: { source, timeToRead }, + }} + /> + </p> + ); + } + + // Otherwise display a default source. + return <p className="source clamp">{source}</p>; +}; + +export const DefaultMeta = ({ + source, + title, + excerpt, + timeToRead, + newSponsoredLabel, + context, + context_type, + sponsor, + sponsored_by_override, + saveToPocketCard, + isRecentSave, +}) => ( + <div className="meta"> + <div className="info-wrap"> + <DSSource + source={source} + timeToRead={timeToRead} + newSponsoredLabel={newSponsoredLabel} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + /> + <header title={title} className="title clamp"> + {title} + </header> + {excerpt && <p className="excerpt clamp">{excerpt}</p>} + </div> + {!newSponsoredLabel && ( + <DSContextFooter + context_type={context_type} + context={context} + sponsor={sponsor} + sponsored_by_override={sponsored_by_override} + /> + )} + {/* Sponsored label is normally in the way of any message. + newSponsoredLabel cards sponsored label is moved to just under the thumbnail, + so we can display both, so we specifically don't pass in context. */} + {newSponsoredLabel && ( + <DSMessageFooter + context_type={context_type} + context={null} + saveToPocketCard={saveToPocketCard} + /> + )} + </div> +); + +export class _DSCard extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + this.onSaveClick = this.onSaveClick.bind(this); + this.onMenuUpdate = this.onMenuUpdate.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + + this.setContextMenuButtonHostRef = element => { + this.contextMenuButtonHostElement = element; + }; + this.setPlaceholderRef = element => { + this.placeholderElement = element; + }; + + this.state = { + isSeen: false, + }; + + // If this is for the about:home startup cache, then we always want + // to render the DSCard, regardless of whether or not its been seen. + if (props.App.isForStartupCache) { + this.state.isSeen = true; + } + + // We want to choose the optimal thumbnail for the underlying DSImage, but + // want to do it in a performant way. The breakpoints used in the + // CSS of the page are, unfortuntely, not easy to retrieve without + // causing a style flush. To avoid that, we hardcode them here. + // + // The values chosen here were the dimensions of the card thumbnails as + // computed by getBoundingClientRect() for each type of viewport width + // across both high-density and normal-density displays. + this.dsImageSizes = [ + { + mediaMatcher: "(min-width: 1122px)", + width: 296, + height: 148, + }, + + { + mediaMatcher: "(min-width: 866px)", + width: 218, + height: 109, + }, + + { + mediaMatcher: "(max-width: 610px)", + width: 202, + height: 101, + }, + ]; + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: this.props.type.toUpperCase(), + action_position: this.props.pos, + value: { card_type: this.props.flightId ? "spoc" : "organic" }, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: this.props.type.toUpperCase(), + click: 0, + window_inner_width: this.props.windowObj.innerWidth, + window_inner_height: this.props.windowObj.innerHeight, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.click + ? { shim: this.props.shim.click } + : {}), + type: this.props.flightId ? "spoc" : "organic", + }, + ], + }) + ); + } + } + + onSaveClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { site: { url: this.props.url, title: this.props.title } }, + }) + ); + + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "SAVE_TO_POCKET", + source: "CARDGRID_HOVER", + action_position: this.props.pos, + value: { card_type: this.props.flightId ? "spoc" : "organic" }, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source: "CARDGRID_HOVER", + pocket: 0, + tiles: [ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.save + ? { shim: this.props.shim.save } + : {}), + }, + ], + }) + ); + } + } + + onMenuUpdate(showContextMenu) { + if (!showContextMenu) { + const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; + if (dsLinkMenuHostDiv) { + dsLinkMenuHostDiv.classList.remove("active", "last-item"); + } + } + } + + async onMenuShow() { + const dsLinkMenuHostDiv = this.contextMenuButtonHostElement; + if (dsLinkMenuHostDiv) { + // Force translation so we can be sure it's ready before measuring. + await this.props.windowObj.document.l10n.translateFragment( + dsLinkMenuHostDiv + ); + if (this.props.windowObj.scrollMaxX > 0) { + dsLinkMenuHostDiv.classList.add("last-item"); + } + dsLinkMenuHostDiv.classList.add("active"); + } + } + + onSeen(entries) { + if (this.state) { + const entry = entries.find(e => e.isIntersecting); + + if (entry) { + if (this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + + // Stop observing since element has been seen + this.setState({ + isSeen: true, + }); + } + } + } + + onIdleCallback() { + if (!this.state.isSeen) { + if (this.observer && this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + this.setState({ + isSeen: true, + }); + } + } + + componentDidMount() { + this.idleCallbackId = this.props.windowObj.requestIdleCallback( + this.onIdleCallback.bind(this) + ); + if (this.placeholderElement) { + this.observer = new IntersectionObserver(this.onSeen.bind(this)); + this.observer.observe(this.placeholderElement); + } + } + + componentWillUnmount() { + // Remove observer on unmount + if (this.observer && this.placeholderElement) { + this.observer.unobserve(this.placeholderElement); + } + if (this.idleCallbackId) { + this.props.windowObj.cancelIdleCallback(this.idleCallbackId); + } + } + + render() { + if (this.props.placeholder || !this.state.isSeen) { + return ( + <div className="ds-card placeholder" ref={this.setPlaceholderRef} /> + ); + } + + const { isRecentSave, DiscoveryStream, saveToPocketCard } = this.props; + let { source } = this.props; + if (!source) { + try { + source = new URL(this.props.url).hostname; + } catch (e) {} + } + + const { + pocketButtonEnabled, + hideDescriptions, + compactImages, + imageGradient, + newSponsoredLabel, + titleLines = 3, + descLines = 3, + readTime: displayReadTime, + } = DiscoveryStream; + + const excerpt = !hideDescriptions ? this.props.excerpt : ""; + + let timeToRead; + if (displayReadTime) { + timeToRead = + this.props.time_to_read || readTimeFromWordCount(this.props.word_count); + } + + const compactImagesClassName = compactImages ? `ds-card-compact-image` : ``; + const imageGradientClassName = imageGradient + ? `ds-card-image-gradient` + : ``; + const titleLinesName = `ds-card-title-lines-${titleLines}`; + const descLinesClassName = `ds-card-desc-lines-${descLines}`; + + let stpButton = () => { + return ( + <button className="card-stp-button" onClick={this.onSaveClick}> + {this.props.context_type === "pocket" ? ( + <> + <span className="story-badge-icon icon icon-pocket" /> + <span data-l10n-id="newtab-pocket-saved" /> + </> + ) : ( + <> + <span className="story-badge-icon icon icon-pocket-save" /> + <span data-l10n-id="newtab-pocket-save" /> + </> + )} + </button> + ); + }; + + return ( + <div + className={`ds-card ${compactImagesClassName} ${imageGradientClassName} ${titleLinesName} ${descLinesClassName}`} + ref={this.setContextMenuButtonHostRef} + > + <SafeAnchor + className="ds-card-link" + dispatch={this.props.dispatch} + onLinkClick={!this.props.placeholder ? this.onLinkClick : undefined} + url={this.props.url} + > + <div className="img-wrapper"> + <DSImage + extraClassNames="img" + source={this.props.image_src} + rawSource={this.props.raw_image_src} + sizes={this.dsImageSizes} + url={this.props.url} + title={this.props.title} + isRecentSave={isRecentSave} + /> + </div> + <DefaultMeta + source={source} + title={this.props.title} + excerpt={excerpt} + newSponsoredLabel={newSponsoredLabel} + timeToRead={timeToRead} + context={this.props.context} + context_type={this.props.context_type} + sponsor={this.props.sponsor} + sponsored_by_override={this.props.sponsored_by_override} + saveToPocketCard={saveToPocketCard} + /> + <ImpressionStats + flightId={this.props.flightId} + rows={[ + { + id: this.props.id, + pos: this.props.pos, + ...(this.props.shim && this.props.shim.impression + ? { shim: this.props.shim.impression } + : {}), + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </SafeAnchor> + {saveToPocketCard && ( + <div className="card-stp-button-hover-background"> + <div className="card-stp-button-position-wrapper"> + {!this.props.flightId && stpButton()} + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + saveToPocketCard={saveToPocketCard} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> + </div> + </div> + )} + {!saveToPocketCard && ( + <DSLinkMenu + id={this.props.id} + index={this.props.pos} + dispatch={this.props.dispatch} + url={this.props.url} + title={this.props.title} + source={source} + type={this.props.type} + pocket_id={this.props.pocket_id} + shim={this.props.shim} + bookmarkGuid={this.props.bookmarkGuid} + flightId={ + !this.props.is_collection ? this.props.flightId : undefined + } + showPrivacyInfo={!!this.props.flightId} + hostRef={this.contextMenuButtonHostRef} + onMenuUpdate={this.onMenuUpdate} + onMenuShow={this.onMenuShow} + pocket_button_enabled={pocketButtonEnabled} + isRecentSave={isRecentSave} + /> + )} + </div> + ); + } +} + +_DSCard.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const DSCard = connect(state => ({ + App: state.App, + DiscoveryStream: state.DiscoveryStream, +}))(_DSCard); + +export const PlaceholderDSCard = props => <DSCard placeholder={true} />; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss new file mode 100644 index 0000000000..a29087a5df --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSCard/_DSCard.scss @@ -0,0 +1,251 @@ +// Type sizes +$header-font-size: 17; +$header-line-height: 24; +$excerpt-font-size: 14; +$excerpt-line-height: 20; +$ds-card-image-gradient-fade: rgba(0, 0, 0, 0%); +$ds-card-image-gradient-solid: rgba(0, 0, 0, 100%); + +.ds-card { + display: flex; + flex-direction: column; + position: relative; + + &.placeholder { + background: transparent; + box-shadow: inset $inner-box-shadow; + border-radius: 4px; + min-height: 300px; + } + + .img-wrapper { + width: 100%; + position: relative; + } + + .card-stp-button-hover-background { + opacity: 0; + width: 100%; + position: absolute; + top: 0; + height: 0; + transition: opacity; + transition-duration: 0s; + padding-top: 50%; + pointer-events: none; + background: $black-40; + border-radius: 8px 8px 0 0; + + .card-stp-button-position-wrapper { + position: absolute; + inset-inline-end: 10px; + top: 10px; + display: flex; + justify-content: end; + align-items: center; + } + + .icon-pocket-save, + .icon-pocket { + margin-inline-end: 4px; + height: 15px; + width: 15px; + background-size: 15px; + fill: $white; + } + + .context-menu-button { + position: static; + transition: none; + border-radius: 3px; + } + + .context-menu-position-container { + position: relative; + } + + .context-menu { + margin-inline-start: 18.5px; + inset-inline-start: auto; + position: absolute; + top: 20.25px; + } + + .card-stp-button { + display: flex; + margin-inline-end: 7px; + font-weight: 400; + font-size: 13px; + line-height: 16px; + background-color: $pocket-icon-fill; + border: 0; + border-radius: 4px; + padding: 6px; + white-space: nowrap; + color: $white; + } + + button, + .context-menu { + pointer-events: auto; + } + + button { + cursor: pointer; + } + } + + &.last-item { + .card-stp-button-hover-background { + .context-menu { + margin-inline-start: auto; + margin-inline-end: 18.5px; + } + } + } + + // The active class is added when the context menu is open. + &.active, + &:focus-within, + &:hover { + .card-stp-button-hover-background { + display: block; + opacity: 1; + transition-duration: 0.3s; + + .context-menu-button { + opacity: 1; + transform: scale(1); + } + } + } + + .img { + height: 0; + padding-top: 50%; // 2:1 aspect ratio + + img { + border-radius: 4px; + box-shadow: $shadow-image-inset; + } + } + + .ds-card-link { + height: 100%; + display: flex; + flex-direction: column; + text-decoration: none; + + &:hover { + header { + color: var(--newtab-primary-action-background); + } + } + + &:focus { + @include ds-focus; + + transition: none; + + header { + color: var(--newtab-primary-action-background); + } + } + + &:active { + header { + color: var(--newtab-primary-element-active-color); + } + } + } + + .meta { + display: flex; + flex-direction: column; + padding: 12px 16px; + flex-grow: 1; + + .info-wrap { + flex-grow: 1; + } + + .title { + // show only 3 lines of copy + @include limit-visible-lines(3, $header-line-height, $header-font-size); + + font-weight: 600; + } + + .excerpt { + // show only 3 lines of copy + @include limit-visible-lines( + 3, + $excerpt-line-height, + $excerpt-font-size + ); + } + + .source { + -webkit-line-clamp: 1; + margin-bottom: 2px; + font-size: 13px; + color: var(--newtab-text-secondary-color); + + span { + display: inline-block; + } + } + + .new-sponsored-label { + font-size: 13px; + margin-bottom: 2px; + } + } + + &.ds-card-title-lines-2 .meta .title { + // show only 2 lines of copy + @include limit-visible-lines(2, $header-line-height, $header-font-size); + } + + &.ds-card-title-lines-1 .meta .title { + // show only 1 line of copy + @include limit-visible-lines(1, $header-line-height, $header-font-size); + } + + &.ds-card-desc-lines-2 .meta .excerpt { + // show only 2 lines of copy + @include limit-visible-lines(2, $excerpt-line-height, $excerpt-font-size); + } + + &.ds-card-desc-lines-1 .meta .excerpt { + // show only 1 line of copy + @include limit-visible-lines(1, $excerpt-line-height, $excerpt-font-size); + } + + &.ds-card-compact-image .img { + padding-top: 47%; + } + + &.ds-card-image-gradient { + img { + mask-image: linear-gradient(to top, $ds-card-image-gradient-fade, $ds-card-image-gradient-solid 40px); + } + + .meta { + padding: 3px 15px 11px; + } + } + + header { + line-height: $header-line-height * 1px; + font-size: $header-font-size * 1px; + color: var(--newtab-text-primary-color); + } + + p { + font-size: $excerpt-font-size * 1px; + line-height: $excerpt-line-height * 1px; + color: var(--newtab-text-primary-color); + margin: 0; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx new file mode 100644 index 0000000000..0d7d4f666e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/DSContextFooter.jsx @@ -0,0 +1,118 @@ +/* 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 { cardContextTypes } from "../../Card/types.js"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; +import { FluentOrText } from "../../FluentOrText/FluentOrText.jsx"; +import React from "react"; + +// Animation time is mirrored in DSContextFooter.scss +const ANIMATION_DURATION = 3000; + +export const DSMessageLabel = props => { + const { context, context_type } = props; + const { icon, fluentID } = cardContextTypes[context_type] || {}; + + if (!context && context_type) { + return ( + <TransitionGroup component={null}> + <CSSTransition + key={fluentID} + timeout={ANIMATION_DURATION} + classNames="story-animate" + > + <StatusMessage icon={icon} fluentID={fluentID} /> + </CSSTransition> + </TransitionGroup> + ); + } + + return null; +}; + +export const StatusMessage = ({ icon, fluentID }) => ( + <div className="status-message"> + <span + aria-haspopup="true" + className={`story-badge-icon icon icon-${icon}`} + /> + <div className="story-context-label" data-l10n-id={fluentID} /> + </div> +); + +export const SponsorLabel = ({ + sponsored_by_override, + sponsor, + context, + newSponsoredLabel, +}) => { + const classList = `story-sponsored-label ${newSponsoredLabel || ""} clamp`; + // If override is not false or an empty string. + if (sponsored_by_override) { + return <p className={classList}>{sponsored_by_override}</p>; + } else if (sponsored_by_override === "") { + // We specifically want to display nothing if the server returns an empty string. + // So the server can turn off the label. + // This is to support the use cases where the sponsored context is displayed elsewhere. + return null; + } else if (sponsor) { + return ( + <p className={classList}> + <FluentOrText + message={{ + id: `newtab-label-sponsored-by`, + values: { sponsor }, + }} + /> + </p> + ); + } else if (context) { + return <p className={classList}>{context}</p>; + } + return null; +}; + +export class DSContextFooter extends React.PureComponent { + render() { + const { context, context_type, sponsor, sponsored_by_override } = + this.props; + + const sponsorLabel = SponsorLabel({ + sponsored_by_override, + sponsor, + context, + }); + const dsMessageLabel = DSMessageLabel({ + context, + context_type, + }); + + if (sponsorLabel || dsMessageLabel) { + return ( + <div className="story-footer"> + {sponsorLabel} + {dsMessageLabel} + </div> + ); + } + + return null; + } +} + +export const DSMessageFooter = props => { + const { context, context_type, saveToPocketCard } = props; + + const dsMessageLabel = DSMessageLabel({ + context, + context_type, + }); + + // This case is specific and already displayed to the user elsewhere. + if (!dsMessageLabel || (saveToPocketCard && context_type === "pocket")) { + return null; + } + + return <div className="story-footer">{dsMessageLabel}</div>; +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss new file mode 100644 index 0000000000..c23bb1c661 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSContextFooter/_DSContextFooter.scss @@ -0,0 +1,81 @@ +.story-footer { + color: var(--newtab-text-secondary-color); + inset-inline-start: 0; + margin-top: 12px; + position: relative; + + .story-sponsored-label span { + display: inline-block; + } + + .story-sponsored-label, + .status-message { + -webkit-line-clamp: 1; + font-size: 13px; + line-height: 24px; + color: var(--newtab-text-secondary-color); + } + + .status-message { + display: flex; + align-items: center; + height: 24px; + + .story-badge-icon { + fill: var(--newtab-text-secondary-color); + height: 16px; + margin-inline-end: 6px; + + &.icon-bookmark-removed { + background-image: url('#{$image-path}icon-removed-bookmark.svg'); + } + } + + .story-context-label { + color: var(--newtab-text-secondary-color); + flex-grow: 1; + font-size: 13px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +} + +.story-animate-enter { + opacity: 0; +} + +.story-animate-enter-active { + opacity: 1; + transition: opacity 150ms ease-in 300ms; + + .story-badge-icon, + .story-context-label { + animation: color 3s ease-out 0.3s; + + @keyframes color { + 0% { + color: var(--newtab-status-success); + fill: var(--newtab-status-success); + } + + 100% { + color: var(--newtab-text-secondary-color); + fill: var(--newtab-text-secondary-color); + } + } + } +} + +.story-animate-exit { + position: absolute; + top: 0; + opacity: 1; +} + +.story-animate-exit-active { + opacity: 0; + transition: opacity 250ms ease-in; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx new file mode 100644 index 0000000000..9090ebe582 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss.jsx @@ -0,0 +1,57 @@ +/* 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 DSDismiss extends React.PureComponent { + constructor(props) { + super(props); + this.onDismissClick = this.onDismissClick.bind(this); + this.onHover = this.onHover.bind(this); + this.offHover = this.offHover.bind(this); + this.state = { + hovering: false, + }; + } + + onDismissClick() { + if (this.props.onDismissClick) { + this.props.onDismissClick(); + } + } + + onHover() { + this.setState({ + hovering: true, + }); + } + + offHover() { + this.setState({ + hovering: false, + }); + } + + render() { + let className = `ds-dismiss + ${this.state.hovering ? ` hovering` : ``} + ${this.props.extraClasses ? ` ${this.props.extraClasses}` : ``}`; + + return ( + <div className={className}> + {this.props.children} + <button + className="ds-dismiss-button" + data-l10n-id="newtab-dismiss-button-tooltip" + onHover={this.onHover} + onClick={this.onDismissClick} + onMouseEnter={this.onHover} + onMouseLeave={this.offHover} + > + <span className="icon icon-dismiss" /> + </button> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss new file mode 100644 index 0000000000..3c736a24ad --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSDismiss/_DSDismiss.scss @@ -0,0 +1,47 @@ +.ds-dismiss { + position: relative; + border-radius: 8px; + transition-duration: 250ms; + transition-property: background; + + &:hover { + .ds-dismiss-button { + opacity: 1; + } + } + + .ds-dismiss-button { + border: 0; + cursor: pointer; + height: 32px; + width: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + inset-inline-end: 0; + top: 0; + border-radius: 50%; + background-color: transparent; + + .icon { + @media (forced-colors: active) { + fill: CurrentColor; + } + fill: var(--newtab-text-primary-color); + } + + &:hover { + background: var(--newtab-element-hover-color); + } + + &:active { + background: var(--newtab-element-active-color); + } + + &:focus { + box-shadow: $shadow-secondary; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx new file mode 100644 index 0000000000..ff3886b407 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx @@ -0,0 +1,100 @@ +/* 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"; + +export class DSEmptyState extends React.PureComponent { + constructor(props) { + super(props); + this.onReset = this.onReset.bind(this); + this.state = {}; + } + + componentWillUnmount() { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + onReset() { + if (this.props.dispatch && this.props.feed) { + const { feed } = this.props; + const { url } = feed; + this.props.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: { + ...feed, + data: { + ...feed.data, + status: "waiting", + }, + }, + url, + }, + }); + + this.setState({ waiting: true }); + this.timeout = setTimeout(() => { + this.timeout = null; + this.setState({ + waiting: false, + }); + }, 300); + + this.props.dispatch( + ac.OnlyToMain({ type: at.DISCOVERY_STREAM_RETRY_FEED, data: { feed } }) + ); + } + } + + renderButton() { + if (this.props.status === "waiting" || this.state.waiting) { + return ( + <button + className="try-again-button waiting" + data-l10n-id="newtab-discovery-empty-section-topstories-loading" + /> + ); + } + + return ( + <button + className="try-again-button" + onClick={this.onReset} + data-l10n-id="newtab-discovery-empty-section-topstories-try-again-button" + /> + ); + } + + renderState() { + if (this.props.status === "waiting" || this.props.status === "failed") { + return ( + <React.Fragment> + <h2 data-l10n-id="newtab-discovery-empty-section-topstories-timed-out" /> + {this.renderButton()} + </React.Fragment> + ); + } + + return ( + <React.Fragment> + <h2 data-l10n-id="newtab-discovery-empty-section-topstories-header" /> + <p data-l10n-id="newtab-discovery-empty-section-topstories-content" /> + </React.Fragment> + ); + } + + render() { + return ( + <div className="section-empty-state"> + <div className="empty-state-message">{this.renderState()}</div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss new file mode 100644 index 0000000000..9f9accf71b --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss @@ -0,0 +1,83 @@ +.section-empty-state { + border: $border-secondary; + border-radius: 4px; + display: flex; + height: $card-height-compact; + width: 100%; + + .empty-state-message { + color: var(--newtab-text-secondary-color); + font-size: 14px; + line-height: 20px; + text-align: center; + margin: auto; + max-width: 936px; + } + + .try-again-button { + margin-top: 12px; + padding: 6px 32px; + border-radius: 2px; + border: 0; + background: var(--newtab-button-secondary-color); + color: var(--newtab-text-primary-color); + cursor: pointer; + position: relative; + transition: background 0.2s ease, color 0.2s ease; + + &:not(.waiting) { + &:focus { + @include ds-fade-in; + + @include dark-theme-only { + @include ds-fade-in($blue-40-40); + } + } + + &:hover { + @include ds-fade-in(var(--newtab-element-secondary-color)); + } + } + + &::after { + content: ''; + height: 20px; + width: 20px; + animation: spinner 1s linear infinite; + opacity: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -10px 0 0 -10px; + mask-image: url('chrome://activity-stream/content/data/content/assets/spinner.svg'); + mask-size: 20px; + background: var(--newtab-text-secondary-color); + } + + &.waiting { + cursor: initial; + background: var(--newtab-element-secondary-color); + color: transparent; + transition: background 0.2s ease; + + &::after { + transition: opacity 0.2s ease; + opacity: 1; + } + } + } + + h2 { + font-size: 15px; + font-weight: 600; + margin: 0; + } + + p { + margin: 0; + } +} + +@keyframes spinner { + to { transform: rotate(360deg); } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx new file mode 100644 index 0000000000..8a6cefed3a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/DSImage.jsx @@ -0,0 +1,263 @@ +/* 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"; + +const PLACEHOLDER_IMAGE_DATA_ARRAY = [ + { + rotation: "0deg", + offsetx: "20px", + offsety: "8px", + scale: "45%", + }, + { + rotation: "54deg", + offsetx: "-26px", + offsety: "62px", + scale: "55%", + }, + { + rotation: "-30deg", + offsetx: "78px", + offsety: "30px", + scale: "68%", + }, + { + rotation: "-22deg", + offsetx: "0", + offsety: "92px", + scale: "60%", + }, + { + rotation: "-65deg", + offsetx: "66px", + offsety: "28px", + scale: "60%", + }, + { + rotation: "22deg", + offsetx: "-35px", + offsety: "62px", + scale: "52%", + }, + { + rotation: "-25deg", + offsetx: "86px", + offsety: "-15px", + scale: "68%", + }, +]; + +const PLACEHOLDER_IMAGE_COLORS_ARRAY = + "#0090ED #FF4F5F #2AC3A2 #FF7139 #A172FF #FFA437 #FF2A8A".split(" "); + +function generateIndex({ keyCode, max }) { + if (!keyCode) { + // Just grab a random index if we cannot generate an index from a key. + return Math.floor(Math.random() * max); + } + + const hashStr = str => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + let charCode = str.charCodeAt(i); + hash += charCode; + } + return hash; + }; + + const hash = hashStr(keyCode); + return hash % max; +} + +export function PlaceholderImage({ urlKey, titleKey }) { + const dataIndex = generateIndex({ + keyCode: urlKey, + max: PLACEHOLDER_IMAGE_DATA_ARRAY.length, + }); + const colorIndex = generateIndex({ + keyCode: titleKey, + max: PLACEHOLDER_IMAGE_COLORS_ARRAY.length, + }); + const { rotation, offsetx, offsety, scale } = + PLACEHOLDER_IMAGE_DATA_ARRAY[dataIndex]; + const color = PLACEHOLDER_IMAGE_COLORS_ARRAY[colorIndex]; + const style = { + "--placeholderBackgroundColor": color, + "--placeholderBackgroundRotation": rotation, + "--placeholderBackgroundOffsetx": offsetx, + "--placeholderBackgroundOffsety": offsety, + "--placeholderBackgroundScale": scale, + }; + + return <div style={style} className="placeholder-image" />; +} + +export class DSImage extends React.PureComponent { + constructor(props) { + super(props); + + this.onOptimizedImageError = this.onOptimizedImageError.bind(this); + this.onNonOptimizedImageError = this.onNonOptimizedImageError.bind(this); + this.onLoad = this.onLoad.bind(this); + + this.state = { + isLoaded: false, + optimizedImageFailed: false, + useTransition: false, + }; + } + + onIdleCallback() { + if (!this.state.isLoaded) { + this.setState({ + useTransition: true, + }); + } + } + + reformatImageURL(url, width, height) { + // Change the image URL to request a size tailored for the parent container width + // Also: force JPEG, quality 60, no upscaling, no EXIF data + // Uses Thumbor: https://thumbor.readthedocs.io/en/latest/usage.html + return `https://img-getpocket.cdn.mozilla.net/${width}x${height}/filters:format(jpeg):quality(60):no_upscale():strip_exif()/${encodeURIComponent( + url + )}`; + } + + componentDidMount() { + this.idleCallbackId = this.props.windowObj.requestIdleCallback( + this.onIdleCallback.bind(this) + ); + } + + componentWillUnmount() { + if (this.idleCallbackId) { + this.props.windowObj.cancelIdleCallback(this.idleCallbackId); + } + } + + render() { + let classNames = `ds-image + ${this.props.extraClassNames ? ` ${this.props.extraClassNames}` : ``} + ${this.state && this.state.useTransition ? ` use-transition` : ``} + ${this.state && this.state.isLoaded ? ` loaded` : ``} + `; + + let img; + + if (this.state) { + if ( + this.props.optimize && + this.props.rawSource && + !this.state.optimizedImageFailed + ) { + let baseSource = this.props.rawSource; + + let sizeRules = []; + let srcSetRules = []; + + for (let rule of this.props.sizes) { + let { mediaMatcher, width, height } = rule; + let sizeRule = `${mediaMatcher} ${width}px`; + sizeRules.push(sizeRule); + let srcSetRule = `${this.reformatImageURL( + baseSource, + width, + height + )} ${width}w`; + let srcSetRule2x = `${this.reformatImageURL( + baseSource, + width * 2, + height * 2 + )} ${width * 2}w`; + srcSetRules.push(srcSetRule); + srcSetRules.push(srcSetRule2x); + } + + if (this.props.sizes.length) { + // We have to supply a fallback in the very unlikely event that none of + // the media queries match. The smallest dimension was chosen arbitrarily. + sizeRules.push( + `${this.props.sizes[this.props.sizes.length - 1].width}px` + ); + } + + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onOptimizedImageError} + sizes={sizeRules.join(",")} + src={baseSource} + srcSet={srcSetRules.join(",")} + /> + ); + } else if (this.props.source && !this.state.nonOptimizedImageFailed) { + img = ( + <img + loading="lazy" + alt={this.props.alt_text} + crossOrigin="anonymous" + onLoad={this.onLoad} + onError={this.onNonOptimizedImageError} + src={this.props.source} + /> + ); + } else { + // We consider a failed to load img or source without an image as loaded. + classNames = `${classNames} loaded`; + // Remove the img element if we have no source. Render a placeholder instead. + // This only happens for recent saves without a source. + if ( + this.props.isRecentSave && + !this.props.rawSource && + !this.props.source + ) { + img = ( + <PlaceholderImage + urlKey={this.props.url} + titleKey={this.props.title} + /> + ); + } else { + img = <div className="broken-image" />; + } + } + } + + return <picture className={classNames}>{img}</picture>; + } + + onOptimizedImageError() { + // This will trigger a re-render and the unoptimized 450px image will be used as a fallback + this.setState({ + optimizedImageFailed: true, + }); + } + + onNonOptimizedImageError() { + this.setState({ + nonOptimizedImageFailed: true, + }); + } + + onLoad() { + this.setState({ + isLoaded: true, + }); + } +} + +DSImage.defaultProps = { + source: null, // The current source style from Pocket API (always 450px) + rawSource: null, // Unadulterated image URL to filter through Thumbor + extraClassNames: null, // Additional classnames to append to component + optimize: true, // Measure parent container to request exact sizes + alt_text: null, + windowObj: window, // Added to support unit tests + sizes: [], +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss new file mode 100644 index 0000000000..b11bcdcf55 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSImage/_DSImage.scss @@ -0,0 +1,48 @@ +.ds-image { + display: block; + position: relative; + opacity: 0; + + &.use-transition { + transition: opacity 0.8s; + } + + &.loaded { + opacity: 1; + } + + img, + .placeholder-image, + .broken-image { + background-color: var(--newtab-element-secondary-color); + position: absolute; + top: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .placeholder-image { + overflow: hidden; + background-color: var(--placeholderBackgroundColor); + + &::before { + content: ''; + background-image: url('chrome://activity-stream/content/data/content/assets/pocket-swoosh.svg'); + background-repeat: no-repeat; + background-position: center; + transform: rotate(var(--placeholderBackgroundRotation)); + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + // We use margin-left over margin-inline-start on purpose. + // This is because we are using it to offset an image's content, + // and the image content is the same in ltr and rtl. + margin-left: var(--placeholderBackgroundOffsetx); // stylelint-disable-line property-disallowed-list + margin-top: var(--placeholderBackgroundOffsety); + background-size: var(--placeholderBackgroundScale); + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx new file mode 100644 index 0000000000..b75063940c --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/DSLinkMenu.jsx @@ -0,0 +1,70 @@ +/* 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 { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import React from "react"; + +export class DSLinkMenu extends React.PureComponent { + render() { + const { index, dispatch } = this.props; + let pocketMenuOptions = []; + let TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + ]; + if (!this.props.isRecentSave) { + if (this.props.pocket_button_enabled) { + pocketMenuOptions = this.props.saveToPocketCard + ? ["CheckDeleteFromPocket"] + : ["CheckSavedToPocket"]; + } + TOP_STORIES_CONTEXT_MENU_OPTIONS = [ + "CheckBookmark", + "CheckArchiveFromPocket", + ...pocketMenuOptions, + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ...(this.props.showPrivacyInfo ? ["ShowPrivacyInfo"] : []), + ]; + } + const type = this.props.type || "DISCOVERY_STREAM"; + const title = this.props.title || this.props.source; + + return ( + <div className="context-menu-position-container"> + <ContextMenuButton + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.props.onMenuUpdate} + > + <LinkMenu + dispatch={dispatch} + index={index} + source={type.toUpperCase()} + onShow={this.props.onMenuShow} + options={TOP_STORIES_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + userEvent={ac.DiscoveryStreamUserEvent} + site={{ + referrer: "https://getpocket.com/recommendations", + title: this.props.title, + type: this.props.type, + url: this.props.url, + guid: this.props.id, + pocket_id: this.props.pocket_id, + shim: this.props.shim, + bookmarkGuid: this.props.bookmarkGuid, + flight_id: this.props.flightId, + }} + /> + </ContextMenuButton> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss new file mode 100644 index 0000000000..e85eab11e7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSLinkMenu/_DSLinkMenu.scss @@ -0,0 +1,28 @@ +.ds-card, +.ds-signup { + @include context-menu-button; + + .context-menu { + opacity: 0; + } + + &.active { + .context-menu { + opacity: 1; + } + } + + &.last-item { + @include context-menu-open-left; + + .context-menu { + opacity: 1; + } + } + + &:is(:hover, :focus, .active) { + @include context-menu-button-hover; + + outline: none; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx new file mode 100644 index 0000000000..df9ad4f641 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/DSMessage.jsx @@ -0,0 +1,34 @@ +/* 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"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class DSMessage extends React.PureComponent { + render() { + return ( + <div className="ds-message"> + <header className="title"> + {this.props.icon && ( + <div + className="glyph" + style={{ backgroundImage: `url(${this.props.icon})` }} + /> + )} + {this.props.title && ( + <span className="title-text"> + <FluentOrText message={this.props.title} /> + </span> + )} + {this.props.link_text && this.props.link_url && ( + <SafeAnchor className="link" url={this.props.link_url}> + <FluentOrText message={this.props.link_text} /> + </SafeAnchor> + )} + </header> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss new file mode 100644 index 0000000000..bb9666ae38 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSMessage/_DSMessage.scss @@ -0,0 +1,37 @@ +.ds-message { + margin: 8px 0 0; + + .title { + display: flex; + align-items: center; + + .glyph { + width: 16px; + height: 16px; + margin: 0 6px 0 0; + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + background-position: center center; + background-size: 16px; + background-repeat: no-repeat; + } + + .title-text { + line-height: 20px; + font-size: 13px; + color: var(--newtab-text-secondary-color); + font-weight: 600; + padding-right: 12px; + } + + .link { + line-height: 20px; + font-size: 13px; + + &:hover, + &:focus { + text-decoration: underline; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx new file mode 100644 index 0000000000..06ec1f7e78 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/DSPrivacyModal.jsx @@ -0,0 +1,72 @@ +/* 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"; +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { ModalOverlayWrapper } from "content-src/asrouter/components/ModalOverlay/ModalOverlay"; + +export class DSPrivacyModal extends React.PureComponent { + constructor(props) { + super(props); + this.closeModal = this.closeModal.bind(this); + this.onLearnLinkClick = this.onLearnLinkClick.bind(this); + this.onManageLinkClick = this.onManageLinkClick.bind(this); + } + + onLearnLinkClick(event) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK_PRIVACY_INFO", + source: "DS_PRIVACY_MODAL", + }) + ); + } + + onManageLinkClick(event) { + this.props.dispatch(ac.OnlyToMain({ type: at.SETTINGS_OPEN })); + } + + closeModal() { + this.props.dispatch({ + type: `HIDE_PRIVACY_INFO`, + data: {}, + }); + } + + render() { + return ( + <ModalOverlayWrapper + onClose={this.closeModal} + innerClassName="ds-privacy-modal" + > + <div className="privacy-notice"> + <h3 data-l10n-id="newtab-privacy-modal-header" /> + <p data-l10n-id="newtab-privacy-modal-paragraph-2" /> + <a + className="modal-link modal-link-privacy" + data-l10n-id="newtab-privacy-modal-link" + onClick={this.onLearnLinkClick} + href="https://help.getpocket.com/article/1142-firefox-new-tab-recommendations-faq" + /> + <button + className="modal-link modal-link-manage" + data-l10n-id="newtab-privacy-modal-button-manage" + onClick={this.onManageLinkClick} + /> + </div> + <section className="actions"> + <button + className="done" + type="submit" + onClick={this.closeModal} + data-l10n-id="newtab-privacy-modal-button-done" + /> + </section> + </ModalOverlayWrapper> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss new file mode 100644 index 0000000000..2077f35709 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSPrivacyModal/_DSPrivacyModal.scss @@ -0,0 +1,48 @@ +.ds-privacy-modal { + .modal-link { + display: flex; + align-items: center; + margin: 0 0 8px; + border: 0; + padding: 0; + color: var(--newtab-primary-action-background); + width: max-content; + + &:hover { + text-decoration: underline; + cursor: pointer; + } + + &::before { + -moz-context-properties: fill; + fill: var(--newtab-primary-action-background); + content: ''; + display: inline-block; + width: 16px; + height: 16px; + margin: 0; + margin-inline-end: 8px; + background-position: center center; + background-repeat: no-repeat; + background-size: 16px; + } + + &.modal-link-privacy::before { + background-image: url('chrome://global/skin/icons/info.svg'); + } + + &.modal-link-manage::before { + background-image: url('chrome://global/skin/icons/settings.svg'); + } + } + + p { + line-height: 24px; + } + + .privacy-notice { + max-width: 572px; + padding: 40px; + margin: auto; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx new file mode 100644 index 0000000000..b7e3205646 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.jsx @@ -0,0 +1,168 @@ +/* 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 { LinkMenu } from "content-src/components/LinkMenu/LinkMenu"; +import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; + +export class DSSignup extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + active: false, + lastItem: false, + }; + this.onMenuButtonUpdate = this.onMenuButtonUpdate.bind(this); + this.onLinkClick = this.onLinkClick.bind(this); + this.onMenuShow = this.onMenuShow.bind(this); + } + + onMenuButtonUpdate(showContextMenu) { + if (!showContextMenu) { + this.setState({ + active: false, + lastItem: false, + }); + } + } + + nextAnimationFrame() { + return new Promise(resolve => + this.props.windowObj.requestAnimationFrame(resolve) + ); + } + + async onMenuShow() { + let { lastItem } = this.state; + // Wait for next frame before computing scrollMaxX to allow fluent menu strings to be visible + await this.nextAnimationFrame(); + if (this.props.windowObj.scrollMaxX > 0) { + lastItem = true; + } + this.setState({ + active: true, + lastItem, + }); + } + + onLinkClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: 0, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source, + click: 0, + tiles: [ + { + id: spoc.id, + pos: 0, + ...(spoc.shim && spoc.shim.click + ? { shim: spoc.shim.click } + : {}), + }, + ], + }) + ); + } + } + + render() { + const { data, dispatch, type } = this.props; + if (!data || !data.spocs || !data.spocs[0]) { + return null; + } + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const { title, url, excerpt, flight_id, id, shim } = spoc; + + const SIGNUP_CONTEXT_MENU_OPTIONS = [ + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", + ...(flight_id ? ["ShowPrivacyInfo"] : []), + ]; + + const outerClassName = [ + "ds-signup", + this.state.active && "active", + this.state.lastItem && "last-item", + ] + .filter(v => v) + .join(" "); + + return ( + <div className={outerClassName}> + <div className="ds-signup-content"> + <span className="icon icon-small-spacer icon-mail"></span> + <span> + {title}{" "} + <SafeAnchor + className="ds-chevron-link" + dispatch={dispatch} + onLinkClick={this.onLinkClick} + url={url} + > + {excerpt} + </SafeAnchor> + </span> + <ImpressionStats + flightId={flight_id} + rows={[ + { + id, + pos: 0, + shim: shim && shim.impression, + }, + ]} + dispatch={dispatch} + source={type} + /> + </div> + <ContextMenuButton + tooltip={"newtab-menu-content-tooltip"} + tooltipArgs={{ title }} + onUpdate={this.onMenuButtonUpdate} + > + <LinkMenu + dispatch={dispatch} + index={0} + source={type.toUpperCase()} + onShow={this.onMenuShow} + options={SIGNUP_CONTEXT_MENU_OPTIONS} + shouldSendImpressionStats={true} + userEvent={ac.DiscoveryStreamUserEvent} + site={{ + referrer: "https://getpocket.com/recommendations", + title, + type, + url, + guid: id, + shim, + flight_id, + }} + /> + </ContextMenuButton> + </div> + ); + } +} + +DSSignup.defaultProps = { + windowObj: window, // Added to support unit tests +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss new file mode 100644 index 0000000000..dcaf0e804a --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSSignup/DSSignup.scss @@ -0,0 +1,52 @@ +.ds-signup { + max-width: 300px; + margin: 0 auto; + padding: 8px; + position: relative; + text-align: center; + font-size: 17px; + font-weight: 600; + + &:hover { + background: var(--newtab-element-hover-color); + border-radius: 4px; + } + + .icon-mail { + height: 40px; + width: 40px; + margin-inline-end: 8px; + fill: var(--newtab-text-secondary-color); + background-size: 30px; + flex-shrink: 0; + } + + .ds-signup-content { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + + .ds-chevron-link { + margin-top: 4px; + box-shadow: none; + display: block; + white-space: nowrap; + } + } + + @media (min-width: $break-point-large) { + min-width: 756px; + width: max-content; + text-align: start; + + .ds-signup-content { + flex-direction: row; + + .ds-chevron-link { + margin-top: 0; + display: inline; + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx new file mode 100644 index 0000000000..02a3326eb7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/DSTextPromo.jsx @@ -0,0 +1,143 @@ +/* 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 { DSDismiss } from "content-src/components/DiscoveryStreamComponents/DSDismiss/DSDismiss"; +import { DSImage } from "../DSImage/DSImage.jsx"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; + +export class DSTextPromo extends React.PureComponent { + constructor(props) { + super(props); + this.onLinkClick = this.onLinkClick.bind(this); + this.onDismissClick = this.onDismissClick.bind(this); + } + + onLinkClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: 0, + }) + ); + + this.props.dispatch( + ac.ImpressionStats({ + source, + click: 0, + tiles: [ + { + id: spoc.id, + pos: 0, + ...(spoc.shim && spoc.shim.click + ? { shim: spoc.shim.click } + : {}), + }, + ], + }) + ); + } + } + + onDismissClick() { + const { data } = this.props; + if (this.props.dispatch && data && data.spocs && data.spocs.length) { + const index = 0; + const source = this.props.type.toUpperCase(); + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const spocData = { + url: spoc.url, + guid: spoc.id, + shim: spoc.shim, + }; + const blockUrlOption = LinkMenuOptions.BlockUrl(spocData, index, source); + + const { action, impression, userEvent } = blockUrlOption; + + this.props.dispatch(action); + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: userEvent, + source, + action_position: index, + }) + ); + if (impression) { + this.props.dispatch(impression); + } + } + } + + render() { + const { data } = this.props; + if (!data || !data.spocs || !data.spocs[0]) { + return null; + } + // Grab the first item in the array as we only have 1 spoc position. + const [spoc] = data.spocs; + const { + image_src, + raw_image_src, + alt_text, + title, + url, + context, + cta, + flight_id, + id, + shim, + } = spoc; + + return ( + <DSDismiss + onDismissClick={this.onDismissClick} + extraClasses={`ds-dismiss-ds-text-promo`} + > + <div className="ds-text-promo"> + <DSImage + alt_text={alt_text} + source={image_src} + rawSource={raw_image_src} + /> + <div className="text"> + <h3> + {`${title}\u2003`} + <SafeAnchor + className="ds-chevron-link" + dispatch={this.props.dispatch} + onLinkClick={this.onLinkClick} + url={url} + > + {cta} + </SafeAnchor> + </h3> + <p className="subtitle">{context}</p> + </div> + <ImpressionStats + flightId={flight_id} + rows={[ + { + id, + pos: 0, + shim: shim && shim.impression, + }, + ]} + dispatch={this.props.dispatch} + source={this.props.type} + /> + </div> + </DSDismiss> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss new file mode 100644 index 0000000000..b0abea1213 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/DSTextPromo/_DSTextPromo.scss @@ -0,0 +1,92 @@ +.ds-dismiss-ds-text-promo { + max-width: 744px; + margin: auto; + overflow: hidden; + + &.hovering { + background: var(--newtab-element-hover-color); + } + + .ds-dismiss-button { + margin-inline: 0 18px; + margin-block: 18px 0; + } +} + +.ds-text-promo { + max-width: 640px; + margin: 0; + padding: 18px; + + @media(min-width: $break-point-medium) { + display: flex; + margin: 18px 24px; + padding: 0 32px 0 0; + } + + .ds-image { + width: 40px; + height: 40px; + flex-shrink: 0; + margin: 0 0 18px; + + @media(min-width: $break-point-medium) { + margin: 4px 12px 0 0; + } + + img { + border-radius: 4px; + } + } + + .text { + line-height: 24px; + } + + h3 { + color: var(--newtab-text-primary-color); + margin: 0; + font-weight: 600; + font-size: 15px; + } + + .subtitle { + font-size: 13px; + margin: 0; + color: var(--newtab-text-primary-color); + } +} + +.ds-chevron-link { + color: var(--newtab-primary-action-background); + display: inline-block; + outline: 0; + + &:hover { + text-decoration: underline; + } + + &:active { + color: var(--newtab-primary-element-active-color); + + &::after { + background-color: var(--newtab-primary-element-active-color); + } + } + + &:focus { + box-shadow: $shadow-secondary; + border-radius: 2px; + } + + &::after { + background-color: var(--newtab-primary-action-background); + content: ' '; + mask: url('chrome://global/skin/icons/arrow-right-12.svg') 0 -8px no-repeat; + margin: 0 0 0 4px; + width: 5px; + height: 8px; + text-decoration: none; + display: inline-block; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx new file mode 100644 index 0000000000..d0cc87cce3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/Highlights.jsx @@ -0,0 +1,26 @@ +/* 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 { connect } from "react-redux"; +import React from "react"; +import { SectionIntl } from "content-src/components/Sections/Sections"; + +export class _Highlights extends React.PureComponent { + render() { + const section = this.props.Sections.find(s => s.id === "highlights"); + if (!section || !section.enabled) { + return null; + } + + return ( + <div className="ds-highlights sections-list"> + <SectionIntl {...section} isFixed={true} /> + </div> + ); + } +} + +export const Highlights = connect(state => ({ Sections: state.Sections }))( + _Highlights +); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss new file mode 100644 index 0000000000..3c5b60e946 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Highlights/_Highlights.scss @@ -0,0 +1,45 @@ +.ds-highlights { + .section { + .section-list { + grid-gap: var(--gridRowGap); + grid-template-columns: repeat(1, 1fr); + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $break-point-large) { + grid-template-columns: repeat(4, 1fr); + } + + .card-outer { + $line-height: 20px; + + height: 175px; + + .card-host-name { + font-size: 13px; + line-height: $line-height; + margin-bottom: 2px; + padding-bottom: 0; + text-transform: unset; + } + + .card-title { + font-size: 14px; + font-weight: 600; + line-height: $line-height; + max-height: $line-height; + } + + a { + text-decoration: none; + } + } + } + } + + .hide-for-narrow { + display: block; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx new file mode 100644 index 0000000000..4cdfc7594f --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule.jsx @@ -0,0 +1,11 @@ +/* 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 HorizontalRule extends React.PureComponent { + render() { + return <hr className="ds-hr" />; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss new file mode 100644 index 0000000000..aa5d6ff9f3 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/HorizontalRule/_HorizontalRule.scss @@ -0,0 +1,7 @@ +.ds-hr { + @include ds-border-top { + border: 0; + }; + + height: 0; +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx new file mode 100644 index 0000000000..1062c3cade --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/Navigation.jsx @@ -0,0 +1,112 @@ +/* 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"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class Topic extends React.PureComponent { + constructor(props) { + super(props); + + this.onLinkClick = this.onLinkClick.bind(this); + } + + onLinkClick(event) { + if (this.props.dispatch) { + this.props.dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source: "POPULAR_TOPICS", + action_position: 0, + value: { + topic: event.target.text.toLowerCase().replace(` `, `-`), + }, + }) + ); + } + } + + render() { + const { url, name } = this.props; + return ( + <SafeAnchor + onLinkClick={this.onLinkClick} + className={this.props.className} + url={url} + > + {name} + </SafeAnchor> + ); + } +} + +export class Navigation extends React.PureComponent { + render() { + let links = this.props.links || []; + const alignment = this.props.alignment || "centered"; + const header = this.props.header || {}; + const english = this.props.locale.startsWith("en-"); + const privacyNotice = this.props.privacyNoticeURL || {}; + const { newFooterSection } = this.props; + const className = `ds-navigation ds-navigation-${alignment} ${ + newFooterSection ? `ds-navigation-new-topics` : `` + }`; + let { title } = header; + if (newFooterSection) { + title = { id: "newtab-pocket-new-topics-title" }; + if (this.props.extraLinks) { + links = [ + ...links.slice(0, links.length - 1), + ...this.props.extraLinks, + links[links.length - 1], + ]; + } + } + + return ( + <div className={className}> + {title && english ? ( + <FluentOrText message={title}> + <span className="ds-navigation-header" /> + </FluentOrText> + ) : null} + + {english ? ( + <ul> + {links && + links.map(t => ( + <li key={t.name}> + <Topic + url={t.url} + name={t.name} + dispatch={this.props.dispatch} + /> + </li> + ))} + </ul> + ) : null} + + {!newFooterSection ? ( + <SafeAnchor className="ds-navigation-privacy" url={privacyNotice.url}> + <FluentOrText message={privacyNotice.title} /> + </SafeAnchor> + ) : null} + + {newFooterSection ? ( + <div className="ds-navigation-family"> + <span className="icon firefox-logo" /> + <span>|</span> + <span className="icon pocket-logo" /> + <span + className="ds-navigation-family-message" + data-l10n-id="newtab-pocket-pocket-firefox-family" + /> + </div> + ) : null} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss new file mode 100644 index 0000000000..f9b5e5c704 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/Navigation/_Navigation.scss @@ -0,0 +1,180 @@ +.ds-navigation { + color: var(--newtab-text-primary-color); + font-size: 11.5px; + font-weight: 500; + line-height: 22px; + padding: 4px 0; + + @media (min-width: $break-point-widest) { + line-height: 32px; + font-size: 14px; + } + + &.ds-navigation-centered { + text-align: center; + } + + &.ds-navigation-right-aligned { + text-align: end; + } + + ul { + display: inline; + margin: 0; + padding: 0; + } + + ul li { + display: inline-block; + + &::after { + content: '·'; + padding: 6px; + } + + &:last-child::after { + content: none; + } + + a { + &:hover, + &:active { + text-decoration: none; + } + + &:active { + color: var(--newtab-primary-element-active-color); + } + } + } + + .ds-navigation-header { + padding-inline-end: 6px; + } + + .ds-navigation-privacy { + padding-inline-start: 6px; + float: inline-end; + + &:hover { + text-decoration: none; + } + } + + &.ds-navigation-new-topics { + display: block; + padding-top: 32px; + + .ds-navigation-header { + font-size: 14px; + line-height: 20px; + font-weight: 700; + display: inline-block; + margin-bottom: 8px; + } + + .ds-navigation-family { + text-align: center; + font-size: 14px; + line-height: 20px; + margin: 16px auto 28px; + + span { + margin: 0 6px; + } + + .firefox-logo, + .pocket-logo { + height: 20px; + width: 20px; + background-size: cover; + } + + .firefox-logo { + background-image: url('chrome://activity-stream/content/data/content/assets/firefox.svg'); + } + + .pocket-logo { + background-image: url('chrome://global/skin/icons/pocket.svg'); + fill: $pocket-icon-fill; + } + + .ds-navigation-family-message { + font-weight: 400; + display: block; + + @media (min-width: $break-point-medium) { + display: inline; + } + } + + @media (min-width: $break-point-medium) { + margin-top: 43px; + } + } + + ul { + display: grid; + grid-gap: 0 24px; + grid-auto-flow: column; + grid-template: repeat(8, 1fr) / repeat(1, 1fr); + + li { + border-top: $border-primary; + line-height: 24px; + font-size: 13px; + font-weight: 500; + + &::after { + content: ''; + padding: 0; + } + + &:nth-last-child(2), + &:nth-last-child(3) { + display: none; + } + + &:nth-last-child(1) { + border-bottom: $border-primary; + } + } + + @media (min-width: $break-point-medium) { + grid-template: repeat(3, 1fr) / repeat(2, 1fr); + + li { + &:nth-child(3) { + border-bottom: $border-primary; + } + } + } + + @media (min-width: $break-point-large) { + grid-template: repeat(2, 1fr) / repeat(3, 1fr); + + + li { + &:nth-child(odd) { + border-bottom: 0; + } + + &:nth-child(even) { + border-bottom: $border-primary; + } + } + } + + @media (min-width: $break-point-widest) { + grid-template: repeat(2, 1fr) / repeat(4, 1fr); + + li { + &:nth-last-child(2), + &:nth-last-child(3) { + display: block; + } + } + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx new file mode 100644 index 0000000000..8f7d88be85 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/PrivacyLink.jsx @@ -0,0 +1,20 @@ +/* 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"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; + +export class PrivacyLink extends React.PureComponent { + render() { + const { properties } = this.props; + return ( + <div className="ds-privacy-link"> + <SafeAnchor url={properties.url}> + <FluentOrText message={properties.title} /> + </SafeAnchor> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss new file mode 100644 index 0000000000..08ce093c27 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/PrivacyLink/_PrivacyLink.scss @@ -0,0 +1,10 @@ +.ds-privacy-link { + text-align: center; + font-size: 13px; + font-weight: 500; + line-height: 24px; + + a:hover { + text-decoration: none; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx new file mode 100644 index 0000000000..cfbc6fe6cb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SafeAnchor/SafeAnchor.jsx @@ -0,0 +1,65 @@ +/* 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"; + +export class SafeAnchor extends React.PureComponent { + constructor(props) { + super(props); + this.onClick = this.onClick.bind(this); + } + + onClick(event) { + // Use dispatch instead of normal link click behavior to include referrer + if (this.props.dispatch) { + event.preventDefault(); + const { altKey, button, ctrlKey, metaKey, shiftKey } = event; + this.props.dispatch( + ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { + event: { altKey, button, ctrlKey, metaKey, shiftKey }, + referrer: "https://getpocket.com/recommendations", + // Use the anchor's url, which could have been cleaned up + url: event.currentTarget.href, + }, + }) + ); + } + + // Propagate event if there's a handler + if (this.props.onLinkClick) { + this.props.onLinkClick(event); + } + } + + safeURI(url) { + let protocol = null; + try { + protocol = new URL(url).protocol; + } catch (e) { + return ""; + } + + const isAllowed = ["http:", "https:"].includes(protocol); + if (!isAllowed) { + console.warn(`${url} is not allowed for anchor targets.`); // eslint-disable-line no-console + return ""; + } + return url; + } + + render() { + const { url, className } = this.props; + return ( + <a href={this.safeURI(url)} className={className} onClick={this.onClick}> + {this.props.children} + </a> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx new file mode 100644 index 0000000000..646dc2263e --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle.jsx @@ -0,0 +1,19 @@ +/* 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 SectionTitle extends React.PureComponent { + render() { + const { + header: { title, subtitle }, + } = this.props; + return ( + <div className="ds-section-title"> + <div className="title">{title}</div> + {subtitle ? <div className="subtitle">{subtitle}</div> : null} + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss new file mode 100644 index 0000000000..453001b1b7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/SectionTitle/_SectionTitle.scss @@ -0,0 +1,18 @@ +.ds-section-title { + text-align: center; + margin-top: 24px; + + .title { + color: var(--newtab-text-primary-color); + line-height: 48px; + font-size: 36px; + font-weight: 300; + } + + .subtitle { + line-height: 24px; + font-size: 14px; + color: var(--newtab-text-secondary-color); + margin-top: 4px; + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss new file mode 100644 index 0000000000..e0c7c1a8eb --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopSites/_TopSites.scss @@ -0,0 +1,77 @@ +.outer-wrapper { + .ds-top-sites { + .top-sites { + .top-site-outer { + .top-site-inner > a:is(.active, :focus) .tile { + @include ds-focus; + } + + .top-site-inner > a:is(:hover) .top-site-inner { + @include ds-fade-in(var(--newtab-background-color-secondary)); + } + } + + .top-sites-list { + margin: 0 -12px; + } + } + } +} + +// Size overrides for topsites in the 2/3 view. +.ds-column-5, +.ds-column-6, +.ds-column-7, +.ds-column-8 { + .ds-top-sites { + .top-site-outer { + padding: 0 10px; + } + + .top-sites-list { + margin: 0 -10px; + } + + .top-site-inner { + --leftPanelIconWidth: 84.67px; + + .tile { + width: var(--leftPanelIconWidth); + height: var(--leftPanelIconWidth); + } + + .title { + width: var(--leftPanelIconWidth); + } + } + } +} + +// Size overrides for topsites in the 1/3 view. +.ds-column-1, +.ds-column-2, +.ds-column-3, +.ds-column-4 { + .ds-top-sites { + .top-site-outer { + padding: 0 8px; + } + + .top-sites-list { + margin: 0 -8px; + } + + .top-site-inner { + --rightPanelIconWidth: 82.67px; + + .tile { + width: var(--rightPanelIconWidth); + height: var(--rightPanelIconWidth); + } + + .title { + width: var(--rightPanelIconWidth); + } + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx new file mode 100644 index 0000000000..1fe2343b94 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/TopicsWidget.jsx @@ -0,0 +1,125 @@ +/* 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"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; +import { SafeAnchor } from "../SafeAnchor/SafeAnchor"; +import { ImpressionStats } from "../../DiscoveryStreamImpressionStats/ImpressionStats"; +import { connect } from "react-redux"; + +export function _TopicsWidget(props) { + const { id, source, position, DiscoveryStream, dispatch } = props; + + const { utmCampaign, utmContent, utmSource } = DiscoveryStream.experimentData; + + let queryParams = `?utm_source=${utmSource}`; + if (utmCampaign && utmContent) { + queryParams += `&utm_content=${utmContent}&utm_campaign=${utmCampaign}`; + } + + const topics = [ + { label: "Technology", name: "technology" }, + { label: "Science", name: "science" }, + { label: "Self-Improvement", name: "self-improvement" }, + { label: "Travel", name: "travel" }, + { label: "Career", name: "career" }, + { label: "Entertainment", name: "entertainment" }, + { label: "Food", name: "food" }, + { label: "Health", name: "health" }, + { + label: "Must-Reads", + name: "must-reads", + url: `https://getpocket.com/collections${queryParams}`, + }, + ]; + + function onLinkClick(topic, positionInCard) { + if (dispatch) { + dispatch( + ac.DiscoveryStreamUserEvent({ + event: "CLICK", + source, + action_position: position, + value: { + card_type: "topics_widget", + topic, + ...(positionInCard || positionInCard === 0 + ? { position_in_card: positionInCard } + : {}), + }, + }) + ); + dispatch( + ac.ImpressionStats({ + source, + click: 0, + window_inner_width: props.windowObj.innerWidth, + window_inner_height: props.windowObj.innerHeight, + tiles: [ + { + id, + pos: position, + }, + ], + }) + ); + } + } + + function mapTopicItem(topic, index) { + return ( + <li + key={topic.name} + className={topic.overflow ? "ds-topics-widget-list-overflow-item" : ""} + > + <SafeAnchor + url={ + topic.url || + `https://getpocket.com/explore/${topic.name}${queryParams}` + } + dispatch={dispatch} + onLinkClick={() => onLinkClick(topic.name, index)} + > + {topic.label} + </SafeAnchor> + </li> + ); + } + + return ( + <div className="ds-topics-widget"> + <header className="ds-topics-widget-header">Popular Topics</header> + <hr /> + <div className="ds-topics-widget-list-container"> + <ul>{topics.map(mapTopicItem)}</ul> + </div> + <SafeAnchor + className="ds-topics-widget-button button primary" + url={`https://getpocket.com/${queryParams}`} + dispatch={dispatch} + onLinkClick={() => onLinkClick("more-topics")} + > + More Topics + </SafeAnchor> + <ImpressionStats + dispatch={dispatch} + rows={[ + { + id, + pos: position, + }, + ]} + source={source} + /> + </div> + ); +} + +_TopicsWidget.defaultProps = { + windowObj: window, // Added to support unit tests +}; + +export const TopicsWidget = connect(state => ({ + DiscoveryStream: state.DiscoveryStream, +}))(_TopicsWidget); diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss new file mode 100644 index 0000000000..d05d46cd07 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamComponents/TopicsWidget/_TopicsWidget.scss @@ -0,0 +1,88 @@ +.ds-topics-widget { + display: flex; + position: relative; + flex-direction: column; + + .ds-topics-widget-header { + font-size: 18px; + line-height: 20px; + } + + hr { + background-color: color-mix(in srgb, var(--newtab-border-color) 52%, transparent); + height: 1px; + border: 0; + margin: 10px 0 0; + } + + .ds-topics-widget-list-container { + flex-grow: 1; + + ul { + margin: 14px 0 0; + padding: 0; + display: flex; + align-items: center; + grid-gap: 10px; + flex-wrap: wrap; + + li { + display: flex; + + a { + font-size: 14px; + line-height: 16px; + text-decoration: none; + padding: 8px 15px; + background: var(--newtab-background-color-secondary); + border: 1px solid color-mix(in srgb, var(--newtab-border-color) 52%, transparent); + color: var(--newtab-text-primary-color); + border-radius: 8px; + + &:hover { + background: var(--newtab-element-hover-color); + } + + &:focus { + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-dimmed), 0 0 0 1px var(--newtab-primary-action-background); + transition: box-shadow 150ms; + } + } + } + + .ds-topics-widget-list-overflow-item { + display: flex; + + @media (min-width: $break-point-medium) { + display: none; + } + + @media (min-width: $break-point-widest) { + display: flex; + } + } + } + } + + .ds-topics-widget-button { + margin: 14px 0 0; + font-size: 16px; + line-height: 24px; + text-align: center; + padding: 8px; + border-radius: 4px; + background-color: var(--newtab-primary-action-background-pocket); + border: 0; + + &:hover { + background: var(--newtab-primary-element-hover-pocket-color); + } + + &:focus { + outline: 0; + box-shadow: 0 0 0 3px var(--newtab-primary-action-background-pocket-dimmed), 0 0 0 1px var(--newtab-primary-action-background-pocket); + transition: box-shadow 150ms; + } + } +} diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx new file mode 100644 index 0000000000..b385c2ebf7 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/ImpressionStats.jsx @@ -0,0 +1,250 @@ +/* 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 { TOP_SITES_SOURCE } from "../TopSites/TopSitesConstants"; +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 Discovery Stream related React components. + * + * 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. + * + * Note: + * * This wrapper used to be used either at the individual card level, + * or by the card container components. + * It is now only used for individual card level. + * * Each impression will be sent only once as soon as the desired + * visibility is detected + * * Batching is not yet implemented, hence it might send multiple + * impression pings separately + */ +export class ImpressionStats extends React.PureComponent { + // This checks if the given cards are the same as those in the last impression ping. + // If so, it should not send the same impression ping again. + _needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchImpressionStats() { + const { props } = this; + const cards = props.rows; + + if (this.props.flightId) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.DISCOVERY_STREAM_SPOC_IMPRESSION, + data: { flightId: this.props.flightId }, + }) + ); + + // Record sponsored topsites impressions if the source is `TOP_SITES_SOURCE`. + if (this.props.source === TOP_SITES_SOURCE) { + for (const card of cards) { + this.props.dispatch( + ac.OnlyToMain({ + type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS, + data: { + type: "impression", + tile_id: card.id, + source: "newtab", + advertiser: card.advertiser, + // Keep the 0-based position, can be adjusted by the telemetry + // sender if necessary. + position: card.pos, + }, + }) + ); + } + } + } + + if (this._needsImpressionStats(cards)) { + props.dispatch( + ac.DiscoveryStreamImpressionStats({ + source: props.source.toUpperCase(), + window_inner_width: window.innerWidth, + window_inner_height: window.innerHeight, + tiles: cards.map(link => ({ + id: link.id, + pos: link.pos, + type: this.props.flightId ? "spoc" : "organic", + ...(link.shim ? { shim: link.shim } : {}), + })), + }) + ); + this.impressionCardGuids = cards.map(link => link.id); + } + } + + // This checks if the given cards are the same as those in the last loaded content ping. + // If so, it should not send the same loaded content ping again. + _needsLoadedContent(cards) { + if ( + !this.loadedContentGuids || + this.loadedContentGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].id !== this.loadedContentGuids[i]) { + return true; + } + } + + return false; + } + + _dispatchLoadedContent() { + const { props } = this; + const cards = props.rows; + + if (this._needsLoadedContent(cards)) { + props.dispatch( + ac.DiscoveryStreamLoadedContent({ + source: props.source.toUpperCase(), + tiles: cards.map(link => ({ id: link.id, pos: link.pos })), + }) + ); + this.loadedContentGuids = cards.map(link => link.id); + } + } + + setImpressionObserverOrAddListener() { + const { props } = this; + + if (!props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + 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) { + // Send the loaded content ping once the page is visible. + this._dispatchLoadedContent(); + 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.rows.length) { + return; + } + + this._handleIntersect = entries => { + if ( + entries.some( + entry => + entry.isIntersecting && + entry.intersectionRatio >= INTERSECTION_RATIO + ) + ) { + this._dispatchImpressionStats(); + this.impressionObserver.unobserve(this.refs.impression); + } + }; + + const options = { threshold: INTERSECTION_RATIO }; + this.impressionObserver = new props.IntersectionObserver( + this._handleIntersect, + options + ); + this.impressionObserver.observe(this.refs.impression); + } + + componentDidMount() { + if (this.props.rows.length) { + this.setImpressionObserverOrAddListener(); + } + } + + componentWillUnmount() { + if (this._handleIntersect && this.impressionObserver) { + this.impressionObserver.unobserve(this.refs.impression); + } + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + render() { + return ( + <div ref={"impression"} className="impression-observer"> + {this.props.children} + </div> + ); + } +} + +ImpressionStats.defaultProps = { + IntersectionObserver: global.IntersectionObserver, + document: global.document, + rows: [], + source: "", +}; diff --git a/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss new file mode 100644 index 0000000000..943e4e34a9 --- /dev/null +++ b/browser/components/newtab/content-src/components/DiscoveryStreamImpressionStats/_ImpressionStats.scss @@ -0,0 +1,7 @@ +.impression-observer { + position: absolute; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; +} diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx new file mode 100644 index 0000000000..1834a0a521 --- /dev/null +++ b/browser/components/newtab/content-src/components/ErrorBoundary/ErrorBoundary.jsx @@ -0,0 +1,68 @@ +/* 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 { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton"; +import React from "react"; + +export class ErrorBoundaryFallback extends React.PureComponent { + constructor(props) { + super(props); + this.windowObj = this.props.windowObj || window; + this.onClick = this.onClick.bind(this); + } + + /** + * Since we only get here if part of the page has crashed, do a + * forced reload to give us the best chance at recovering. + */ + onClick() { + this.windowObj.location.reload(true); + } + + render() { + const defaultClass = "as-error-fallback"; + let className; + if ("className" in this.props) { + className = `${this.props.className} ${defaultClass}`; + } else { + className = defaultClass; + } + + // "A11yLinkButton" to force normal link styling stuff (eg cursor on hover) + return ( + <div className={className}> + <div data-l10n-id="newtab-error-fallback-info" /> + <span> + <A11yLinkButton + className="reload-button" + onClick={this.onClick} + data-l10n-id="newtab-error-fallback-refresh-link" + /> + </span> + </div> + ); + } +} +ErrorBoundaryFallback.defaultProps = { className: "as-error-fallback" }; + +export class ErrorBoundary extends React.PureComponent { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + componentDidCatch(error, info) { + this.setState({ hasError: true }); + } + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + return <this.props.FallbackComponent className={this.props.className} />; + } +} + +ErrorBoundary.defaultProps = { FallbackComponent: ErrorBoundaryFallback }; diff --git a/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss new file mode 100644 index 0000000000..cc54f78a27 --- /dev/null +++ b/browser/components/newtab/content-src/components/ErrorBoundary/_ErrorBoundary.scss @@ -0,0 +1,21 @@ +.as-error-fallback { + align-items: center; + border-radius: $border-radius; + box-shadow: inset $inner-box-shadow; + color: var(--newtab-text-secondary-color); + display: flex; + flex-direction: column; + font-size: $error-fallback-font-size; + justify-content: center; + justify-items: center; + line-height: $error-fallback-line-height; + + &.borderless-error { + box-shadow: none; + } + + a { + color: var(--newtab-text-secondary-color); + text-decoration: underline; + } +} diff --git a/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx new file mode 100644 index 0000000000..583a5e4a01 --- /dev/null +++ b/browser/components/newtab/content-src/components/FluentOrText/FluentOrText.jsx @@ -0,0 +1,36 @@ +/* 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"; + +/** + * Set text on a child element/component depending on if the message is already + * translated plain text or a fluent id with optional args. + */ +export class FluentOrText extends React.PureComponent { + render() { + // Ensure we have a single child to attach attributes + const { children, message } = this.props; + const child = children ? React.Children.only(children) : <span />; + + // For a string message, just use it as the child's text + let grandChildren = message; + let extraProps; + + // Convert a message object to set desired fluent-dom attributes + if (typeof message === "object") { + const args = message.args || message.values; + extraProps = { + "data-l10n-args": args && JSON.stringify(args), + "data-l10n-id": message.id || message.string_id, + }; + + // Use original children potentially with data-l10n-name attributes + grandChildren = child.props.children; + } + + // Add the message to the child via fluent attributes or text node + return React.cloneElement(child, extraProps, grandChildren); + } +} diff --git a/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx new file mode 100644 index 0000000000..650a03eb95 --- /dev/null +++ b/browser/components/newtab/content-src/components/LinkMenu/LinkMenu.jsx @@ -0,0 +1,110 @@ +/* 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 { connect } from "react-redux"; +import { ContextMenu } from "content-src/components/ContextMenu/ContextMenu"; +import { LinkMenuOptions } from "content-src/lib/link-menu-options"; +import React from "react"; + +const DEFAULT_SITE_MENU_OPTIONS = [ + "CheckPinTopSite", + "EditTopSite", + "Separator", + "OpenInNewWindow", + "OpenInPrivateWindow", + "Separator", + "BlockUrl", +]; + +export class _LinkMenu extends React.PureComponent { + getOptions() { + const { props } = this; + const { + site, + index, + source, + isPrivateBrowsingEnabled, + siteInfo, + platform, + userEvent = ac.UserEvent, + } = props; + + // Handle special case of default site + const propOptions = + site.isDefault && !site.searchTopSite && !site.sponsored_position + ? DEFAULT_SITE_MENU_OPTIONS + : props.options; + + const options = propOptions + .map(o => + LinkMenuOptions[o]( + site, + index, + source, + isPrivateBrowsingEnabled, + siteInfo, + platform + ) + ) + .map(option => { + const { action, impression, id, type, userEvent: eventName } = option; + if (!type && id) { + option.onClick = (event = {}) => { + const { ctrlKey, metaKey, shiftKey, button } = event; + // Only send along event info if there's something non-default to send + if (ctrlKey || metaKey || shiftKey || button === 1) { + action.data = Object.assign( + { + event: { ctrlKey, metaKey, shiftKey, button }, + }, + action.data + ); + } + props.dispatch(action); + if (eventName) { + const userEventData = Object.assign( + { + event: eventName, + source, + action_position: index, + value: { card_type: site.flight_id ? "spoc" : "organic" }, + }, + siteInfo + ); + props.dispatch(userEvent(userEventData)); + } + if (impression && props.shouldSendImpressionStats) { + props.dispatch(impression); + } + }; + } + return option; + }); + + // This is for accessibility to support making each item tabbable. + // We want to know which item is the first and which item + // is the last, so we can close the context menu accordingly. + options[0].first = true; + options[options.length - 1].last = true; + return options; + } + + render() { + return ( + <ContextMenu + onUpdate={this.props.onUpdate} + onShow={this.props.onShow} + options={this.getOptions()} + keyboardAccess={this.props.keyboardAccess} + /> + ); + } +} + +const getState = state => ({ + isPrivateBrowsingEnabled: state.Prefs.values.isPrivateBrowsingEnabled, + platform: state.Prefs.values.platform, +}); +export const LinkMenu = connect(getState)(_LinkMenu); diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx new file mode 100644 index 0000000000..f2c332e5bd --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/MoreRecommendations.jsx @@ -0,0 +1,21 @@ +/* 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 MoreRecommendations extends React.PureComponent { + render() { + const { read_more_endpoint } = this.props; + if (read_more_endpoint) { + return ( + <a + className="more-recommendations" + href={read_more_endpoint} + data-l10n-id="newtab-pocket-more-recommendations" + /> + ); + } + return null; + } +} diff --git a/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss new file mode 100644 index 0000000000..12a66b7c11 --- /dev/null +++ b/browser/components/newtab/content-src/components/MoreRecommendations/_MoreRecommendations.scss @@ -0,0 +1,24 @@ +@use 'sass:math'; + +.more-recommendations { + display: flex; + align-items: center; + white-space: nowrap; + line-height: math.div(16, 13); // (16 / 13) -> 16px computed + + &::after { + background: url('chrome://global/skin/icons/arrow-right-12.svg') no-repeat center center; + content: ''; + -moz-context-properties: fill; + display: inline-block; + fill: var(--newtab-primary-action-background); + height: 16px; + margin-inline-start: 5px; + vertical-align: top; + width: 12px; + } + + &:dir(rtl)::after { + background-image: url('chrome://global/skin/icons/arrow-left-12.svg'); + } +} diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx new file mode 100644 index 0000000000..53c22f319c --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/PocketLoggedInCta.jsx @@ -0,0 +1,42 @@ +/* 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 { connect } from "react-redux"; +import React from "react"; + +export class _PocketLoggedInCta extends React.PureComponent { + render() { + const { pocketCta } = this.props.Pocket; + return ( + <span className="pocket-logged-in-cta"> + <a + className="pocket-cta-button" + href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"} + > + {pocketCta.ctaButton ? ( + pocketCta.ctaButton + ) : ( + <span data-l10n-id="newtab-pocket-cta-button" /> + )} + </a> + + <a + href={pocketCta.ctaUrl ? pocketCta.ctaUrl : "https://getpocket.com/"} + > + <span className="cta-text"> + {pocketCta.ctaText ? ( + pocketCta.ctaText + ) : ( + <span data-l10n-id="newtab-pocket-cta-text" /> + )} + </span> + </a> + </span> + ); + } +} + +export const PocketLoggedInCta = connect(state => ({ Pocket: state.Pocket }))( + _PocketLoggedInCta +); diff --git a/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss new file mode 100644 index 0000000000..e1eed58e9c --- /dev/null +++ b/browser/components/newtab/content-src/components/PocketLoggedInCta/_PocketLoggedInCta.scss @@ -0,0 +1,42 @@ +@use 'sass:math'; + +.pocket-logged-in-cta { + $max-button-width: 130px; + $min-button-height: 18px; + + font-size: 13px; + margin-inline-end: 20px; + display: flex; + align-items: flex-start; + + .pocket-cta-button { + white-space: nowrap; + background: var(--newtab-primary-action-background); + letter-spacing: -0.34px; + color: $white; + border-radius: 4px; + cursor: pointer; + max-width: $max-button-width; + // The button height is 2px taller than the rest of the cta text. + // So I move it up by 1px to align with the rest of the cta text. + margin-top: -1px; + min-height: $min-button-height; + padding: 0 8px; + display: inline-flex; + justify-content: center; + align-items: center; + font-size: 11px; + margin-inline-end: 10px; + } + + .cta-text { + font-weight: normal; + font-size: 13px; + line-height: math.div(16, 13); // (16 / 13) -> 16px computed + } + + .pocket-cta-button, + .cta-text { + vertical-align: top; + } +} diff --git a/browser/components/newtab/content-src/components/Search/Search.jsx b/browser/components/newtab/content-src/components/Search/Search.jsx new file mode 100644 index 0000000000..b131c884c1 --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/Search.jsx @@ -0,0 +1,223 @@ +/* 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/. */ + +/* globals ContentSearchUIController, ContentSearchHandoffUIController */ +"use strict"; + +import { + actionCreators as ac, + actionTypes as at, +} from "common/Actions.sys.mjs"; +import { connect } from "react-redux"; +import { IS_NEWTAB } from "content-src/lib/constants"; +import React from "react"; + +export class _Search extends React.PureComponent { + constructor(props) { + super(props); + this.onSearchClick = this.onSearchClick.bind(this); + this.onSearchHandoffClick = this.onSearchHandoffClick.bind(this); + this.onSearchHandoffPaste = this.onSearchHandoffPaste.bind(this); + this.onSearchHandoffDrop = this.onSearchHandoffDrop.bind(this); + this.onInputMount = this.onInputMount.bind(this); + this.onInputMountHandoff = this.onInputMountHandoff.bind(this); + this.onSearchHandoffButtonMount = + this.onSearchHandoffButtonMount.bind(this); + } + + handleEvent(event) { + // Also track search events with our own telemetry + if (event.detail.type === "Search") { + this.props.dispatch(ac.UserEvent({ event: "SEARCH" })); + } + } + + onSearchClick(event) { + window.gContentSearchController.search(event); + } + + doSearchHandoff(text) { + this.props.dispatch( + ac.OnlyToMain({ type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, data: { text } }) + ); + this.props.dispatch({ type: at.FAKE_FOCUS_SEARCH }); + this.props.dispatch(ac.UserEvent({ event: "SEARCH_HANDOFF" })); + if (text) { + this.props.dispatch({ type: at.DISABLE_SEARCH }); + } + } + + onSearchHandoffClick(event) { + // When search hand-off is enabled, we render a big button that is styled to + // look like a search textbox. If the button is clicked, we style + // the button as if it was a focused search box and show a fake cursor but + // really focus the awesomebar without the focus styles ("hidden focus"). + event.preventDefault(); + this.doSearchHandoff(); + } + + onSearchHandoffPaste(event) { + event.preventDefault(); + this.doSearchHandoff(event.clipboardData.getData("Text")); + } + + onSearchHandoffDrop(event) { + event.preventDefault(); + let text = event.dataTransfer.getData("text"); + if (text) { + this.doSearchHandoff(text); + } + } + + componentWillUnmount() { + delete window.gContentSearchController; + } + + onInputMount(input) { + if (input) { + // The "healthReportKey" and needs to be "newtab" or "abouthome" so that + // BrowserUsageTelemetry.jsm knows to handle events with this name, and + // can add the appropriate telemetry probes for search. Without the correct + // name, certain tests like browser_UsageTelemetry_content.js will fail + // (See github ticket #2348 for more details) + const healthReportKey = IS_NEWTAB ? "newtab" : "abouthome"; + + // The "searchSource" needs to be "newtab" or "homepage" and is sent with + // the search data and acts as context for the search request (See + // nsISearchEngine.getSubmission). It is necessary so that search engine + // plugins can correctly atribute referrals. (See github ticket #3321 for + // more details) + const searchSource = IS_NEWTAB ? "newtab" : "homepage"; + + // gContentSearchController needs to exist as a global so that tests for + // the existing about:home can find it; and so it allows these tests to pass. + // In the future, when activity stream is default about:home, this can be renamed + window.gContentSearchController = new ContentSearchUIController( + input, + input.parentNode, + healthReportKey, + searchSource + ); + addEventListener("ContentSearchClient", this); + } else { + window.gContentSearchController = null; + removeEventListener("ContentSearchClient", this); + } + } + + onInputMountHandoff(input) { + if (input) { + // The handoff UI controller helps us set the search icon and reacts to + // changes to default engine to keep everything in sync. + this._handoffSearchController = new ContentSearchHandoffUIController(); + } + } + + getDefaultEngineName() { + // _handoffSearchController will manage engine names once it is initialized. + return this.props.Prefs.values["urlbar.placeholderName"]; + } + + getHandoffInputL10nAttributes() { + let defaultEngineName = this.getDefaultEngineName(); + return defaultEngineName + ? { + "data-l10n-id": "newtab-search-box-handoff-input", + "data-l10n-args": `{"engine": "${defaultEngineName}"}`, + } + : { + "data-l10n-id": "newtab-search-box-handoff-input-no-engine", + }; + } + + getHandoffTextL10nAttributes() { + let defaultEngineName = this.getDefaultEngineName(); + return defaultEngineName + ? { + "data-l10n-id": "newtab-search-box-handoff-text", + "data-l10n-args": `{"engine": "${defaultEngineName}"}`, + } + : { + "data-l10n-id": "newtab-search-box-handoff-text-no-engine", + }; + } + + onSearchHandoffButtonMount(button) { + // Keep a reference to the button for use during "paste" event handling. + this._searchHandoffButton = button; + } + + /* + * Do not change the ID on the input field, as legacy newtab code + * specifically looks for the id 'newtab-search-text' on input fields + * in order to execute searches in various tests + */ + render() { + const wrapperClassName = [ + "search-wrapper", + this.props.disable && "search-disabled", + this.props.fakeFocus && "fake-focus", + ] + .filter(v => v) + .join(" "); + + return ( + <div className={wrapperClassName}> + {this.props.showLogo && ( + <div className="logo-and-wordmark"> + <div className="logo" /> + <div className="wordmark" /> + </div> + )} + {!this.props.handoffEnabled && ( + <div className="search-inner-wrapper"> + <input + id="newtab-search-text" + data-l10n-id="newtab-search-box-input" + maxLength="256" + ref={this.onInputMount} + type="search" + /> + <button + id="searchSubmit" + className="search-button" + data-l10n-id="newtab-search-box-search-button" + onClick={this.onSearchClick} + /> + </div> + )} + {this.props.handoffEnabled && ( + <div className="search-inner-wrapper"> + <button + className="search-handoff-button" + {...this.getHandoffInputL10nAttributes()} + ref={this.onSearchHandoffButtonMount} + onClick={this.onSearchHandoffClick} + tabIndex="-1" + > + <div + className="fake-textbox" + {...this.getHandoffTextL10nAttributes()} + /> + <input + type="search" + className="fake-editable" + tabIndex="-1" + aria-hidden="true" + onDrop={this.onSearchHandoffDrop} + onPaste={this.onSearchHandoffPaste} + ref={this.onInputMountHandoff} + /> + <div className="fake-caret" /> + </button> + </div> + )} + </div> + ); + } +} + +export const Search = connect(state => ({ + Prefs: state.Prefs, +}))(_Search); diff --git a/browser/components/newtab/content-src/components/Search/_Search.scss b/browser/components/newtab/content-src/components/Search/_Search.scss new file mode 100644 index 0000000000..189198a16c --- /dev/null +++ b/browser/components/newtab/content-src/components/Search/_Search.scss @@ -0,0 +1,412 @@ +$search-height: 48px; +$search-height-new: 52px; +$search-icon-size: 24px; +$search-icon-padding: 16px; +$search-icon-width: 2 * $search-icon-padding + $search-icon-size - 4px; +$search-button-width: 48px; +$glyph-forward: url('chrome://browser/skin/forward.svg'); + +.search-wrapper { + padding: 34px 0 38px; + + .only-search & { + padding: 0 0 38px; + } + + .logo-and-wordmark { + $logo-size: 82px; + $wordmark-size: 134px; + + align-items: center; + display: flex; + justify-content: center; + margin-bottom: 48px; + + .logo { + display: inline-block; + height: $logo-size; + width: $logo-size; + background: url('chrome://branding/content/about-logo.png') no-repeat center; + background-size: $logo-size; + + @media (min-resolution: 2x) { + background-image: url('chrome://branding/content/about-logo@2x.png'); + } + } + + .wordmark { + background: url('chrome://branding/content/firefox-wordmark.svg') no-repeat center center; + background-size: $wordmark-size; + -moz-context-properties: fill; + display: inline-block; + fill: var(--newtab-wordmark-color); + height: $logo-size; + margin-inline-start: 16px; + width: $wordmark-size; + } + + @media (max-width: $break-point-medium - 1) { + $logo-size-small: 64px; + $wordmark-small-size: 100px; + + .logo { + background-size: $logo-size-small; + height: $logo-size-small; + width: $logo-size-small; + } + + .wordmark { + background-size: $wordmark-small-size; + height: $logo-size-small; + width: $wordmark-small-size; + margin-inline-start: 12px; + } + } + } + + .search-inner-wrapper { + cursor: default; + display: flex; + min-height: $search-height-new; + margin: 0 auto; + position: relative; + width: $searchbar-width-small; + + @media (min-width: $break-point-medium) { + width: $searchbar-width-medium; + } + + @media (min-width: $break-point-large) { + width: $searchbar-width-large; + } + + @media (min-width: $break-point-widest) { + width: $searchbar-width-largest; + } + } + + .search-handoff-button, + input { + background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + padding-inline-start: $search-icon-width; + padding-inline-end: 10px; + padding-block: 0; + width: 100%; + box-shadow: $shadow-card; + border: 1px solid transparent; + border-radius: 8px; + color: var(--newtab-text-primary-color); + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + .search-inner-wrapper:active input, + input:focus { + border: 1px solid var(--newtab-primary-action-background); + outline: 0; + box-shadow: $shadow-focus; + } + + .search-button { + background: $glyph-forward no-repeat center center; + background-size: 16px 16px; + border: 0; + border-radius: 0 $border-radius $border-radius 0; + -moz-context-properties: fill; + fill: var(--newtab-text-secondary-color); + height: 100%; + inset-inline-end: 0; + position: absolute; + width: $search-button-width; + + &:focus, + &:hover { + background-color: var(--newtab-element-hover-color); + cursor: pointer; + } + + &:focus { + outline: 0; + box-shadow: $shadow-focus; + border: 1px solid var(--newtab-primary-action-background); + border-radius: 0 $border-radius-new $border-radius-new 0; + } + + &:active { + background-color: var(--newtab-element-hover-color); + } + + &:dir(rtl) { + transform: scaleX(-1); + } + } + + &.fake-focus:not(.search.disabled) { + .search-handoff-button { + border: 1px solid var(--newtab-primary-action-background); + box-shadow: $shadow-focus; + } + } + + .search-handoff-button { + padding-inline-end: 15px; + color: var(--newtab-text-primary-color); + fill: var(--newtab-text-secondary-color); + -moz-context-properties: fill; + + .fake-caret { + top: 18px; + inset-inline-start: $search-icon-width; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + } + + &.visible-logo { + .logo-and-wordmark { + .wordmark { + fill: var(--newtab-wordmark-color); + } + } + } +} + +.non-collapsible-section + .below-search-snippet-wrapper { + // If search is enabled, we need to invade its large bottom padding. + margin-top: -48px; +} + +@media (max-height: 700px) { + .search-wrapper { + padding: 0 0 30px; + } + + .non-collapsible-section + .below-search-snippet-wrapper { + // In shorter windows, search doesn't have such a large padding. + margin-top: -14px; + } + + .below-search-snippet-wrapper { + min-height: 0; + } +} + +.search-handoff-button { + background: var(--newtab-background-color-secondary) var(--newtab-search-icon) $search-icon-padding center no-repeat; + background-size: $search-icon-size; + border: solid 1px transparent; + border-radius: 3px; + box-shadow: $shadow-secondary, 0 0 0 1px $black-15; + cursor: text; + font-size: 15px; + padding: 0; + padding-inline-end: 48px; + padding-inline-start: 46px; + opacity: 1; + width: 100%; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + .fake-focus:not(.search-disabled) & { + border: $input-border-active; + box-shadow: var(--newtab-textbox-focus-boxshadow); + + .fake-caret { + display: block; + } + } + + .search-disabled & { + opacity: 0.5; + box-shadow: none; + } + + .fake-editable:focus { + outline: none; + caret-color: transparent; + } + + .fake-editable { + color: transparent; + height: 100%; + opacity: 0; + position: absolute; + inset: 0; + } + + .fake-textbox { + opacity: 0.54; + text-align: start; + } + + .fake-caret { + animation: caret-animation 1.3s steps(5, start) infinite; + background: var(--newtab-text-primary-color); + display: none; + inset-inline-start: 47px; + height: 17px; + position: absolute; + top: 16px; + width: 1px; + + @keyframes caret-animation { + to { + visibility: hidden; + } + } + } +} + +@media (min-height: 701px) { + body:not(.inline-onboarding) .fixed-search { + main { + padding-top: 124px; + } + + &.visible-logo { + main { + padding-top: 254px; + } + } + + .search-wrapper { + $search-height: 45px; + $search-icon-size: 24px; + $search-icon-padding: 16px; + $search-header-bar-height: 95px; + + border-bottom: solid 1px var(--newtab-border-color); + padding: 27px 0; + background-color: var(--newtab-overlay-color); + min-height: $search-header-bar-height; + left: 0; + position: fixed; + top: 0; + width: 100%; + z-index: 9; + + .search-inner-wrapper { + min-height: $search-height; + } + + input { + background-position-x: $search-icon-padding; + background-size: $search-icon-size; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + } + + .search-handoff-button .fake-caret { + top: 14px; + } + + .logo-and-wordmark { + display: none; + } + } + + .search-handoff-button { + background-position-x: $search-icon-padding; + background-size: $search-icon-size; + + &:dir(rtl) { + background-position-x: right $search-icon-padding; + } + + .fake-caret { + top: 10px; + } + } + } +} + +@at-root { + // Adjust the style of the contentSearchUI-generated table + .contentSearchSuggestionTable { + border: 0; + box-shadow: $context-menu-shadow; + transform: translateY($textbox-shadow-size); + background-color: var(--newtab-background-color); + + .contentSearchHeader { + color: var(--newtab-text-secondary-color); + background-color: var(--newtab-background-color-secondary); + } + + .contentSearchHeader, + .contentSearchSettingsButton { + border-color: var(--newtab-border-color); + } + + .contentSearchSuggestionsList { + color: var(--newtab-text-primary-color); + border: 0; + } + + .contentSearchOneOffsTable { + border-top: solid 1px var(--newtab-border-color); + background-color: var(--newtab-background-color); + } + + .contentSearchSearchWithHeaderSearchText { + color: var(--newtab-text-primary-color); + } + + .contentSearchSuggestionRow { + &.selected { + background: var(--newtab-element-hover-color); + color: var(--newtab-text-primary-color); + + &:active { + background: var(--newtab-element-active-color); + } + + .historyIcon { + fill: var(--newtab-text-secondary-color); + } + } + } + + .contentSearchOneOffItem { + // Make the border slightly shorter by offsetting from the top and bottom + $border-offset: 18%; + + background-image: none; + border-image: linear-gradient(transparent $border-offset, var(--newtab-border-color) $border-offset, var(--newtab-border-color) 100% - $border-offset, transparent 100% - $border-offset) 1; + border-inline-end: 1px solid; + position: relative; + + &.selected { + background: var(--newtab-element-hover-color); + } + + &:active { + background: var(--newtab-element-active-color); + } + } + + .contentSearchSettingsButton { + &:hover { + background: var(--newtab-element-hover-color); + color: var(--newtab-text-primary-color); + } + } + } + + .contentSearchHeaderRow > td > img, + .contentSearchSuggestionRow > td > .historyIcon { + margin-inline-start: 7px; + margin-inline-end: 15px; + } +} diff --git a/browser/components/newtab/content-src/components/Sections/Sections.jsx b/browser/components/newtab/content-src/components/Sections/Sections.jsx new file mode 100644 index 0000000000..e72e9145ad --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/Sections.jsx @@ -0,0 +1,378 @@ +/* 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 { Card, PlaceholderCard } from "content-src/components/Card/Card"; +import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection"; +import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer"; +import { FluentOrText } from "content-src/components/FluentOrText/FluentOrText"; +import { connect } from "react-redux"; +import { MoreRecommendations } from "content-src/components/MoreRecommendations/MoreRecommendations"; +import { PocketLoggedInCta } from "content-src/components/PocketLoggedInCta/PocketLoggedInCta"; +import React from "react"; +import { Topics } from "content-src/components/Topics/Topics"; +import { TopSites } from "content-src/components/TopSites/TopSites"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; +const CARDS_PER_ROW_DEFAULT = 3; +const CARDS_PER_ROW_COMPACT_WIDE = 4; + +export class Section extends React.PureComponent { + get numRows() { + const { rowsPref, maxRows, Prefs } = this.props; + return rowsPref ? Prefs.values[rowsPref] : maxRows; + } + + _dispatchImpressionStats() { + const { props } = this; + let cardsPerRow = CARDS_PER_ROW_DEFAULT; + if ( + props.compactCards && + global.matchMedia(`(min-width: 1072px)`).matches + ) { + // If the section has compact cards and the viewport is wide enough, we show + // 4 columns instead of 3. + // $break-point-widest = 1072px (from _variables.scss) + cardsPerRow = CARDS_PER_ROW_COMPACT_WIDE; + } + const maxCards = cardsPerRow * this.numRows; + const cards = props.rows.slice(0, maxCards); + + if (this.needsImpressionStats(cards)) { + props.dispatch( + ac.ImpressionStats({ + source: props.eventSource, + tiles: cards.map(link => ({ id: link.guid })), + }) + ); + this.impressionCardGuids = cards.map(link => link.guid); + } + } + + // This sends an event when a user sees a set of new content. If content + // changes while the page is hidden (i.e. preloaded or on a hidden tab), + // only send the event if the page becomes visible again. + sendImpressionStatsOrAddListener() { + const { props } = this; + + if (!props.shouldSendImpressionStats || !props.dispatch) { + return; + } + + if (props.document.visibilityState === VISIBLE) { + this._dispatchImpressionStats(); + } 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 + ); + } + + // When the page becomes visible, send the impression stats ping if the section isn't collapsed. + this._onVisibilityChange = () => { + if (props.document.visibilityState === VISIBLE) { + if (!this.props.pref.collapsed) { + this._dispatchImpressionStats(); + } + props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + }; + props.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + componentWillMount() { + this.sendNewTabRehydrated(this.props.initialized); + } + + componentDidMount() { + if (this.props.rows.length && !this.props.pref.collapsed) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentDidUpdate(prevProps) { + const { props } = this; + const isCollapsed = props.pref.collapsed; + const wasCollapsed = prevProps.pref.collapsed; + if ( + // Don't send impression stats for the empty state + props.rows.length && + // We only want to send impression stats if the content of the cards has changed + // and the section is not collapsed... + ((props.rows !== prevProps.rows && !isCollapsed) || + // or if we are expanding a section that was collapsed. + (wasCollapsed && !isCollapsed)) + ) { + this.sendImpressionStatsOrAddListener(); + } + } + + componentWillUpdate(nextProps) { + this.sendNewTabRehydrated(nextProps.initialized); + } + + componentWillUnmount() { + if (this._onVisibilityChange) { + this.props.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + needsImpressionStats(cards) { + if ( + !this.impressionCardGuids || + this.impressionCardGuids.length !== cards.length + ) { + return true; + } + + for (let i = 0; i < cards.length; i++) { + if (cards[i].guid !== this.impressionCardGuids[i]) { + return true; + } + } + + return false; + } + + // The NEW_TAB_REHYDRATED event is used to inform feeds that their + // data has been consumed e.g. for counting the number of tabs that + // have rendered that data. + sendNewTabRehydrated(initialized) { + if (initialized && !this.renderNotified) { + this.props.dispatch( + ac.AlsoToMain({ type: at.NEW_TAB_REHYDRATED, data: {} }) + ); + this.renderNotified = true; + } + } + + render() { + const { + id, + eventSource, + title, + rows, + Pocket, + topics, + emptyState, + dispatch, + compactCards, + read_more_endpoint, + contextMenuOptions, + initialized, + learnMore, + pref, + privacyNoticeURL, + isFirst, + isLast, + } = this.props; + + const waitingForSpoc = + id === "topstories" && this.props.Pocket.waitingForSpoc; + const maxCardsPerRow = compactCards + ? CARDS_PER_ROW_COMPACT_WIDE + : CARDS_PER_ROW_DEFAULT; + const { numRows } = this; + const maxCards = maxCardsPerRow * numRows; + const maxCardsOnNarrow = CARDS_PER_ROW_DEFAULT * numRows; + + const { pocketCta, isUserLoggedIn } = Pocket || {}; + const { useCta } = pocketCta || {}; + + // Don't display anything until we have a definitve result from Pocket, + // to avoid a flash of logged out state while we render. + const isPocketLoggedInDefined = + isUserLoggedIn === true || isUserLoggedIn === false; + + const hasTopics = topics && !!topics.length; + + const shouldShowPocketCta = + id === "topstories" && useCta && isUserLoggedIn === false; + + // Show topics only for top stories and if it has loaded with topics. + // The classs .top-stories-bottom-container ensures content doesn't shift as things load. + const shouldShowTopics = + id === "topstories" && + hasTopics && + ((useCta && isUserLoggedIn === true) || + (!useCta && isPocketLoggedInDefined)); + + // We use topics to determine language support for read more. + const shouldShowReadMore = read_more_endpoint && hasTopics; + + const realRows = rows.slice(0, maxCards); + + // The empty state should only be shown after we have initialized and there is no content. + // Otherwise, we should show placeholders. + const shouldShowEmptyState = initialized && !rows.length; + + const cards = []; + if (!shouldShowEmptyState) { + for (let i = 0; i < maxCards; i++) { + const link = realRows[i]; + // On narrow viewports, we only show 3 cards per row. We'll mark the rest as + // .hide-for-narrow to hide in CSS via @media query. + const className = i >= maxCardsOnNarrow ? "hide-for-narrow" : ""; + let usePlaceholder = !link; + // If we are in the third card and waiting for spoc, + // use the placeholder. + if (!usePlaceholder && i === 2 && waitingForSpoc) { + usePlaceholder = true; + } + cards.push( + !usePlaceholder ? ( + <Card + key={i} + index={i} + className={className} + dispatch={dispatch} + link={link} + contextMenuOptions={contextMenuOptions} + eventSource={eventSource} + shouldSendImpressionStats={this.props.shouldSendImpressionStats} + isWebExtension={this.props.isWebExtension} + /> + ) : ( + <PlaceholderCard key={i} className={className} /> + ) + ); + } + } + + const sectionClassName = [ + "section", + compactCards ? "compact-cards" : "normal-cards", + ].join(" "); + + // <Section> <-- React component + // <section> <-- HTML5 element + return ( + <ComponentPerfTimer {...this.props}> + <CollapsibleSection + className={sectionClassName} + title={title} + id={id} + eventSource={eventSource} + collapsed={this.props.pref.collapsed} + showPrefName={(pref && pref.feed) || id} + privacyNoticeURL={privacyNoticeURL} + Prefs={this.props.Prefs} + isFixed={this.props.isFixed} + isFirst={isFirst} + isLast={isLast} + learnMore={learnMore} + dispatch={this.props.dispatch} + isWebExtension={this.props.isWebExtension} + > + {!shouldShowEmptyState && ( + <ul className="section-list" style={{ padding: 0 }}> + {cards} + </ul> + )} + {shouldShowEmptyState && ( + <div className="section-empty-state"> + <div className="empty-state"> + <FluentOrText message={emptyState.message}> + <p className="empty-state-message" /> + </FluentOrText> + </div> + </div> + )} + {id === "topstories" && ( + <div className="top-stories-bottom-container"> + {shouldShowTopics && ( + <div className="wrapper-topics"> + <Topics topics={this.props.topics} /> + </div> + )} + + {shouldShowPocketCta && ( + <div className="wrapper-cta"> + <PocketLoggedInCta /> + </div> + )} + + <div className="wrapper-more-recommendations"> + {shouldShowReadMore && ( + <MoreRecommendations + read_more_endpoint={read_more_endpoint} + /> + )} + </div> + </div> + )} + </CollapsibleSection> + </ComponentPerfTimer> + ); + } +} + +Section.defaultProps = { + document: global.document, + rows: [], + emptyState: {}, + pref: {}, + title: "", +}; + +export const SectionIntl = connect(state => ({ + Prefs: state.Prefs, + Pocket: state.Pocket, +}))(Section); + +export class _Sections extends React.PureComponent { + renderSections() { + const sections = []; + const enabledSections = this.props.Sections.filter( + section => section.enabled + ); + const { sectionOrder, "feeds.topsites": showTopSites } = + this.props.Prefs.values; + // Enabled sections doesn't include Top Sites, so we add it if enabled. + const expectedCount = enabledSections.length + ~~showTopSites; + + for (const sectionId of sectionOrder.split(",")) { + const commonProps = { + key: sectionId, + isFirst: sections.length === 0, + isLast: sections.length === expectedCount - 1, + }; + if (sectionId === "topsites" && showTopSites) { + sections.push(<TopSites {...commonProps} />); + } else { + const section = enabledSections.find(s => s.id === sectionId); + if (section) { + sections.push(<SectionIntl {...section} {...commonProps} />); + } + } + } + return sections; + } + + render() { + return <div className="sections-list">{this.renderSections()}</div>; + } +} + +export const Sections = connect(state => ({ + Sections: state.Sections, + Prefs: state.Prefs, +}))(_Sections); diff --git a/browser/components/newtab/content-src/components/Sections/_Sections.scss b/browser/components/newtab/content-src/components/Sections/_Sections.scss new file mode 100644 index 0000000000..e3fe15f762 --- /dev/null +++ b/browser/components/newtab/content-src/components/Sections/_Sections.scss @@ -0,0 +1,123 @@ +.sections-list { + .section-list { + display: grid; + grid-gap: $base-gutter; + grid-template-columns: repeat(auto-fit, $card-width); + margin: 0; + + @media (max-width: $break-point-medium) { + @include context-menu-open-left; + } + + @media (min-width: $break-point-medium) and (max-width: $break-point-large) { + :nth-child(2n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) { + :nth-child(3n) { + @include context-menu-open-left; + } + } + + @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) { + // 3n for normal cards, 4n for compact cards + :nth-child(3n), + :nth-child(4n) { + @include context-menu-open-left; + } + } + } + + .section-empty-state { + border: $border-secondary; + border-radius: $border-radius; + display: flex; + height: $card-height; + width: 100%; + + .empty-state { + margin: auto; + max-width: 350px; + + .empty-state-message { + color: var(--newtab-text-primary-color); + font-size: 13px; + margin-bottom: 0; + text-align: center; + } + } + + @media (min-width: $break-point-widest) { + height: $card-height-large; + } + } +} + +.top-stories-bottom-container { + color: var(--newtab-text-primary-color); + font-size: 12px; + line-height: 1.6; + margin-top: $topic-margin-top; + display: flex; + justify-content: space-between; + + a { + color: var(--newtab-primary-action-background); + font-weight: bold; + + &.more-recommendations { + font-weight: normal; + font-size: 13px; + } + } + + .wrapper-topics, + .wrapper-cta + .wrapper-more-recommendations { + @media (max-width: $break-point-large - 1) { + display: none; + } + } + + @media (max-width: $break-point-medium - 1) { + .wrapper-cta { + text-align: center; + + .pocket-logged-in-cta { + display: block; + margin-inline-end: 0; + + .pocket-cta-button { + max-width: none; + display: block; + margin-inline-end: 0; + margin: 5px 0 10px; + } + } + } + + .wrapper-more-recommendations { + width: 100%; + + .more-recommendations { + justify-content: center; + + &::after { + display: none; + } + } + } + } +} + +@media (min-width: $break-point-widest) { + .sections-list { + // Compact cards stay the same size but normal cards get bigger. + .normal-cards { + .section-list { + grid-template-columns: repeat(auto-fit, $card-width-large); + } + } + } +} 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..90641008be --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx @@ -0,0 +1,873 @@ +/* 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. + 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..fd1e3048a5 --- /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 "../../asrouter/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..b893b6b33e --- /dev/null +++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss @@ -0,0 +1,628 @@ +@use 'sass:math'; + +$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; + padding: 20px $half-base-gutter 4px; + border-radius: 8px; + display: inline-block; + + // container for context menu + .top-site-inner { + position: relative; + + > a { + 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: -9px; + 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; +} diff --git a/browser/components/newtab/content-src/components/Topics/Topics.jsx b/browser/components/newtab/content-src/components/Topics/Topics.jsx new file mode 100644 index 0000000000..ef59094c65 --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/Topics.jsx @@ -0,0 +1,33 @@ +/* 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 Topic extends React.PureComponent { + render() { + const { url, name } = this.props; + return ( + <li> + <a key={name} href={url}> + {name} + </a> + </li> + ); + } +} + +export class Topics extends React.PureComponent { + render() { + const { topics } = this.props; + return ( + <span className="topics"> + <span data-l10n-id="newtab-pocket-read-more" /> + <ul> + {topics && + topics.map(t => <Topic key={t.name} url={t.url} name={t.name} />)} + </ul> + </span> + ); + } +} diff --git a/browser/components/newtab/content-src/components/Topics/_Topics.scss b/browser/components/newtab/content-src/components/Topics/_Topics.scss new file mode 100644 index 0000000000..205f42e600 --- /dev/null +++ b/browser/components/newtab/content-src/components/Topics/_Topics.scss @@ -0,0 +1,24 @@ +.topics { + ul { + margin: 0; + padding: 0; + + @media (min-width: $break-point-large) { + display: inline; + padding-inline-start: 12px; + } + } + + ul li { + display: inline-block; + + &::after { + content: '•'; + padding: 8px; + } + + &:last-child::after { + content: none; + } + } +} |