diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/newtab/lib/PlacesFeed.sys.mjs | 572 |
1 files changed, 572 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/PlacesFeed.sys.mjs b/browser/components/newtab/lib/PlacesFeed.sys.mjs new file mode 100644 index 0000000000..70011412f8 --- /dev/null +++ b/browser/components/newtab/lib/PlacesFeed.sys.mjs @@ -0,0 +1,572 @@ +/* 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, + actionUtils as au, +} from "resource://activity-stream/common/Actions.sys.mjs"; + +import { shortURL } from "resource://activity-stream/lib/ShortURL.sys.mjs"; + +// We use importESModule here instead of static import so that +// the Karma test environment won't choke on this module. This +// is because the Karma test environment already stubs out +// AboutNewTab, and overrides importESModule to be a no-op (which +// can't be done for a static import statement). + +// eslint-disable-next-line mozilla/use-static-import +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", + pktApi: "chrome://pocket/content/pktApi.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const LINK_BLOCKED_EVENT = "newtab-linkBlocked"; +const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events + +// The pref to store the blocked sponsors of the sponsored Top Sites. +// The value of this pref is an array (JSON serialized) of hostnames of the +// blocked sponsors. +const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; + +/** + * PlacesObserver - observes events from PlacesUtils.observers + */ +class PlacesObserver { + constructor(dispatch) { + this.dispatch = dispatch; + this.QueryInterface = ChromeUtils.generateQI(["nsISupportsWeakReference"]); + this.handlePlacesEvent = this.handlePlacesEvent.bind(this); + } + + handlePlacesEvent(events) { + const removedPages = []; + const removedBookmarks = []; + + for (const { + itemType, + source, + dateAdded, + guid, + title, + url, + isRemovedFromStore, + isTagging, + type, + } of events) { + switch (type) { + case "history-cleared": + this.dispatch({ type: at.PLACES_HISTORY_CLEARED }); + break; + case "page-removed": + if (isRemovedFromStore) { + removedPages.push(url); + } + break; + case "bookmark-added": + // Skips items that are not bookmarks (like folders), about:* pages or + // default bookmarks, added when the profile is created. + if ( + isTagging || + itemType !== lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK || + source === lazy.PlacesUtils.bookmarks.SOURCES.IMPORT || + source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE || + source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP || + source === lazy.PlacesUtils.bookmarks.SOURCES.SYNC || + (!url.startsWith("http://") && !url.startsWith("https://")) + ) { + return; + } + + this.dispatch({ type: at.PLACES_LINKS_CHANGED }); + this.dispatch({ + type: at.PLACES_BOOKMARK_ADDED, + data: { + bookmarkGuid: guid, + bookmarkTitle: title, + dateAdded: dateAdded * 1000, + url, + }, + }); + break; + case "bookmark-removed": + if ( + isTagging || + (itemType === lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK && + source !== lazy.PlacesUtils.bookmarks.SOURCES.IMPORT && + source !== lazy.PlacesUtils.bookmarks.SOURCES.RESTORE && + source !== + lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP && + source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) + ) { + removedBookmarks.push(url); + } + break; + } + } + + if (removedPages.length || removedBookmarks.length) { + this.dispatch({ type: at.PLACES_LINKS_CHANGED }); + } + + if (removedPages.length) { + this.dispatch({ + type: at.PLACES_LINKS_DELETED, + data: { urls: removedPages }, + }); + } + + if (removedBookmarks.length) { + this.dispatch({ + type: at.PLACES_BOOKMARKS_REMOVED, + data: { urls: removedBookmarks }, + }); + } + } +} + +export class PlacesFeed { + constructor() { + this.placesChangedTimer = null; + this.customDispatch = this.customDispatch.bind(this); + this.placesObserver = new PlacesObserver(this.customDispatch); + } + + addObservers() { + lazy.PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], + this.placesObserver.handlePlacesEvent + ); + + Services.obs.addObserver(this, LINK_BLOCKED_EVENT); + } + + /** + * setTimeout - A custom function that creates an nsITimer that can be cancelled + * + * @param {func} callback A function to be executed after the timer expires + * @param {int} delay The time (in ms) the timer should wait before the function is executed + */ + setTimeout(callback, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT); + return timer; + } + + customDispatch(action) { + // If we are changing many links at once, delay this action and only dispatch + // one action at the end + if (action.type === at.PLACES_LINKS_CHANGED) { + if (this.placesChangedTimer) { + this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME; + } else { + this.placesChangedTimer = this.setTimeout(() => { + this.placesChangedTimer = null; + this.store.dispatch(ac.OnlyToMain(action)); + }, PLACES_LINKS_CHANGED_DELAY_TIME); + } + } else { + // To avoid blocking Places notifications on expensive work, run it at the + // next tick of the events loop. + Services.tm.dispatchToMainThread(() => + this.store.dispatch(ac.BroadcastToContent(action)) + ); + } + } + + removeObservers() { + if (this.placesChangedTimer) { + this.placesChangedTimer.cancel(); + this.placesChangedTimer = null; + } + lazy.PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], + this.placesObserver.handlePlacesEvent + ); + Services.obs.removeObserver(this, LINK_BLOCKED_EVENT); + } + + /** + * observe - An observer for the LINK_BLOCKED_EVENT. + * Called when a link is blocked. + * Links can be blocked outside of newtab, + * which is why we need to listen to this + * on such a generic level. + * + * @param {null} subject + * @param {str} topic The name of the event + * @param {str} value The data associated with the event + */ + observe(subject, topic, value) { + if (topic === LINK_BLOCKED_EVENT) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PLACES_LINK_BLOCKED, + data: { url: value }, + }) + ); + } + } + + /** + * Open a link in a desired destination defaulting to action's event. + */ + openLink(action, where = "", isPrivate = false) { + const params = { + private: isPrivate, + targetBrowser: action._target.browser, + forceForeground: false, // This ensure we maintain user preference for how to open new tabs. + globalHistoryOptions: { + triggeringSponsoredURL: action.data.sponsored_tile_id + ? action.data.url + : undefined, + }, + }; + + // Always include the referrer (even for http links) if we have one + const { event, referrer, typedBonus } = action.data; + if (referrer) { + const ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ); + params.referrerInfo = new ReferrerInfo( + Ci.nsIReferrerInfo.UNSAFE_URL, + true, + Services.io.newURI(referrer) + ); + } + + // Pocket gives us a special reader URL to open their stories in + const urlToOpen = + action.data.type === "pocket" ? action.data.open_url : action.data.url; + + try { + let uri = Services.io.newURI(urlToOpen); + if (!["http", "https"].includes(uri.scheme)) { + throw new Error( + `Can't open link using ${uri.scheme} protocol from the new tab page.` + ); + } + } catch (e) { + console.error(e); + return; + } + + // Mark the page as typed for frecency bonus before opening the link + if (typedBonus) { + lazy.PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen)); + } + + const win = action._target.browser.ownerGlobal; + win.openTrustedLinkIn( + urlToOpen, + where || win.whereToOpenLink(event), + params + ); + + // If there's an original URL e.g. using the unprocessed %YYYYMMDDHH% tag, + // add a visit for that so it may become a frecent top site. + if (action.data.original_url) { + lazy.PlacesUtils.history.insert({ + url: action.data.original_url, + visits: [{ transition: lazy.PlacesUtils.history.TRANSITION_TYPED }], + }); + } + } + + async saveToPocket(site, browser) { + const sendToPocket = + lazy.NimbusFeatures.pocketNewtab.getVariable("sendToPocket"); + // An experiment to send the user directly to Pocket's signup page. + if (sendToPocket && !lazy.pktApi.isUserLoggedIn()) { + const pocketNewtabExperiment = lazy.ExperimentAPI.getExperiment({ + featureId: "pocketNewtab", + }); + const pocketSiteHost = Services.prefs.getStringPref( + "extensions.pocket.site" + ); // getpocket.com + let utmSource = "firefox_newtab_save_button"; + // We want to know if the user is in a Pocket newtab related experiment. + let utmCampaign = pocketNewtabExperiment?.slug; + let utmContent = pocketNewtabExperiment?.branch?.slug; + + const url = new URL(`https://${pocketSiteHost}/signup`); + url.searchParams.append("utm_source", utmSource); + if (utmCampaign && utmContent) { + url.searchParams.append("utm_campaign", utmCampaign); + url.searchParams.append("utm_content", utmContent); + } + + const win = browser.ownerGlobal; + win.openTrustedLinkIn(url.href, "tab"); + return; + } + + const { url, title } = site; + try { + let data = await lazy.NewTabUtils.activityStreamLinks.addPocketEntry( + url, + title, + browser + ); + if (data) { + this.store.dispatch( + ac.BroadcastToContent({ + type: at.PLACES_SAVED_TO_POCKET, + data: { + url, + open_url: data.item.open_url, + title, + pocket_id: data.item.item_id, + }, + }) + ); + } + } catch (err) { + console.error(err); + } + } + + /** + * Deletes an item from a user's saved to Pocket feed + * @param {int} itemID + * The unique ID given by Pocket for that item; used to look the item up when deleting + */ + async deleteFromPocket(itemID) { + try { + await lazy.NewTabUtils.activityStreamLinks.deletePocketEntry(itemID); + this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED }); + } catch (err) { + console.error(err); + } + } + + /** + * Archives an item from a user's saved to Pocket feed + * @param {int} itemID + * The unique ID given by Pocket for that item; used to look the item up when archiving + */ + async archiveFromPocket(itemID) { + try { + await lazy.NewTabUtils.activityStreamLinks.archivePocketEntry(itemID); + this.store.dispatch({ type: at.POCKET_LINK_DELETED_OR_ARCHIVED }); + } catch (err) { + console.error(err); + } + } + + /** + * Sends an attribution request for Top Sites interactions. + * @param {object} data + * Attribution paramters from a Top Site. + */ + makeAttributionRequest(data) { + let args = Object.assign( + { + campaignID: Services.prefs.getStringPref( + "browser.partnerlink.campaign.topsites" + ), + }, + data + ); + lazy.PartnerLinkAttribution.makeRequest(args); + } + + async fillSearchTopSiteTerm({ _target, data }) { + const searchEngine = await Services.search.getEngineByAlias(data.label); + _target.browser.ownerGlobal.gURLBar.search(data.label, { + searchEngine, + searchModeEntry: "topsites_newtab", + }); + } + + _getDefaultSearchEngine(isPrivateWindow) { + return Services.search[ + isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine" + ]; + } + + handoffSearchToAwesomebar(action) { + const { _target, data, meta } = action; + const searchEngine = this._getDefaultSearchEngine( + lazy.PrivateBrowsingUtils.isBrowserPrivate(_target.browser) + ); + const urlBar = _target.browser.ownerGlobal.gURLBar; + let isFirstChange = true; + + const newtabSession = AboutNewTab.activityStream.store.feeds + .get("feeds.telemetry") + ?.sessions.get(au.getPortIdOfSender(action)); + if (!data || !data.text) { + urlBar.setHiddenFocus(); + } else { + urlBar.handoff(data.text, searchEngine, newtabSession?.session_id); + isFirstChange = false; + } + + const checkFirstChange = () => { + // Check if this is the first change since we hidden focused. If it is, + // remove hidden focus styles, prepend the search alias and hide the + // in-content search. + if (isFirstChange) { + isFirstChange = false; + urlBar.removeHiddenFocus(true); + urlBar.handoff("", searchEngine, newtabSession?.session_id); + this.store.dispatch( + ac.OnlyToOneContent({ type: at.DISABLE_SEARCH }, meta.fromTarget) + ); + urlBar.removeEventListener("compositionstart", checkFirstChange); + urlBar.removeEventListener("paste", checkFirstChange); + } + }; + + const onKeydown = ev => { + // Check if the keydown will cause a value change. + if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + checkFirstChange(); + } + // If the Esc button is pressed, we are done. Show in-content search and cleanup. + if (ev.key === "Escape") { + onDone(); // eslint-disable-line no-use-before-define + } + }; + + const onDone = ev => { + // We are done. Show in-content search again and cleanup. + this.store.dispatch( + ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget) + ); + + const forceSuppressFocusBorder = ev?.type === "mousedown"; + urlBar.removeHiddenFocus(forceSuppressFocusBorder); + + urlBar.removeEventListener("keydown", onKeydown); + urlBar.removeEventListener("mousedown", onDone); + urlBar.removeEventListener("blur", onDone); + urlBar.removeEventListener("compositionstart", checkFirstChange); + urlBar.removeEventListener("paste", checkFirstChange); + }; + + urlBar.addEventListener("keydown", onKeydown); + urlBar.addEventListener("mousedown", onDone); + urlBar.addEventListener("blur", onDone); + urlBar.addEventListener("compositionstart", checkFirstChange); + urlBar.addEventListener("paste", checkFirstChange); + } + + /** + * Add the hostnames of the given urls to the Top Sites sponsor blocklist. + * + * @param {array} urls + * An array of the objects structured as `{ url }` + */ + addToBlockedTopSitesSponsors(urls) { + const blockedPref = JSON.parse( + Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") + ); + const merged = new Set([...blockedPref, ...urls.map(url => shortURL(url))]); + + Services.prefs.setStringPref( + TOP_SITES_BLOCKED_SPONSORS_PREF, + JSON.stringify([...merged]) + ); + } + + onAction(action) { + switch (action.type) { + case at.INIT: + // Briefly avoid loading services for observing for better startup timing + Services.tm.dispatchToMainThread(() => this.addObservers()); + break; + case at.UNINIT: + this.removeObservers(); + break; + case at.ABOUT_SPONSORED_TOP_SITES: { + const url = `${Services.urlFormatter.formatURLPref( + "app.support.baseURL" + )}sponsor-privacy`; + const win = action._target.browser.ownerGlobal; + win.openTrustedLinkIn(url, "tab"); + break; + } + case at.BLOCK_URL: { + if (action.data) { + let sponsoredTopSites = []; + action.data.forEach(site => { + const { url, pocket_id, isSponsoredTopSite } = site; + lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); + if (isSponsoredTopSite) { + sponsoredTopSites.push({ url }); + } + }); + if (sponsoredTopSites.length) { + this.addToBlockedTopSitesSponsors(sponsoredTopSites); + } + } + break; + } + case at.BOOKMARK_URL: + lazy.NewTabUtils.activityStreamLinks.addBookmark( + action.data, + action._target.browser.ownerGlobal + ); + break; + case at.DELETE_BOOKMARK_BY_ID: + lazy.NewTabUtils.activityStreamLinks.deleteBookmark(action.data); + break; + case at.DELETE_HISTORY_URL: { + const { url, forceBlock, pocket_id } = action.data; + lazy.NewTabUtils.activityStreamLinks.deleteHistoryEntry(url); + if (forceBlock) { + lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); + } + break; + } + case at.OPEN_NEW_WINDOW: + this.openLink(action, "window"); + break; + case at.OPEN_PRIVATE_WINDOW: + this.openLink(action, "window", true); + break; + case at.SAVE_TO_POCKET: + this.saveToPocket(action.data.site, action._target.browser); + break; + case at.DELETE_FROM_POCKET: + this.deleteFromPocket(action.data.pocket_id); + break; + case at.ARCHIVE_FROM_POCKET: + this.archiveFromPocket(action.data.pocket_id); + break; + case at.FILL_SEARCH_TERM: + this.fillSearchTopSiteTerm(action); + break; + case at.HANDOFF_SEARCH_TO_AWESOMEBAR: + this.handoffSearchToAwesomebar(action); + break; + case at.OPEN_LINK: { + this.openLink(action); + break; + } + case at.PARTNER_LINK_ATTRIBUTION: + this.makeAttributionRequest(action.data); + break; + } + } +} + +// Exported for testing only +PlacesFeed.PlacesObserver = PlacesObserver; |