YelpSuggestions.sys.mjs (20160B)
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 { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 GeolocationUtils: 11 "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs", 12 GeonameMatchType: 13 "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", 14 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 15 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 16 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 17 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 18 YelpSubjectType: 19 "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", 20 }); 21 22 /** 23 * @import {GeonameMatch} from "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs" 24 */ 25 26 const RESULT_MENU_COMMAND = { 27 INACCURATE_LOCATION: "inaccurate_location", 28 MANAGE: "manage", 29 NOT_INTERESTED: "not_interested", 30 NOT_RELEVANT: "not_relevant", 31 SHOW_LESS_FREQUENTLY: "show_less_frequently", 32 }; 33 34 /** 35 * A feature for Yelp suggestions. 36 */ 37 export class YelpSuggestions extends SuggestProvider { 38 get enablingPreferences() { 39 return [ 40 "yelpFeatureGate", 41 "suggest.yelp", 42 "suggest.quicksuggest.all", 43 "suggest.quicksuggest.sponsored", 44 ]; 45 } 46 47 get primaryUserControlledPreferences() { 48 return ["suggest.yelp"]; 49 } 50 51 get rustSuggestionType() { 52 return "Yelp"; 53 } 54 55 get mlIntent() { 56 return "yelp_intent"; 57 } 58 59 get isMlIntentEnabled() { 60 // Note that even when ML is enabled, we still leave Yelp Rust suggestions 61 // enabled because we need to fetch the Yelp icon, URL, etc. from Rust, as 62 // well as geonames, and Rust still needs to ingest all of that. 63 return lazy.UrlbarPrefs.get("yelpMlEnabled"); 64 } 65 66 get showLessFrequentlyCount() { 67 const count = lazy.UrlbarPrefs.get("yelp.showLessFrequentlyCount") || 0; 68 return Math.max(count, 0); 69 } 70 71 get canShowLessFrequently() { 72 const cap = 73 lazy.UrlbarPrefs.get("yelpShowLessFrequentlyCap") || 74 lazy.QuickSuggest.config.showLessFrequentlyCap || 75 0; 76 return !cap || this.showLessFrequentlyCount < cap; 77 } 78 79 isSuggestionSponsored(_suggestion) { 80 return true; 81 } 82 83 getSuggestionTelemetryType() { 84 return "yelp"; 85 } 86 87 enable(enabled) { 88 if (!enabled) { 89 this.#metadataCache = null; 90 } 91 } 92 93 async filterSuggestions(suggestions) { 94 // Important notes: 95 // 96 // Both Rust and ML return at most one Yelp suggestion each. 97 // 98 // We leave Rust Yelp suggestions enabled even when ML Yelp is enabled 99 // because we need to fetch the Yelp icon, URL, etc. from Rust, as well as 100 // geonames, and Rust still needs to ingest all of that. Since we don't have 101 // a way to tell the Rust backend to leave a suggestion type enabled without 102 // querying it, `suggestions` can contain both kinds of suggestions. If ML 103 // is enabled, return the ML suggestion; if it's disabled, return Rust. 104 // 105 // After this method returns, the Suggest provider will sort suggestions by 106 // score and check whether they've been previously dismissed based on their 107 // URLs. So we need to make sure suggestions have scores and URLs now. For 108 // both Rust and ML suggestions, we'll make sure URLs at this point do *not* 109 // contain a location param because we'll likely end up setting a new param 110 // in `makeResult()`. That means for the purpose of dismissal, Yelp URLs 111 // will exclude location. 112 // 113 // Since we're doing all the above in this method anyway, we'll also 114 // normalize the suggestion so that `makeResult()` can easily handle either 115 // kind of suggestion. 116 117 let suggestion; 118 if (!lazy.UrlbarPrefs.get("yelpMlEnabled")) { 119 suggestion = suggestions.find(s => s.source != "ml"); 120 if (suggestion) { 121 suggestion = this.#normalizeRustSuggestion(suggestion); 122 } 123 } else { 124 suggestion = suggestions.find(s => s.source == "ml"); 125 if (suggestion) { 126 if (!this.#metadataCache) { 127 this.#metadataCache = await this.#makeMetadataCache(); 128 } 129 suggestion = this.#normalizeMlSuggestion(suggestion); 130 } 131 } 132 133 return suggestion ? [suggestion] : []; 134 } 135 136 async makeResult(queryContext, suggestion, searchString) { 137 // If the user clicked "Show less frequently" at least once or if the 138 // subject wasn't typed in full, then apply the min length threshold and 139 // return null if the entire search string is too short. 140 if ( 141 (this.showLessFrequentlyCount || !suggestion.subjectExactMatch) && 142 searchString.length < this.#minKeywordLength 143 ) { 144 return null; 145 } 146 147 let { city, region } = suggestion; 148 if (!city && !region) { 149 // The user didn't specify any location at all, so use geolocation. If we 150 // can't get the geolocation for some reason, that's fine, the suggestion 151 // just won't have a location. 152 let geo = await lazy.GeolocationUtils.geolocation(); 153 if (geo) { 154 city = geo.city; 155 region = geo.region_code; 156 } 157 } else { 158 // The user specified a city and/or region -- at least we think they did. 159 // If we can't find a matching location, assume they're typing something 160 // unrelated to Yelp and discard the suggestion by returning null. 161 let match = await this.#bestCityRegion(city, region); 162 if (!match) { 163 return null; 164 } 165 city = match.city; 166 region = match.region; 167 } 168 169 let url = new URL(suggestion.url); 170 171 let title = suggestion.title; 172 let locationStr = [city, region].filter(s => !!s).join(", "); 173 if (locationStr) { 174 url.searchParams.set(suggestion.locationParam, locationStr); 175 if (!suggestion.hasLocationSign) { 176 title += " in"; 177 } 178 title += " " + locationStr; 179 } 180 181 url.searchParams.set("utm_medium", "partner"); 182 url.searchParams.set("utm_source", "mozilla"); 183 184 let resultProperties = { 185 isRichSuggestion: true, 186 showFeedbackMenu: true, 187 isBestMatch: lazy.UrlbarPrefs.get("yelpSuggestPriority"), 188 }; 189 if (!resultProperties.isBestMatch) { 190 let suggestedIndex = lazy.UrlbarPrefs.get("yelpSuggestNonPriorityIndex"); 191 if (suggestedIndex !== null) { 192 resultProperties.isSuggestedIndexRelativeToGroup = true; 193 resultProperties.suggestedIndex = suggestedIndex; 194 } 195 } 196 197 let payload = { 198 url: url.toString(), 199 originalUrl: suggestion.url, 200 bottomTextL10n: { 201 id: "firefox-suggest-yelp-bottom-text", 202 }, 203 iconBlob: suggestion.icon_blob, 204 }; 205 let highlights; 206 207 if ( 208 lazy.UrlbarPrefs.get("yelpServiceResultDistinction") && 209 suggestion.subjectType === lazy.YelpSubjectType.SERVICE 210 ) { 211 let titleHighlights = lazy.UrlbarUtils.getTokenMatches( 212 queryContext.tokens, 213 title, 214 lazy.UrlbarUtils.HIGHLIGHT.TYPED 215 ); 216 payload.titleL10n = { 217 id: "firefox-suggest-yelp-service-title", 218 args: { 219 service: title, 220 }, 221 argsHighlights: { 222 service: titleHighlights, 223 }, 224 }; 225 // Used for the tooltip. 226 payload.title = title; 227 } else { 228 payload.title = title; 229 highlights = { 230 title: lazy.UrlbarUtils.HIGHLIGHT.TYPED, 231 }; 232 } 233 234 return new lazy.UrlbarResult({ 235 type: lazy.UrlbarUtils.RESULT_TYPE.URL, 236 source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, 237 ...resultProperties, 238 payload, 239 highlights, 240 }); 241 } 242 243 /** 244 * @typedef {object} L10nItem 245 * @property {Values<RESULT_MENU_COMMAND>} [name] 246 * The name of the command. 247 * @property {{id: string}} [l10n] 248 * The id of the l10n string to use for the translation. 249 */ 250 251 /** 252 * Gets the list of commands that should be shown in the result menu for a 253 * given result from the provider. All commands returned by this method should 254 * be handled by implementing `onEngagement()` with the possible exception of 255 * commands automatically handled by the urlbar, like "help". 256 */ 257 getResultCommands() { 258 /** @type {UrlbarResultCommand[]} */ 259 let commands = [ 260 { 261 name: RESULT_MENU_COMMAND.INACCURATE_LOCATION, 262 l10n: { 263 id: "urlbar-result-menu-report-inaccurate-location", 264 }, 265 }, 266 ]; 267 268 if (this.canShowLessFrequently) { 269 commands.push({ 270 name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY, 271 l10n: { 272 id: "urlbar-result-menu-show-less-frequently", 273 }, 274 }); 275 } 276 277 commands.push( 278 { 279 l10n: { 280 id: "firefox-suggest-command-dont-show-this", 281 }, 282 children: [ 283 { 284 name: RESULT_MENU_COMMAND.NOT_RELEVANT, 285 l10n: { 286 id: "firefox-suggest-command-not-relevant", 287 }, 288 }, 289 { 290 name: RESULT_MENU_COMMAND.NOT_INTERESTED, 291 l10n: { 292 id: "firefox-suggest-command-not-interested", 293 }, 294 }, 295 ], 296 }, 297 { name: "separator" }, 298 { 299 name: RESULT_MENU_COMMAND.MANAGE, 300 l10n: { 301 id: "urlbar-result-menu-manage-firefox-suggest", 302 }, 303 } 304 ); 305 306 return commands; 307 } 308 309 onEngagement(queryContext, controller, details, searchString) { 310 let { result } = details; 311 switch (details.selType) { 312 case RESULT_MENU_COMMAND.MANAGE: 313 // "manage" is handled by UrlbarInput, no need to do anything here. 314 break; 315 case RESULT_MENU_COMMAND.INACCURATE_LOCATION: 316 // Currently the only way we record this feedback is in the Glean 317 // engagement event. As with all commands, it will be recorded with an 318 // `engagement_type` value that is the command's name, in this case 319 // `inaccurate_location`. 320 controller.view.acknowledgeFeedback(result); 321 break; 322 // selType == "dismiss" when the user presses the dismiss key shortcut. 323 case "dismiss": 324 case RESULT_MENU_COMMAND.NOT_RELEVANT: 325 lazy.QuickSuggest.dismissResult(result); 326 result.acknowledgeDismissalL10n = { 327 id: "firefox-suggest-dismissal-acknowledgment-one-yelp", 328 }; 329 controller.removeResult(result); 330 break; 331 case RESULT_MENU_COMMAND.NOT_INTERESTED: 332 lazy.UrlbarPrefs.set("suggest.yelp", false); 333 result.acknowledgeDismissalL10n = { 334 id: "firefox-suggest-dismissal-acknowledgment-all-yelp", 335 }; 336 controller.removeResult(result); 337 break; 338 case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY: 339 controller.view.acknowledgeFeedback(result); 340 this.incrementShowLessFrequentlyCount(); 341 if (!this.canShowLessFrequently) { 342 controller.view.invalidateResultMenuCommands(); 343 } 344 lazy.UrlbarPrefs.set("yelp.minKeywordLength", searchString.length + 1); 345 break; 346 } 347 } 348 349 incrementShowLessFrequentlyCount() { 350 if (this.canShowLessFrequently) { 351 lazy.UrlbarPrefs.set( 352 "yelp.showLessFrequentlyCount", 353 this.showLessFrequentlyCount + 1 354 ); 355 } 356 } 357 358 get #minKeywordLength() { 359 // Use the pref value if it has a user value (which means the user clicked 360 // "Show less frequently") or if there's no Nimbus value. Otherwise use the 361 // Nimbus value. This lets us override the pref's default value using Nimbus 362 // if necessary. 363 let hasUserValue = Services.prefs.prefHasUserValue( 364 "browser.urlbar.yelp.minKeywordLength" 365 ); 366 let nimbusValue = lazy.UrlbarPrefs.get("yelpMinKeywordLength"); 367 let minLength = 368 hasUserValue || nimbusValue === null 369 ? lazy.UrlbarPrefs.get("yelp.minKeywordLength") 370 : nimbusValue; 371 return Math.max(minLength, 0); 372 } 373 374 #normalizeRustSuggestion(suggestion) { 375 // TODO: The Rust component should be updated to return Yelp suggestions 376 // that don't require us to make these modifications. 377 378 // Rust Yelp suggestions don't currently specify the city and region 379 // separately. Instead the location param in the URL contains whatever was 380 // left over at the end of the search string. We'll assume it's a city. If 381 // it's actually a region, then unfortunately we'll discard the suggestion 382 // because it won't match any cities in our DB, but it's much more likely 383 // for it to be a city. 384 let url = new URL(suggestion.url); 385 let loc = url.searchParams.get(suggestion.locationParam); 386 if (loc) { 387 // Normalized suggestion URLs should not include the location. See 388 // `filterSuggestions()`. 389 url.searchParams.delete(suggestion.locationParam); 390 suggestion.url = url.toString(); 391 suggestion.city = loc; 392 393 // Rust includes the location in the title, but we'll want to replace it 394 // with the location we compute in `makeResult()`, so remove it. 395 if (suggestion.title.endsWith(loc)) { 396 suggestion.title = suggestion.title 397 .substring(0, suggestion.title.length - loc.length) 398 .trimEnd(); 399 } 400 } 401 402 return suggestion; 403 } 404 405 #normalizeMlSuggestion(ml) { 406 // The ML model can return false positives, including Yelp-intent 407 // suggestions with nothing but a city or region, no subject. Discard them. 408 if (!ml.subject) { 409 return null; 410 } 411 412 let url = new URL(this.#metadataCache.urlOrigin); 413 url.pathname = this.#metadataCache.urlPathname; 414 url.searchParams.set(this.#metadataCache.findDesc, ml.subject); 415 416 return { 417 ...ml, 418 title: ml.subject, 419 url: url.toString(), 420 subjectExactMatch: false, 421 hasLocationSign: false, 422 locationParam: this.#metadataCache.findLoc, 423 icon_blob: this.#metadataCache.iconBlob, 424 score: this.#metadataCache.score, 425 city: ml.location?.city, 426 region: ml.location?.state, 427 }; 428 } 429 430 /** 431 * TODO Bug 1926782: ML suggestions don't include an icon, score, or URL, so 432 * for now we directly query the Rust backend with a known Yelp keyword and 433 * location to get all of that information and then cache it in 434 * `#metadataCache`. If the known Yelp suggestion is absent for some reason, 435 * we fall back to hardcoded values. This is a tad hacky and we should come up 436 * with something better. 437 */ 438 async #makeMetadataCache() { 439 let cache; 440 441 this.logger.debug("Querying Rust backend to populate metadata cache"); 442 let rs = await lazy.QuickSuggest.rustBackend.query("coffee in atlanta", { 443 types: ["Yelp"], 444 }); 445 if (!rs.length) { 446 this.logger.debug("Rust didn't return any Yelp suggestions!"); 447 cache = {}; 448 } else { 449 let suggestion = rs[0]; 450 let url = new URL(suggestion.url); 451 let findParamWithValue = value => { 452 let tuple = [...url.searchParams.entries()].find( 453 ([_, v]) => v == value 454 ); 455 return tuple?.[0]; 456 }; 457 cache = { 458 iconBlob: suggestion.icon_blob, 459 score: suggestion.score, 460 urlOrigin: url.origin, 461 urlPathname: url.pathname, 462 findDesc: findParamWithValue("coffee"), 463 findLoc: findParamWithValue("atlanta"), 464 }; 465 } 466 467 let defaults = { 468 urlOrigin: "https://www.yelp.com", 469 urlPathname: "/search", 470 findDesc: "find_desc", 471 findLoc: "find_loc", 472 score: 0.25, 473 }; 474 for (let [key, value] of Object.entries(defaults)) { 475 if (cache[key] === undefined) { 476 cache[key] = value; 477 } 478 } 479 480 return cache; 481 } 482 483 /** 484 * Looks up a city-region in the Suggest database and returns the one that 485 * best matches the client's geolocation. 486 * 487 * @param {string|null} city 488 * The candidate city name or null if you're only matching regions. 489 * @param {string|null} region 490 * The candidate region name or abbreviation, or null if you're only 491 * matching cities. 492 * @returns {Promise<{city: string|null, region: string|null}|null>} 493 * If a city was passed in and it didn't match a city in the DB, or if a 494 * region was passed in and it didn't match a region in the DB, null is 495 * returned. Null is also returned if both were passed but they aren't a 496 * valid city-region combination. Otherwise, an object `{ city, region }` is 497 * returned: 498 * 499 * city 500 * The best matching city's name, or if the passed-in city was null and a 501 * region was matched, this will be null. 502 * region 503 * The best matching region. If a city was matched, it will be the ISO 504 * code of the city's region (e.g., the usual two-letter abbreviation for 505 * U.S. states). If a city wasn't passed in, this will be the best 506 * matching region's name. 507 */ 508 async #bestCityRegion(city, region) { 509 // Match the region first since we'll use region matches to filter city 510 // matches. We'll do prefix matching on cities below, so to avoid even more 511 // time and work that's probably unnecessary, don't do it for regions. 512 let regionMatches; 513 if (region) { 514 regionMatches = await lazy.QuickSuggest.rustBackend.fetchGeonames( 515 region, 516 false, // prefix matching 517 null // geonames filter array 518 ); 519 if (!regionMatches.length) { 520 // The user typed something we thought was a region but isn't, so assume 521 // the query is not Yelp-related after all. 522 return null; 523 } 524 } 525 526 if (city) { 527 let cityMatches = await lazy.QuickSuggest.rustBackend.fetchGeonames( 528 city, 529 true, // prefix matching 530 regionMatches?.map(m => m.geoname) 531 ); 532 // Discard prefix matches on any names that aren't full names, i.e., on 533 // abbreviations and airport codes. Airport codes especially can sometimes 534 // be surprising (e.g., "act" for Waco, TX), and we don't want to return 535 // too many false positives. 536 cityMatches = cityMatches.filter( 537 match => match.matchType == lazy.GeonameMatchType.NAME || !match.prefix 538 ); 539 if (!cityMatches.length) { 540 // The user typed something we thought was a city but isn't, so assume 541 // the query is not Yelp-related after all. 542 return null; 543 } 544 545 // Return the best city for the user's geolocation. 546 let best = await lazy.GeolocationUtils.best( 547 cityMatches, 548 locationFromGeonameMatch 549 ); 550 return { 551 city: best.geoname.name, 552 region: best.geoname.adminDivisionCodes.get(1), 553 }; 554 } 555 556 // We didn't detect a city in the query but we detected a region, so try to 557 // return at least that, but only if a full name was matched, not an 558 // abbreviation. Abbreviations are too short and make it too easy to return 559 // false positives. For example, after the user types "ramen in", we 560 // probably shouldn't match "in" to Indiana. 561 regionMatches = regionMatches?.filter( 562 match => match.matchType == lazy.GeonameMatchType.NAME 563 ); 564 if (regionMatches?.length) { 565 let best = await lazy.GeolocationUtils.best( 566 regionMatches, 567 locationFromGeonameMatch 568 ); 569 return { city: null, region: best.geoname.name }; 570 } 571 572 return null; 573 } 574 575 _test_invalidateMetadataCache() { 576 this.#metadataCache = null; 577 } 578 579 #metadataCache = null; 580 } 581 582 /** 583 * A function that can be passed to `GeolocationUtils.best()` as 584 * `locationFromItem`. It maps `GeonameMatch` objects to the location objects 585 * required by that function. 586 * 587 * @param {GeonameMatch} match 588 * A match object. 589 * @returns {object} 590 * A location object suitable for `GeolocationUtils`. 591 */ 592 function locationFromGeonameMatch(match) { 593 return { 594 latitude: match.geoname.latitude, 595 longitude: match.geoname.longitude, 596 country: match.geoname.countryCode, 597 region: match.geoname.adminDivisionCodes.get(1), 598 population: match.geoname.population, 599 }; 600 }