PlacesFeed.sys.mjs (17620B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { 6 actionCreators as ac, 7 actionTypes as at, 8 actionUtils as au, 9 } from "resource://newtab/common/Actions.mjs"; 10 11 // We use importESModule here instead of static import so that 12 // the Karma test environment won't choke on this module. This 13 // is because the Karma test environment already stubs out 14 // AboutNewTab, and overrides importESModule to be a no-op (which 15 // can't be done for a static import statement). 16 17 // eslint-disable-next-line mozilla/use-static-import 18 const { AboutNewTab } = ChromeUtils.importESModule( 19 "resource:///modules/AboutNewTab.sys.mjs" 20 ); 21 22 const lazy = {}; 23 24 ChromeUtils.defineESModuleGetters(lazy, { 25 BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", 26 NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", 27 PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs", 28 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 29 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 30 }); 31 32 const LINK_BLOCKED_EVENT = "newtab-linkBlocked"; 33 const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events 34 35 // The pref to store the blocked sponsors of the sponsored Top Sites. 36 // The value of this pref is an array (JSON serialized) of hostnames of the 37 // blocked sponsors. 38 const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors"; 39 40 const PREF_UNIFIED_ADS_TILES_ENABLED = 41 "browser.newtabpage.activity-stream.unifiedAds.tiles.enabled"; 42 43 const PREF_UNIFIED_ADS_BLOCKED_LIST = 44 "browser.newtabpage.activity-stream.unifiedAds.blockedAds"; 45 46 /** 47 * PlacesObserver - observes events from PlacesUtils.observers 48 */ 49 class PlacesObserver { 50 constructor(dispatch) { 51 this.dispatch = dispatch; 52 this.QueryInterface = ChromeUtils.generateQI(["nsISupportsWeakReference"]); 53 this.handlePlacesEvent = this.handlePlacesEvent.bind(this); 54 } 55 56 handlePlacesEvent(events) { 57 const removedPages = []; 58 const removedBookmarks = []; 59 60 for (const { 61 itemType, 62 source, 63 dateAdded, 64 guid, 65 title, 66 url, 67 isRemovedFromStore, 68 isTagging, 69 type, 70 } of events) { 71 switch (type) { 72 case "history-cleared": 73 this.dispatch({ type: at.PLACES_HISTORY_CLEARED }); 74 break; 75 case "page-removed": 76 if (isRemovedFromStore) { 77 removedPages.push(url); 78 } 79 break; 80 case "bookmark-added": 81 // Skips items that are not bookmarks (like folders), about:* pages or 82 // default bookmarks, added when the profile is created. 83 if ( 84 isTagging || 85 itemType !== lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK || 86 source === lazy.PlacesUtils.bookmarks.SOURCES.IMPORT || 87 source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE || 88 source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP || 89 source === lazy.PlacesUtils.bookmarks.SOURCES.SYNC || 90 (!url.startsWith("http://") && !url.startsWith("https://")) 91 ) { 92 return; 93 } 94 95 this.dispatch({ type: at.PLACES_LINKS_CHANGED }); 96 this.dispatch({ 97 type: at.PLACES_BOOKMARK_ADDED, 98 data: { 99 bookmarkGuid: guid, 100 bookmarkTitle: title, 101 dateAdded: dateAdded * 1000, 102 url, 103 }, 104 }); 105 break; 106 case "bookmark-removed": 107 if ( 108 isTagging || 109 (itemType === lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK && 110 source !== lazy.PlacesUtils.bookmarks.SOURCES.IMPORT && 111 source !== lazy.PlacesUtils.bookmarks.SOURCES.RESTORE && 112 source !== 113 lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP && 114 source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC) 115 ) { 116 removedBookmarks.push(url); 117 } 118 break; 119 } 120 } 121 122 if (removedPages.length || removedBookmarks.length) { 123 this.dispatch({ type: at.PLACES_LINKS_CHANGED }); 124 } 125 126 if (removedPages.length) { 127 this.dispatch({ 128 type: at.PLACES_LINKS_DELETED, 129 data: { urls: removedPages }, 130 }); 131 } 132 133 if (removedBookmarks.length) { 134 this.dispatch({ 135 type: at.PLACES_BOOKMARKS_REMOVED, 136 data: { urls: removedBookmarks }, 137 }); 138 } 139 } 140 } 141 142 export class PlacesFeed { 143 constructor() { 144 this.placesChangedTimer = null; 145 this.customDispatch = this.customDispatch.bind(this); 146 this.placesObserver = new PlacesObserver(this.customDispatch); 147 } 148 149 addObservers() { 150 lazy.PlacesUtils.observers.addListener( 151 ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], 152 this.placesObserver.handlePlacesEvent 153 ); 154 155 Services.obs.addObserver(this, LINK_BLOCKED_EVENT); 156 } 157 158 /** 159 * setTimeout - A custom function that creates an nsITimer that can be cancelled 160 * 161 * @param {func} callback A function to be executed after the timer expires 162 * @param {int} delay The time (in ms) the timer should wait before the function is executed 163 */ 164 setTimeout(callback, delay) { 165 let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 166 timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT); 167 return timer; 168 } 169 170 customDispatch(action) { 171 // If we are changing many links at once, delay this action and only dispatch 172 // one action at the end 173 if (action.type === at.PLACES_LINKS_CHANGED) { 174 if (this.placesChangedTimer) { 175 this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME; 176 } else { 177 this.placesChangedTimer = this.setTimeout(() => { 178 this.placesChangedTimer = null; 179 this.store.dispatch(ac.OnlyToMain(action)); 180 }, PLACES_LINKS_CHANGED_DELAY_TIME); 181 } 182 } else { 183 // To avoid blocking Places notifications on expensive work, run it at the 184 // next tick of the events loop. 185 Services.tm.dispatchToMainThread(() => 186 this.store.dispatch(ac.BroadcastToContent(action)) 187 ); 188 } 189 } 190 191 removeObservers() { 192 if (this.placesChangedTimer) { 193 this.placesChangedTimer.cancel(); 194 this.placesChangedTimer = null; 195 } 196 lazy.PlacesUtils.observers.removeListener( 197 ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"], 198 this.placesObserver.handlePlacesEvent 199 ); 200 Services.obs.removeObserver(this, LINK_BLOCKED_EVENT); 201 } 202 203 /** 204 * observe - An observer for the LINK_BLOCKED_EVENT. 205 * Called when a link is blocked. 206 * Links can be blocked outside of newtab, 207 * which is why we need to listen to this 208 * on such a generic level. 209 * 210 * @param {null} subject 211 * @param {str} topic The name of the event 212 * @param {str} value The data associated with the event 213 */ 214 observe(subject, topic, value) { 215 if (topic === LINK_BLOCKED_EVENT) { 216 this.store.dispatch( 217 ac.BroadcastToContent({ 218 type: at.PLACES_LINK_BLOCKED, 219 data: { url: value }, 220 }) 221 ); 222 } 223 } 224 225 /** 226 * Open a link in a desired destination defaulting to action's event. 227 */ 228 openLink(action, where = "", isPrivate = false) { 229 const params = { 230 private: isPrivate, 231 targetBrowser: action._target.browser, 232 forceForeground: false, // This ensure we maintain user preference for how to open new tabs. 233 globalHistoryOptions: { 234 triggeringSponsoredURL: action.data.is_sponsored 235 ? action.data.url 236 : undefined, 237 triggeringSource: "newtab", 238 }, 239 }; 240 241 // Always include the referrer (even for http links) if we have one 242 const { event, referrer, typedBonus } = action.data; 243 if (referrer) { 244 const ReferrerInfo = Components.Constructor( 245 "@mozilla.org/referrer-info;1", 246 "nsIReferrerInfo", 247 "init" 248 ); 249 params.referrerInfo = new ReferrerInfo( 250 Ci.nsIReferrerInfo.UNSAFE_URL, 251 true, 252 Services.io.newURI(referrer) 253 ); 254 } 255 256 // Pocket gives us a special reader URL to open their stories in 257 const urlToOpen = 258 action.data.type === "pocket" ? action.data.open_url : action.data.url; 259 260 try { 261 let uri = Services.io.newURI(urlToOpen); 262 if (!["http", "https"].includes(uri.scheme)) { 263 throw new Error( 264 `Can't open link using ${uri.scheme} protocol from the new tab page.` 265 ); 266 } 267 } catch (e) { 268 console.error(e); 269 return; 270 } 271 272 // Mark the page as typed for frecency bonus before opening the link 273 if (typedBonus) { 274 lazy.PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen)); 275 } 276 277 const win = action._target.browser.ownerGlobal; 278 win.openTrustedLinkIn( 279 urlToOpen, 280 where || lazy.BrowserUtils.whereToOpenLink(event), 281 params 282 ); 283 284 // If there's an original URL e.g. using the unprocessed %YYYYMMDDHH% tag, 285 // add a visit for that so it may become a frecent top site. 286 if (action.data.original_url) { 287 lazy.PlacesUtils.history.insert({ 288 url: action.data.original_url, 289 visits: [{ transition: lazy.PlacesUtils.history.TRANSITION_TYPED }], 290 }); 291 } 292 } 293 294 /** 295 * Sends an attribution request for Top Sites interactions. 296 * 297 * @param {object} data 298 * Attribution paramters from a Top Site. 299 */ 300 makeAttributionRequest(data) { 301 let args = Object.assign( 302 { 303 campaignID: Services.prefs.getStringPref( 304 "browser.partnerlink.campaign.topsites" 305 ), 306 }, 307 data 308 ); 309 lazy.PartnerLinkAttribution.makeRequest(args); 310 } 311 312 async fillSearchTopSiteTerm({ _target, data }) { 313 const searchEngine = await Services.search.getEngineByAlias(data.label); 314 _target.browser.ownerGlobal.gURLBar.search(data.label, { 315 searchEngine, 316 searchModeEntry: "topsites_newtab", 317 }); 318 } 319 320 _getDefaultSearchEngine(isPrivateWindow) { 321 return Services.search[ 322 isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine" 323 ]; 324 } 325 326 /** 327 * @backward-compat { version 148 } 328 * 329 * This, and all newtab-specific handoff searchbar handling can be removed 330 * once 147 is released, as all handoff UI and logic will be handled by 331 * contentSearchHandoffUI and the ContentSearch JSWindowActors. 332 */ 333 handoffSearchToAwesomebar(action) { 334 const { _target, data, meta } = action; 335 const searchEngine = this._getDefaultSearchEngine( 336 lazy.PrivateBrowsingUtils.isBrowserPrivate(_target.browser) 337 ); 338 const urlBar = _target.browser.ownerGlobal.gURLBar; 339 let isFirstChange = true; 340 341 const newtabSession = AboutNewTab.activityStream.store.feeds 342 .get("feeds.telemetry") 343 ?.sessions.get(au.getPortIdOfSender(action)); 344 if (!data || !data.text) { 345 urlBar.setHiddenFocus(); 346 } else { 347 urlBar.handoff(data.text, searchEngine, newtabSession?.session_id); 348 isFirstChange = false; 349 } 350 351 const checkFirstChange = () => { 352 // Check if this is the first change since we hidden focused. If it is, 353 // remove hidden focus styles, prepend the search alias and hide the 354 // in-content search. 355 if (isFirstChange) { 356 isFirstChange = false; 357 urlBar.removeHiddenFocus(true); 358 urlBar.handoff("", searchEngine, newtabSession?.session_id); 359 this.store.dispatch( 360 ac.OnlyToOneContent({ type: at.DISABLE_SEARCH }, meta.fromTarget) 361 ); 362 urlBar.removeEventListener("compositionstart", checkFirstChange); 363 urlBar.removeEventListener("paste", checkFirstChange); 364 } 365 }; 366 367 const onKeydown = ev => { 368 // Check if the keydown will cause a value change. 369 if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { 370 checkFirstChange(); 371 } 372 // If the Esc button is pressed, we are done. Show in-content search and cleanup. 373 if (ev.key === "Escape") { 374 onDone(); // eslint-disable-line no-use-before-define 375 } 376 }; 377 378 const onDone = ev => { 379 // We are done. Show in-content search again and cleanup. 380 this.store.dispatch( 381 ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget) 382 ); 383 384 const forceSuppressFocusBorder = ev?.type === "mousedown"; 385 urlBar.removeHiddenFocus(forceSuppressFocusBorder); 386 387 urlBar.removeEventListener("keydown", onKeydown); 388 urlBar.removeEventListener("mousedown", onDone); 389 urlBar.removeEventListener("blur", onDone); 390 urlBar.removeEventListener("compositionstart", checkFirstChange); 391 urlBar.removeEventListener("paste", checkFirstChange); 392 }; 393 394 urlBar.addEventListener("keydown", onKeydown); 395 urlBar.addEventListener("mousedown", onDone); 396 urlBar.addEventListener("blur", onDone); 397 urlBar.addEventListener("compositionstart", checkFirstChange); 398 urlBar.addEventListener("paste", checkFirstChange); 399 } 400 401 /** 402 * Add the hostnames of the given urls to the Top Sites sponsor blocklist. 403 * 404 * @param {Array} urls 405 * An array of the objects structured as `{ url }` 406 */ 407 addToBlockedTopSitesSponsors(urls) { 408 const blockedPref = JSON.parse( 409 Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]") 410 ); 411 const merged = new Set([ 412 ...blockedPref, 413 ...urls.map(url => lazy.NewTabUtils.shortURL(url)), 414 ]); 415 416 Services.prefs.setStringPref( 417 TOP_SITES_BLOCKED_SPONSORS_PREF, 418 JSON.stringify([...merged]) 419 ); 420 } 421 422 /** 423 * Add the block key (uuid) of the given urls to the blocked ads pref 424 * to send back to the ads service when requesting new topsite ads 425 * from the unified ads service 426 * 427 * @param {Array} block_key 428 * An array of the (string) keys 429 */ 430 addToUnifiedAdsBlockedAdsList(keysArray) { 431 const blockedAdsPref = Services.prefs.getStringPref( 432 PREF_UNIFIED_ADS_BLOCKED_LIST, 433 "" 434 ); 435 436 let blockedAdsArray; 437 438 if (blockedAdsPref === "") { 439 // Set new IDs as prev blocked array 440 blockedAdsArray = keysArray; 441 } else { 442 // Convert prev blocked csv list to array 443 blockedAdsArray = blockedAdsPref 444 .split(",") 445 .map(s => s.trim()) 446 .filter(item => item); 447 // Add new IDs to prev blocked array 448 blockedAdsArray = blockedAdsArray.concat(keysArray); 449 } 450 451 // Save generated array as a CSV string 452 Services.prefs.setStringPref( 453 PREF_UNIFIED_ADS_BLOCKED_LIST, 454 blockedAdsArray.join(",") 455 ); 456 } 457 458 onAction(action) { 459 const unifiedAdsTilesEnabled = Services.prefs.getBoolPref( 460 PREF_UNIFIED_ADS_TILES_ENABLED, 461 false 462 ); 463 464 switch (action.type) { 465 case at.INIT: 466 // Briefly avoid loading services for observing for better startup timing 467 Services.tm.dispatchToMainThread(() => this.addObservers()); 468 break; 469 case at.UNINIT: 470 this.removeObservers(); 471 break; 472 case at.ABOUT_SPONSORED_TOP_SITES: { 473 const url = `${Services.urlFormatter.formatURLPref( 474 "app.support.baseURL" 475 )}sponsor-privacy`; 476 const win = action._target.browser.ownerGlobal; 477 win.openTrustedLinkIn(url, "tab"); 478 break; 479 } 480 case at.BLOCK_URL: { 481 if (action.data) { 482 let sponsoredTopSites = []; 483 let sponsoredBlockKeys = []; 484 action.data.forEach(site => { 485 const { url, pocket_id, isSponsoredTopSite, block_key } = site; 486 lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); 487 488 if (isSponsoredTopSite) { 489 sponsoredTopSites.push({ url }); 490 491 // Add block keys if available 492 if (unifiedAdsTilesEnabled) { 493 sponsoredBlockKeys.push(block_key); 494 } 495 } 496 }); 497 if (sponsoredTopSites.length) { 498 this.addToBlockedTopSitesSponsors(sponsoredTopSites); 499 } 500 if (sponsoredBlockKeys.length) { 501 this.addToUnifiedAdsBlockedAdsList(sponsoredBlockKeys); 502 } 503 } 504 break; 505 } 506 case at.BOOKMARK_URL: 507 lazy.NewTabUtils.activityStreamLinks.addBookmark( 508 action.data, 509 action._target.browser.ownerGlobal 510 ); 511 break; 512 case at.DELETE_BOOKMARK_BY_ID: 513 lazy.NewTabUtils.activityStreamLinks.deleteBookmark(action.data); 514 break; 515 case at.DELETE_HISTORY_URL: { 516 const { url, forceBlock, pocket_id } = action.data; 517 lazy.NewTabUtils.activityStreamLinks.deleteHistoryEntry(url); 518 if (forceBlock) { 519 lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id }); 520 } 521 break; 522 } 523 case at.OPEN_NEW_WINDOW: 524 this.openLink(action, "window"); 525 break; 526 case at.OPEN_PRIVATE_WINDOW: 527 this.openLink(action, "window", true); 528 break; 529 case at.FILL_SEARCH_TERM: 530 this.fillSearchTopSiteTerm(action); 531 break; 532 case at.HANDOFF_SEARCH_TO_AWESOMEBAR: 533 this.handoffSearchToAwesomebar(action); 534 break; 535 case at.OPEN_LINK: { 536 this.openLink(action); 537 break; 538 } 539 case at.PARTNER_LINK_ATTRIBUTION: 540 this.makeAttributionRequest(action.data); 541 break; 542 } 543 } 544 } 545 546 // Exported for testing only 547 PlacesFeed.PlacesObserver = PlacesObserver;