diff options
Diffstat (limited to '')
8 files changed, 1126 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/lib/aboutwelcome-utils.js b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js new file mode 100644 index 0000000000..840aee0f19 --- /dev/null +++ b/browser/components/newtab/content-src/lib/aboutwelcome-utils.js @@ -0,0 +1,119 @@ +/* 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/. */ + +// If the container has a "page" data attribute, then this is +// a Spotlight modal or Feature Callout. Otherwise, this is +// about:welcome and we should return the current page. +const page = + document.querySelector( + "#multi-stage-message-root.onboardingContainer[data-page]" + )?.dataset.page || document.location.href; + +export const AboutWelcomeUtils = { + handleUserAction(action) { + return window.AWSendToParent("SPECIAL_ACTION", action); + }, + sendImpressionTelemetry(messageId, context) { + window.AWSendEventTelemetry?.({ + event: "IMPRESSION", + event_context: { + ...context, + page, + }, + message_id: messageId, + }); + }, + sendActionTelemetry(messageId, elementId, eventName = "CLICK_BUTTON") { + const ping = { + event: eventName, + event_context: { + source: elementId, + page, + }, + message_id: messageId, + }; + window.AWSendEventTelemetry?.(ping); + }, + sendDismissTelemetry(messageId, elementId) { + // Don't send DISMISS telemetry in spotlight modals since they already send + // their own equivalent telemetry. + if (page !== "spotlight") { + this.sendActionTelemetry(messageId, elementId, "DISMISS"); + } + }, + async fetchFlowParams(metricsFlowUri) { + let flowParams; + try { + const response = await fetch(metricsFlowUri, { + credentials: "omit", + }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + flowParams = { deviceId, flowId, flowBeginTime }; + } else { + console.error("Non-200 response", response); + } + } catch (e) { + flowParams = null; + } + return flowParams; + }, + sendEvent(type, detail) { + document.dispatchEvent( + new CustomEvent(`AWPage:${type}`, { + bubbles: true, + detail, + }) + ); + }, +}; + +export const DEFAULT_RTAMO_CONTENT = { + template: "return_to_amo", + utm_term: "rtamo", + content: { + position: "split", + title: { string_id: "mr1-return-to-amo-subtitle" }, + has_noodles: false, + subtitle: { + string_id: "mr1-return-to-amo-addon-title", + }, + backdrop: + "var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-rtamo-background-image.svg') no-repeat center", + progress_bar: true, + primary_button: { + label: { string_id: "mr1-return-to-amo-add-extension-label" }, + source_id: "ADD_EXTENSION_BUTTON", + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: null, telemetrySource: "rtamo" }, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + source_id: "RTAMO_START_BROWSING_BUTTON", + action: { + type: "OPEN_AWESOME_BAR", + }, + }, + secondary_button_top: { + label: { + string_id: "mr1-onboarding-sign-in-button-label", + }, + source_id: "RTAMO_FXA_SIGNIN_BUTTON", + action: { + data: { + entrypoint: "activity-stream-firstrun", + where: "tab", + }, + type: "SHOW_FIREFOX_ACCOUNTS", + addFlowParams: true, + }, + }, + }, +}; diff --git a/browser/components/newtab/content-src/lib/constants.js b/browser/components/newtab/content-src/lib/constants.js new file mode 100644 index 0000000000..2c96160b4b --- /dev/null +++ b/browser/components/newtab/content-src/lib/constants.js @@ -0,0 +1,38 @@ +/* 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 IS_NEWTAB = + global.document && global.document.documentURI === "about:newtab"; +export const NEWTAB_DARK_THEME = { + ntp_background: { + r: 42, + g: 42, + b: 46, + a: 1, + }, + ntp_card_background: { + r: 66, + g: 65, + b: 77, + a: 1, + }, + ntp_text: { + r: 249, + g: 249, + b: 250, + a: 1, + }, + sidebar: { + r: 56, + g: 56, + b: 61, + a: 1, + }, + sidebar_text: { + r: 249, + g: 249, + b: 250, + a: 1, + }, +}; diff --git a/browser/components/newtab/content-src/lib/detect-user-session-start.js b/browser/components/newtab/content-src/lib/detect-user-session-start.js new file mode 100644 index 0000000000..43aa388967 --- /dev/null +++ b/browser/components/newtab/content-src/lib/detect-user-session-start.js @@ -0,0 +1,82 @@ +/* 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"; + +const VISIBLE = "visible"; +const VISIBILITY_CHANGE_EVENT = "visibilitychange"; + +export class DetectUserSessionStart { + constructor(store, options = {}) { + this._store = store; + // Overrides for testing + this.document = options.document || global.document; + this._perfService = options.perfService || perfSvc; + this._onVisibilityChange = this._onVisibilityChange.bind(this); + } + + /** + * sendEventOrAddListener - Notify immediately if the page is already visible, + * or else set up a listener for when visibility changes. + * This is needed for accurate session tracking for telemetry, + * because tabs are pre-loaded. + */ + sendEventOrAddListener() { + if (this.document.visibilityState === VISIBLE) { + // If the document is already visible, to the user, send a notification + // immediately that a session has started. + this._sendEvent(); + } else { + // If the document is not visible, listen for when it does become visible. + this.document.addEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } + + /** + * _sendEvent - Sends a message to the main process to indicate the current + * tab is now visible to the user, includes the + * visibility_event_rcvd_ts time in ms from the UNIX epoch. + */ + _sendEvent() { + this._perfService.mark("visibility_event_rcvd_ts"); + + try { + let visibility_event_rcvd_ts = + this._perfService.getMostRecentAbsMarkStartByName( + "visibility_event_rcvd_ts" + ); + + this._store.dispatch( + ac.AlsoToMain({ + type: at.SAVE_SESSION_PERF_DATA, + data: { visibility_event_rcvd_ts }, + }) + ); + } catch (ex) { + // If this failed, it's likely because the `privacy.resistFingerprinting` + // pref is true. We should at least not blow up. + } + } + + /** + * _onVisibilityChange - If the visibility has changed to visible, sends a notification + * and removes the event listener. This should only be called once per tab. + */ + _onVisibilityChange() { + if (this.document.visibilityState === VISIBLE) { + this._sendEvent(); + this.document.removeEventListener( + VISIBILITY_CHANGE_EVENT, + this._onVisibilityChange + ); + } + } +} diff --git a/browser/components/newtab/content-src/lib/init-store.js b/browser/components/newtab/content-src/lib/init-store.js new file mode 100644 index 0000000000..77ae5363bf --- /dev/null +++ b/browser/components/newtab/content-src/lib/init-store.js @@ -0,0 +1,175 @@ +/* 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/. */ + +/* eslint-env mozilla/remote-page */ + +import { + actionCreators as ac, + actionTypes as at, + actionUtils as au, +} from "common/Actions.sys.mjs"; +import { applyMiddleware, combineReducers, createStore } from "redux"; + +export const MERGE_STORE_ACTION = "NEW_TAB_INITIAL_STATE"; +export const OUTGOING_MESSAGE_NAME = "ActivityStream:ContentToMain"; +export const INCOMING_MESSAGE_NAME = "ActivityStream:MainToContent"; +export const EARLY_QUEUED_ACTIONS = [at.SAVE_SESSION_PERF_DATA]; + +/** + * A higher-order function which returns a reducer that, on MERGE_STORE action, + * will return the action.data object merged into the previous state. + * + * For all other actions, it merely calls mainReducer. + * + * Because we want this to merge the entire state object, it's written as a + * higher order function which takes the main reducer (itself often a call to + * combineReducers) as a parameter. + * + * @param {function} mainReducer reducer to call if action != MERGE_STORE_ACTION + * @return {function} a reducer that, on MERGE_STORE_ACTION action, + * will return the action.data object merged + * into the previous state, and the result + * of calling mainReducer otherwise. + */ +function mergeStateReducer(mainReducer) { + return (prevState, action) => { + if (action.type === MERGE_STORE_ACTION) { + return { ...prevState, ...action.data }; + } + + return mainReducer(prevState, action); + }; +} + +/** + * messageMiddleware - Middleware that looks for SentToMain type actions, and sends them if necessary + */ +const messageMiddleware = store => next => action => { + const skipLocal = action.meta && action.meta.skipLocal; + if (au.isSendToMain(action)) { + RPMSendAsyncMessage(OUTGOING_MESSAGE_NAME, action); + } + if (!skipLocal) { + next(action); + } +}; + +export const rehydrationMiddleware = ({ getState }) => { + // NB: The parameter here is MiddlewareAPI which looks like a Store and shares + // the same getState, so attached properties are accessible from the store. + getState.didRehydrate = false; + getState.didRequestInitialState = false; + return next => action => { + if (getState.didRehydrate || window.__FROM_STARTUP_CACHE__) { + // Startup messages can be safely ignored by the about:home document + // stored in the startup cache. + if ( + window.__FROM_STARTUP_CACHE__ && + action.meta && + action.meta.isStartup + ) { + return null; + } + return next(action); + } + + const isMergeStoreAction = action.type === MERGE_STORE_ACTION; + const isRehydrationRequest = action.type === at.NEW_TAB_STATE_REQUEST; + + if (isRehydrationRequest) { + getState.didRequestInitialState = true; + return next(action); + } + + if (isMergeStoreAction) { + getState.didRehydrate = true; + return next(action); + } + + // If init happened after our request was made, we need to re-request + if (getState.didRequestInitialState && action.type === at.INIT) { + return next(ac.AlsoToMain({ type: at.NEW_TAB_STATE_REQUEST })); + } + + if ( + au.isBroadcastToContent(action) || + au.isSendToOneContent(action) || + au.isSendToPreloaded(action) + ) { + // Note that actions received before didRehydrate will not be dispatched + // because this could negatively affect preloading and the the state + // will be replaced by rehydration anyway. + return null; + } + + return next(action); + }; +}; + +/** + * This middleware queues up all the EARLY_QUEUED_ACTIONS until it receives + * the first action from main. This is useful for those actions for main which + * require higher reliability, i.e. the action will not be lost in the case + * that it gets sent before the main is ready to receive it. Conversely, any + * actions allowed early are accepted to be ignorable or re-sendable. + */ +export const queueEarlyMessageMiddleware = ({ getState }) => { + // NB: The parameter here is MiddlewareAPI which looks like a Store and shares + // the same getState, so attached properties are accessible from the store. + getState.earlyActionQueue = []; + getState.receivedFromMain = false; + return next => action => { + if (getState.receivedFromMain) { + next(action); + } else if (au.isFromMain(action)) { + next(action); + getState.receivedFromMain = true; + // Sending out all the early actions as main is ready now + getState.earlyActionQueue.forEach(next); + getState.earlyActionQueue.length = 0; + } else if (EARLY_QUEUED_ACTIONS.includes(action.type)) { + getState.earlyActionQueue.push(action); + } else { + // Let any other type of action go through + next(action); + } + }; +}; + +/** + * initStore - Create a store and listen for incoming actions + * + * @param {object} reducers An object containing Redux reducers + * @param {object} intialState (optional) The initial state of the store, if desired + * @return {object} A redux store + */ +export function initStore(reducers, initialState) { + const store = createStore( + mergeStateReducer(combineReducers(reducers)), + initialState, + global.RPMAddMessageListener && + applyMiddleware( + queueEarlyMessageMiddleware, + rehydrationMiddleware, + messageMiddleware + ) + ); + + if (global.RPMAddMessageListener) { + global.RPMAddMessageListener(INCOMING_MESSAGE_NAME, msg => { + try { + store.dispatch(msg.data); + } catch (ex) { + console.error("Content msg:", msg, "Dispatch error: ", ex); + dump( + `Content msg: ${JSON.stringify(msg)}\nDispatch error: ${ex}\n${ + ex.stack + }` + ); + } + }); + } + + return store; +} diff --git a/browser/components/newtab/content-src/lib/link-menu-options.js b/browser/components/newtab/content-src/lib/link-menu-options.js new file mode 100644 index 0000000000..792522b52d --- /dev/null +++ b/browser/components/newtab/content-src/lib/link-menu-options.js @@ -0,0 +1,292 @@ +/* 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"; + +const _OpenInPrivateWindow = site => ({ + id: "newtab-menu-open-new-private-window", + icon: "new-window-private", + action: ac.OnlyToMain({ + type: at.OPEN_PRIVATE_WINDOW, + data: { url: site.url, referrer: site.referrer }, + }), + userEvent: "OPEN_PRIVATE_WINDOW", +}); + +/** + * List of functions that return items that can be included as menu options in a + * LinkMenu. All functions take the site as the first parameter, and optionally + * the index of the site. + */ +export const LinkMenuOptions = { + Separator: () => ({ type: "separator" }), + EmptyItem: () => ({ type: "empty" }), + ShowPrivacyInfo: site => ({ + id: "newtab-menu-show-privacy-info", + icon: "info", + action: { + type: at.SHOW_PRIVACY_INFO, + }, + userEvent: "SHOW_PRIVACY_INFO", + }), + AboutSponsored: site => ({ + id: "newtab-menu-show-privacy-info", + icon: "info", + action: ac.AlsoToMain({ + type: at.ABOUT_SPONSORED_TOP_SITES, + }), + userEvent: "TOPSITE_SPONSOR_INFO", + }), + RemoveBookmark: site => ({ + id: "newtab-menu-remove-bookmark", + icon: "bookmark-added", + action: ac.AlsoToMain({ + type: at.DELETE_BOOKMARK_BY_ID, + data: site.bookmarkGuid, + }), + userEvent: "BOOKMARK_DELETE", + }), + AddBookmark: site => ({ + id: "newtab-menu-bookmark", + icon: "bookmark-hollow", + action: ac.AlsoToMain({ + type: at.BOOKMARK_URL, + data: { url: site.url, title: site.title, type: site.type }, + }), + userEvent: "BOOKMARK_ADD", + }), + OpenInNewWindow: site => ({ + id: "newtab-menu-open-new-window", + icon: "new-window", + action: ac.AlsoToMain({ + type: at.OPEN_NEW_WINDOW, + data: { + referrer: site.referrer, + typedBonus: site.typedBonus, + url: site.url, + sponsored_tile_id: site.sponsored_tile_id, + }, + }), + userEvent: "OPEN_NEW_WINDOW", + }), + // This blocks the url for regular stories, + // but also sends a message to DiscoveryStream with flight_id. + // If DiscoveryStream sees this message for a flight_id + // it also blocks it on the flight_id. + BlockUrl: (site, index, eventSource) => { + return LinkMenuOptions.BlockUrls([site], index, eventSource); + }, + // Same as BlockUrl, cept can work on an array of sites. + BlockUrls: (tiles, pos, eventSource) => ({ + id: "newtab-menu-dismiss", + icon: "dismiss", + action: ac.AlsoToMain({ + type: at.BLOCK_URL, + data: tiles.map(site => ({ + url: site.original_url || site.open_url || site.url, + // pocket_id is only for pocket stories being in highlights, and then dismissed. + pocket_id: site.pocket_id, + // used by PlacesFeed and TopSitesFeed for sponsored top sites blocking. + isSponsoredTopSite: site.sponsored_position, + ...(site.flight_id ? { flight_id: site.flight_id } : {}), + })), + }), + impression: ac.ImpressionStats({ + source: eventSource, + block: 0, + tiles: tiles.map((site, index) => ({ + id: site.guid, + pos: pos + index, + ...(site.shim && site.shim.delete ? { shim: site.shim.delete } : {}), + })), + }), + userEvent: "BLOCK", + }), + + // This is an option for web extentions which will result in remove items from + // memory and notify the web extenion, rather than using the built-in block list. + WebExtDismiss: (site, index, eventSource) => ({ + id: "menu_action_webext_dismiss", + string_id: "newtab-menu-dismiss", + icon: "dismiss", + action: ac.WebExtEvent(at.WEBEXT_DISMISS, { + source: eventSource, + url: site.url, + action_position: index, + }), + }), + DeleteUrl: (site, index, eventSource, isEnabled, siteInfo) => ({ + id: "newtab-menu-delete-history", + icon: "delete", + action: { + type: at.DIALOG_OPEN, + data: { + onConfirm: [ + ac.AlsoToMain({ + type: at.DELETE_HISTORY_URL, + data: { + url: site.url, + pocket_id: site.pocket_id, + forceBlock: site.bookmarkGuid, + }, + }), + ac.UserEvent( + Object.assign( + { event: "DELETE", source: eventSource, action_position: index }, + siteInfo + ) + ), + ], + eventSource, + body_string_id: [ + "newtab-confirm-delete-history-p1", + "newtab-confirm-delete-history-p2", + ], + confirm_button_string_id: "newtab-topsites-delete-history-button", + cancel_button_string_id: "newtab-topsites-cancel-button", + icon: "modal-delete", + }, + }, + userEvent: "DIALOG_OPEN", + }), + ShowFile: site => ({ + id: "newtab-menu-show-file", + icon: "search", + action: ac.OnlyToMain({ + type: at.SHOW_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + OpenFile: site => ({ + id: "newtab-menu-open-file", + icon: "open-file", + action: ac.OnlyToMain({ + type: at.OPEN_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + CopyDownloadLink: site => ({ + id: "newtab-menu-copy-download-link", + icon: "copy", + action: ac.OnlyToMain({ + type: at.COPY_DOWNLOAD_LINK, + data: { url: site.url }, + }), + }), + GoToDownloadPage: site => ({ + id: "newtab-menu-go-to-download-page", + icon: "download", + action: ac.OnlyToMain({ + type: at.OPEN_LINK, + data: { url: site.referrer }, + }), + disabled: !site.referrer, + }), + RemoveDownload: site => ({ + id: "newtab-menu-remove-download", + icon: "delete", + action: ac.OnlyToMain({ + type: at.REMOVE_DOWNLOAD_FILE, + data: { url: site.url }, + }), + }), + PinTopSite: (site, index) => ({ + id: "newtab-menu-pin", + icon: "pin", + action: ac.AlsoToMain({ + type: at.TOP_SITES_PIN, + data: { + site, + index, + }, + }), + userEvent: "PIN", + }), + UnpinTopSite: site => ({ + id: "newtab-menu-unpin", + icon: "unpin", + action: ac.AlsoToMain({ + type: at.TOP_SITES_UNPIN, + data: { site: { url: site.url } }, + }), + userEvent: "UNPIN", + }), + SaveToPocket: (site, index, eventSource = "CARDGRID") => ({ + id: "newtab-menu-save-to-pocket", + icon: "pocket-save", + action: ac.AlsoToMain({ + type: at.SAVE_TO_POCKET, + data: { + site: { url: site.url, title: site.title }, + }, + }), + impression: ac.ImpressionStats({ + source: eventSource, + pocket: 0, + tiles: [ + { + id: site.guid, + pos: index, + ...(site.shim && site.shim.save ? { shim: site.shim.save } : {}), + }, + ], + }), + userEvent: "SAVE_TO_POCKET", + }), + DeleteFromPocket: site => ({ + id: "newtab-menu-delete-pocket", + icon: "pocket-delete", + action: ac.AlsoToMain({ + type: at.DELETE_FROM_POCKET, + data: { pocket_id: site.pocket_id }, + }), + userEvent: "DELETE_FROM_POCKET", + }), + ArchiveFromPocket: site => ({ + id: "newtab-menu-archive-pocket", + icon: "pocket-archive", + action: ac.AlsoToMain({ + type: at.ARCHIVE_FROM_POCKET, + data: { pocket_id: site.pocket_id }, + }), + userEvent: "ARCHIVE_FROM_POCKET", + }), + EditTopSite: (site, index) => ({ + id: "newtab-menu-edit-topsites", + icon: "edit", + action: { + type: at.TOP_SITES_EDIT, + data: { index }, + }, + }), + CheckBookmark: site => + site.bookmarkGuid + ? LinkMenuOptions.RemoveBookmark(site) + : LinkMenuOptions.AddBookmark(site), + CheckPinTopSite: (site, index) => + site.isPinned + ? LinkMenuOptions.UnpinTopSite(site) + : LinkMenuOptions.PinTopSite(site, index), + CheckSavedToPocket: (site, index, source) => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.SaveToPocket(site, index, source), + CheckBookmarkOrArchive: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.CheckBookmark(site), + CheckArchiveFromPocket: site => + site.pocket_id + ? LinkMenuOptions.ArchiveFromPocket(site) + : LinkMenuOptions.EmptyItem(), + CheckDeleteFromPocket: site => + site.pocket_id + ? LinkMenuOptions.DeleteFromPocket(site) + : LinkMenuOptions.EmptyItem(), + OpenInPrivateWindow: (site, index, eventSource, isEnabled) => + isEnabled ? _OpenInPrivateWindow(site) : LinkMenuOptions.EmptyItem(), +}; diff --git a/browser/components/newtab/content-src/lib/perf-service.js b/browser/components/newtab/content-src/lib/perf-service.js new file mode 100644 index 0000000000..6ea99ce877 --- /dev/null +++ b/browser/components/newtab/content-src/lib/perf-service.js @@ -0,0 +1,104 @@ +/* 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/. */ + +"use strict"; + +let usablePerfObj = window.performance; + +export function _PerfService(options) { + // For testing, so that we can use a fake Window.performance object with + // known state. + if (options && options.performanceObj) { + this._perf = options.performanceObj; + } else { + this._perf = usablePerfObj; + } +} + +_PerfService.prototype = { + /** + * Calls the underlying mark() method on the appropriate Window.performance + * object to add a mark with the given name to the appropriate performance + * timeline. + * + * @param {String} name the name to give the current mark + * @return {void} + */ + mark: function mark(str) { + this._perf.mark(str); + }, + + /** + * Calls the underlying getEntriesByName on the appropriate Window.performance + * object. + * + * @param {String} name + * @param {String} type eg "mark" + * @return {Array} Performance* objects + */ + getEntriesByName: function getEntriesByName(name, type) { + return this._perf.getEntriesByName(name, type); + }, + + /** + * The timeOrigin property from the appropriate performance object. + * Used to ensure that timestamps from the add-on code and the content code + * are comparable. + * + * @note If this is called from a context without a window + * (eg a JSM in chrome), it will return the timeOrigin of the XUL hidden + * window, which appears to be the first created window (and thus + * timeOrigin) in the browser. Note also, however, there is also a private + * hidden window, presumably for private browsing, which appears to be + * created dynamically later. Exactly how/when that shows up needs to be + * investigated. + * + * @return {Number} A double of milliseconds with a precision of 0.5us. + */ + get timeOrigin() { + return this._perf.timeOrigin; + }, + + /** + * Returns the "absolute" version of performance.now(), i.e. one that + * should ([bug 1401406](https://bugzilla.mozilla.org/show_bug.cgi?id=1401406) + * be comparable across both chrome and content. + * + * @return {Number} + */ + absNow: function absNow() { + return this.timeOrigin + this._perf.now(); + }, + + /** + * This returns the absolute startTime from the most recent performance.mark() + * with the given name. + * + * @param {String} name the name to lookup the start time for + * + * @return {Number} the returned start time, as a DOMHighResTimeStamp + * + * @throws {Error} "No Marks with the name ..." if none are available + * + * @note Always surround calls to this by try/catch. Otherwise your code + * may fail when the `privacy.resistFingerprinting` pref is true. When + * this pref is set, all attempts to get marks will likely fail, which will + * cause this method to throw. + * + * See [bug 1369303](https://bugzilla.mozilla.org/show_bug.cgi?id=1369303) + * for more info. + */ + getMostRecentAbsMarkStartByName(name) { + let entries = this.getEntriesByName(name, "mark"); + + if (!entries.length) { + throw new Error(`No marks with the name ${name}`); + } + + let mostRecentEntry = entries[entries.length - 1]; + return this._perf.timeOrigin + mostRecentEntry.startTime; + }, +}; + +export const perfService = new _PerfService(); diff --git a/browser/components/newtab/content-src/lib/screenshot-utils.js b/browser/components/newtab/content-src/lib/screenshot-utils.js new file mode 100644 index 0000000000..7ea93f12ae --- /dev/null +++ b/browser/components/newtab/content-src/lib/screenshot-utils.js @@ -0,0 +1,61 @@ +/* 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/. */ + +/** + * List of helper functions for screenshot-based images. + * + * There are two kinds of images: + * 1. Remote Image: This is the image from the main process and it refers to + * the image in the React props. This can either be an object with the `data` + * and `path` properties, if it is a blob, or a string, if it is a normal image. + * 2. Local Image: This is the image object in the content process and it refers + * to the image *object* in the React component's state. All local image + * objects have the `url` property, and an additional property `path`, if they + * are blobs. + */ +export const ScreenshotUtils = { + isBlob(isLocal, image) { + return !!( + image && + image.path && + ((!isLocal && image.data) || (isLocal && image.url)) + ); + }, + + // This should always be called with a remote image and not a local image. + createLocalImageObject(remoteImage) { + if (!remoteImage) { + return null; + } + if (this.isBlob(false, remoteImage)) { + return { + url: global.URL.createObjectURL(remoteImage.data), + path: remoteImage.path, + }; + } + return { url: remoteImage }; + }, + + // Revokes the object URL of the image if the local image is a blob. + // This should always be called with a local image and not a remote image. + maybeRevokeBlobObjectURL(localImage) { + if (this.isBlob(true, localImage)) { + global.URL.revokeObjectURL(localImage.url); + } + }, + + // Checks if remoteImage and localImage are the same. + isRemoteImageLocal(localImage, remoteImage) { + // Both remoteImage and localImage are present. + if (remoteImage && localImage) { + return this.isBlob(false, remoteImage) + ? localImage.path === remoteImage.path + : localImage.url === remoteImage; + } + + // This will only handle the remaining three possible outcomes. + // (i.e. everything except when both image and localImage are present) + return !remoteImage && !localImage; + }, +}; diff --git a/browser/components/newtab/content-src/lib/selectLayoutRender.js b/browser/components/newtab/content-src/lib/selectLayoutRender.js new file mode 100644 index 0000000000..aa3eb927d2 --- /dev/null +++ b/browser/components/newtab/content-src/lib/selectLayoutRender.js @@ -0,0 +1,255 @@ +/* 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 selectLayoutRender = ({ state = {}, prefs = {}, locale = "" }) => { + const { layout, feeds, spocs } = state; + let spocIndexPlacementMap = {}; + + /* This function fills spoc positions on a per placement basis with available spocs. + * It does this by looping through each position for a placement and replacing a rec with a spoc. + * If it runs out of spocs or positions, it stops. + * If it sees the same placement again, it remembers the previous spoc index, and continues. + * If it sees a blocked spoc, it skips that position leaving in a regular story. + */ + function fillSpocPositionsForPlacement( + data, + spocsConfig, + spocsData, + placementName + ) { + if ( + !spocIndexPlacementMap[placementName] && + spocIndexPlacementMap[placementName] !== 0 + ) { + spocIndexPlacementMap[placementName] = 0; + } + const results = [...data]; + for (let position of spocsConfig.positions) { + const spoc = spocsData[spocIndexPlacementMap[placementName]]; + // If there are no spocs left, we can stop filling positions. + if (!spoc) { + break; + } + + // A placement could be used in two sections. + // In these cases, we want to maintain the index of the previous section. + // If we didn't do this, it might duplicate spocs. + spocIndexPlacementMap[placementName]++; + + // A spoc that's blocked is removed from the source for subsequent newtab loads. + // If we have a spoc in the source that's blocked, it means it was *just* blocked, + // and in this case, we skip this position, and show a regular spoc instead. + if (!spocs.blocked.includes(spoc.url)) { + results.splice(position.index, 0, spoc); + } + } + + return results; + } + + const positions = {}; + const DS_COMPONENTS = [ + "Message", + "TextPromo", + "SectionTitle", + "Signup", + "Navigation", + "CardGrid", + "CollectionCardGrid", + "HorizontalRule", + "PrivacyLink", + ]; + + const filterArray = []; + + if (!prefs["feeds.topsites"]) { + filterArray.push("TopSites"); + } + + const pocketEnabled = + prefs["feeds.section.topstories"] && prefs["feeds.system.topstories"]; + if (!pocketEnabled) { + filterArray.push(...DS_COMPONENTS); + } + + const placeholderComponent = component => { + if (!component.feed) { + // TODO we now need a placeholder for topsites and textPromo. + return { + ...component, + data: { + spocs: [], + }, + }; + } + const data = { + recommendations: [], + }; + + let items = 0; + if (component.properties && component.properties.items) { + items = component.properties.items; + } + for (let i = 0; i < items; i++) { + data.recommendations.push({ placeholder: true }); + } + + return { ...component, data }; + }; + + // TODO update devtools to show placements + const handleSpocs = (data, component) => { + let result = [...data]; + // Do we ever expect to possibly have a spoc. + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + const placement = component.placement || {}; + const placementName = placement.name || "spocs"; + const spocsData = spocs.data[placementName]; + // We expect a spoc, spocs are loaded, and the server returned spocs. + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + result = fillSpocPositionsForPlacement( + result, + component.spocs, + spocsData.items, + placementName + ); + } + } + return result; + }; + + const handleComponent = component => { + if ( + component.spocs && + component.spocs.positions && + component.spocs.positions.length + ) { + const placement = component.placement || {}; + const placementName = placement.name || "spocs"; + const spocsData = spocs.data[placementName]; + if ( + spocs.loaded && + spocsData && + spocsData.items && + spocsData.items.length + ) { + return { + ...component, + data: { + spocs: spocsData.items + .filter(spoc => spoc && !spocs.blocked.includes(spoc.url)) + .map((spoc, index) => ({ + ...spoc, + pos: index, + })), + }, + }; + } + } + return { + ...component, + data: { + spocs: [], + }, + }; + }; + + const handleComponentWithFeed = component => { + positions[component.type] = positions[component.type] || 0; + let data = { + recommendations: [], + }; + + const feed = feeds.data[component.feed.url]; + if (feed && feed.data) { + data = { + ...feed.data, + recommendations: [...(feed.data.recommendations || [])], + }; + } + + if (component && component.properties && component.properties.offset) { + data = { + ...data, + recommendations: data.recommendations.slice( + component.properties.offset + ), + }; + } + + data = { + ...data, + recommendations: handleSpocs(data.recommendations, component), + }; + + let items = 0; + if (component.properties && component.properties.items) { + items = Math.min(component.properties.items, data.recommendations.length); + } + + // loop through a component items + // Store the items position sequentially for multiple components of the same type. + // Example: A second card grid starts pos offset from the last card grid. + for (let i = 0; i < items; i++) { + data.recommendations[i] = { + ...data.recommendations[i], + pos: positions[component.type]++, + }; + } + + return { ...component, data }; + }; + + const renderLayout = () => { + const renderedLayoutArray = []; + for (const row of layout.filter( + r => r.components.filter(c => !filterArray.includes(c.type)).length + )) { + let components = []; + renderedLayoutArray.push({ + ...row, + components, + }); + for (const component of row.components.filter( + c => !filterArray.includes(c.type) + )) { + const spocsConfig = component.spocs; + if (spocsConfig || component.feed) { + // TODO make sure this still works for different loading cases. + if ( + (component.feed && !feeds.data[component.feed.url]) || + (spocsConfig && + spocsConfig.positions && + spocsConfig.positions.length && + !spocs.loaded) + ) { + components.push(placeholderComponent(component)); + return renderedLayoutArray; + } + if (component.feed) { + components.push(handleComponentWithFeed(component)); + } else { + components.push(handleComponent(component)); + } + } else { + components.push(component); + } + } + } + return renderedLayoutArray; + }; + + const layoutRender = renderLayout(); + + return { layoutRender }; +}; |