FrecencyBoostProvider.mjs (9097B)
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 https://mozilla.org/MPL/2.0/. */ 4 5 // We use importESModule here instead of static import so that 6 // the Karma test environment won't choke on this module. This 7 // is because the Karma test environment already stubs out 8 // AppConstants, and overrides importESModule to be a no-op (which 9 // can't be done for a static import statement). 10 11 // eslint-disable-next-line mozilla/use-static-import 12 const { AppConstants } = ChromeUtils.importESModule( 13 "resource://gre/modules/AppConstants.sys.mjs" 14 ); 15 16 const lazy = {}; 17 18 ChromeUtils.defineESModuleGetters(lazy, { 19 NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs", 20 PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs", 21 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 22 Region: "resource://gre/modules/Region.sys.mjs", 23 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", 24 Utils: "resource://services-settings/Utils.sys.mjs", 25 }); 26 27 ChromeUtils.defineLazyGetter(lazy, "log", () => { 28 const { Logger } = ChromeUtils.importESModule( 29 "resource://messaging-system/lib/Logger.sys.mjs" 30 ); 31 return new Logger("FrecencyBoostProvider"); 32 }); 33 34 ChromeUtils.defineLazyGetter(lazy, "pageFrecencyThreshold", () => { 35 // @backward-compat { version 147 } 36 // Frecency was changed in 147 Nightly. 37 if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "147.0a1") >= 0) { 38 // 30 days ago, 5 visits. The threshold avoids one non-typed visit from 39 // immediately being included in recent history to mimic the original 40 // threshold which aimed to prevent first-run visits from being included in 41 // Top Sites. 42 return lazy.PlacesUtils.history.pageFrecencyThreshold(30, 5, false); 43 } 44 // The old threshold used for classic frecency: Slightly over one visit. 45 return 101; 46 }); 47 48 const CACHE_KEY = "frecency_boost_cache"; 49 const RS_FALLBACK_BASE_URL = 50 "https://firefox-settings-attachments.cdn.mozilla.net/"; 51 const SPONSORED_TILE_PARTNER_FREC_BOOST = "frec-boost"; 52 const DEFAULT_SOV_NUM_ITEMS = 200; 53 54 export class FrecencyBoostProvider { 55 constructor(frecentCache) { 56 this.cache = new lazy.PersistentCache(CACHE_KEY, true); 57 this.frecentCache = frecentCache; 58 this._links = null; 59 this._frecencyBoostedSponsors = new Map(); 60 this._frecencyBoostRS = null; 61 this._onSync = this.onSync.bind(this); 62 } 63 64 init() { 65 if (!this._frecencyBoostRS) { 66 this._frecencyBoostRS = lazy.RemoteSettings( 67 "newtab-frecency-boosted-sponsors" 68 ); 69 this._frecencyBoostRS.on("sync", this._onSync); 70 } 71 } 72 73 uninit() { 74 if (this._frecencyBoostRS) { 75 this._frecencyBoostRS.off("sync", this._onSync); 76 this._frecencyBoostRS = null; 77 } 78 } 79 80 async onSync() { 81 this._frecencyBoostedSponsors = new Map(); 82 await this._importFrecencyBoostedSponsors(); 83 } 84 85 /** 86 * Import all sponsors from Remote Settings and save their favicons. 87 * This is called lazily when frecency boosted spocs are first requested. 88 * We fetch all favicons regardless of whether the user has visited these sites. 89 */ 90 async _importFrecencyBoostedSponsors() { 91 const records = await this._frecencyBoostRS?.get(); 92 if (!records) { 93 return; 94 } 95 96 const userRegion = lazy.Region.home || ""; 97 const regionRecords = records.filter( 98 record => record.region === userRegion 99 ); 100 101 await Promise.all( 102 regionRecords.map(record => 103 this._importFrecencyBoostedSponsor(record).catch(error => { 104 lazy.log.warn( 105 `Failed to import sponsor ${record.title || "unknown"}`, 106 error 107 ); 108 }) 109 ) 110 ); 111 } 112 113 /** 114 * Import a single sponsor record and fetch its favicon as data URI. 115 * 116 * @param {object} record - Remote Settings record with title, domain, redirect_url, and attachment 117 */ 118 async _importFrecencyBoostedSponsor(record) { 119 const { title, domain, redirect_url, attachment } = record; 120 const faviconDataURI = await this._fetchSponsorFaviconAsDataURI(attachment); 121 const hostname = lazy.NewTabUtils.shortURL({ url: domain }); 122 123 const sponsorData = { 124 title, 125 domain, 126 hostname, 127 redirectURL: redirect_url, 128 faviconDataURI, 129 }; 130 131 this._frecencyBoostedSponsors.set(hostname, sponsorData); 132 } 133 134 /** 135 * Fetch favicon from Remote Settings attachment and return as data URI. 136 * 137 * @param {object} attachment - Remote Settings attachment object 138 * @returns {Promise<string|null>} Favicon data URI, or null on error 139 */ 140 async _fetchSponsorFaviconAsDataURI(attachment) { 141 let baseAttachmentURL = RS_FALLBACK_BASE_URL; 142 try { 143 baseAttachmentURL = await lazy.Utils.baseAttachmentsURL(); 144 } catch (error) { 145 lazy.log.warn( 146 `Error fetching remote settings base url from CDN. Falling back to ${RS_FALLBACK_BASE_URL}`, 147 error 148 ); 149 } 150 151 const faviconURL = baseAttachmentURL + attachment.location; 152 const response = await fetch(faviconURL); 153 154 const blob = await response.blob(); 155 const dataURI = await new Promise((resolve, reject) => { 156 const reader = new FileReader(); 157 reader.addEventListener("load", () => resolve(reader.result)); 158 reader.addEventListener("error", reject); 159 reader.readAsDataURL(blob); 160 }); 161 162 return dataURI; 163 } 164 165 /** 166 * Build frecency-boosted spocs from a list of sponsor domains by checking Places history. 167 * Checks if domains exist in history, and returns all matches sorted by frecency. 168 * 169 * @param {Integer} numItems - Number of frecency items to check against. 170 * @returns {Array} Array of sponsored tile objects sorted by frecency, or empty array 171 */ 172 async buildFrecencyBoostedSpocs(numItems) { 173 if (!this._frecencyBoostedSponsors.size) { 174 return []; 175 } 176 177 const topsiteFrecency = lazy.pageFrecencyThreshold; 178 179 // Get all frecent sites from history. 180 const frecent = await this.frecentCache.request({ 181 numItems, 182 topsiteFrecency, 183 }); 184 185 const candidates = []; 186 frecent.forEach(site => { 187 const normalizedSiteUrl = lazy.NewTabUtils.shortURL(site); 188 const candidate = this._frecencyBoostedSponsors.get(normalizedSiteUrl); 189 190 if ( 191 candidate && 192 !lazy.NewTabUtils.blockedLinks.isBlocked({ url: candidate.domain }) 193 ) { 194 candidates.push({ 195 hostname: candidate.hostname, 196 url: candidate.redirectURL, 197 label: candidate.title, 198 partner: SPONSORED_TILE_PARTNER_FREC_BOOST, 199 type: "frecency-boost", 200 frecency: site.frecency, 201 show_sponsored_label: true, 202 favicon: candidate.faviconDataURI, 203 faviconSize: 96, 204 }); 205 } 206 }); 207 208 candidates.sort((a, b) => b.frecency - a.frecency); 209 return candidates; 210 } 211 212 async update(numItems = DEFAULT_SOV_NUM_ITEMS) { 213 if (!this._frecencyBoostedSponsors.size) { 214 await this._importFrecencyBoostedSponsors(); 215 } 216 217 // Find all matches from the sponsor domains, sorted by frecency 218 this._links = await this.buildFrecencyBoostedSpocs(numItems); 219 await this.cache.set("links", this._links); 220 } 221 222 async fetch(numItems) { 223 if (!this._links) { 224 this._links = await this.cache.get("links"); 225 226 // If we still have no links we are likely in first startup. 227 // In that case, we can fire off a background update. 228 if (!this._links) { 229 void this.update(numItems); 230 } 231 } 232 233 const links = this._links || []; 234 235 // Apply blocking at read time so it’s always current. 236 return links.filter( 237 link => !lazy.NewTabUtils.blockedLinks.isBlocked({ url: link.url }) 238 ); 239 } 240 241 async retrieveRandomFrecencyTile() { 242 if (!this._frecencyBoostedSponsors.size) { 243 await this._importFrecencyBoostedSponsors(); 244 } 245 246 const storedTile = await this.cache.get("randomFrecencyTile"); 247 if (storedTile) { 248 const tile = JSON.parse(storedTile); 249 if ( 250 this._frecencyBoostedSponsors.has(tile.hostname) && 251 !lazy.NewTabUtils.blockedLinks.isBlocked({ url: tile.url }) 252 ) { 253 return tile; 254 } 255 await this.cache.set("randomFrecencyTile", null); 256 } 257 258 const candidates = Array.from( 259 this._frecencyBoostedSponsors.values() 260 ).filter(s => !lazy.NewTabUtils.blockedLinks.isBlocked({ url: s.domain })); 261 262 if (!candidates.length) { 263 return null; 264 } 265 266 const selected = candidates[Math.floor(Math.random() * candidates.length)]; 267 const tile = { 268 hostname: selected.hostname, 269 url: selected.redirectURL, 270 label: selected.title, 271 partner: SPONSORED_TILE_PARTNER_FREC_BOOST, 272 type: "frecency-boost-random", 273 show_sponsored_label: true, 274 favicon: selected.faviconDataURI, 275 faviconSize: 96, 276 }; 277 await this.cache.set("randomFrecencyTile", JSON.stringify(tile)); 278 return tile; 279 } 280 281 async clearRandomFrecencyTile() { 282 await this.cache.set("randomFrecencyTile", null); 283 } 284 }