/* 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 { SuggestProvider } from "resource:///modules/urlbar/private/SuggestFeature.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { GeolocationUtils: "resource:///modules/urlbar/private/GeolocationUtils.sys.mjs", GeonameMatchType: "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", YelpSubjectType: "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", }); const RESULT_MENU_COMMAND = { INACCURATE_LOCATION: "inaccurate_location", MANAGE: "manage", NOT_INTERESTED: "not_interested", NOT_RELEVANT: "not_relevant", SHOW_LESS_FREQUENTLY: "show_less_frequently", }; /** * A feature for Yelp suggestions. */ export class YelpSuggestions extends SuggestProvider { get enablingPreferences() { return [ "yelpFeatureGate", "suggest.yelp", "suggest.quicksuggest.sponsored", ]; } get primaryUserControlledPreference() { return "suggest.yelp"; } get rustSuggestionType() { return "Yelp"; } get mlIntent() { return "yelp_intent"; } get isMlIntentEnabled() { // Note that even when ML is enabled, we still leave Yelp Rust suggestions // enabled because we need to fetch the Yelp icon, URL, etc. from Rust, as // well as geonames, and Rust still needs to ingest all of that. return lazy.UrlbarPrefs.get("yelpMlEnabled"); } get showLessFrequentlyCount() { const count = lazy.UrlbarPrefs.get("yelp.showLessFrequentlyCount") || 0; return Math.max(count, 0); } get canShowLessFrequently() { const cap = lazy.UrlbarPrefs.get("yelpShowLessFrequentlyCap") || lazy.QuickSuggest.config.showLessFrequentlyCap || 0; return !cap || this.showLessFrequentlyCount < cap; } isSuggestionSponsored(_suggestion) { return true; } getSuggestionTelemetryType() { return "yelp"; } enable(enabled) { if (!enabled) { this.#metadataCache = null; } } async filterSuggestions(suggestions) { // Important notes: // // Both Rust and ML return at most one Yelp suggestion each. // // We leave Rust Yelp suggestions enabled even when ML Yelp is enabled // because we need to fetch the Yelp icon, URL, etc. from Rust, as well as // geonames, and Rust still needs to ingest all of that. Since we don't have // a way to tell the Rust backend to leave a suggestion type enabled without // querying it, `suggestions` can contain both kinds of suggestions. If ML // is enabled, return the ML suggestion; if it's disabled, return Rust. // // After this method returns, the Suggest provider will sort suggestions by // score and check whether they've been previously dismissed based on their // URLs. So we need to make sure suggestions have scores and URLs now. For // both Rust and ML suggestions, we'll make sure URLs at this point do *not* // contain a location param because we'll likely end up setting a new param // in `makeResult()`. That means for the purpose of dismissal, Yelp URLs // will exclude location. // // Since we're doing all the above in this method anyway, we'll also // normalize the suggestion so that `makeResult()` can easily handle either // kind of suggestion. let suggestion; if (!lazy.UrlbarPrefs.get("yelpMlEnabled")) { suggestion = suggestions.find(s => s.source != "ml"); if (suggestion) { suggestion = this.#normalizeRustSuggestion(suggestion); } } else { suggestion = suggestions.find(s => s.source == "ml"); if (suggestion) { if (!this.#metadataCache) { this.#metadataCache = await this.#makeMetadataCache(); } suggestion = this.#normalizeMlSuggestion(suggestion); } } return suggestion ? [suggestion] : []; } async makeResult(queryContext, suggestion, searchString) { // If the user clicked "Show less frequently" at least once or if the // subject wasn't typed in full, then apply the min length threshold and // return null if the entire search string is too short. if ( (this.showLessFrequentlyCount || !suggestion.subjectExactMatch) && searchString.length < this.#minKeywordLength ) { return null; } let { city, region } = suggestion; if (!city && !region) { // The user didn't specify any location at all, so use geolocation. If we // can't get the geolocation for some reason, that's fine, the suggestion // just won't have a location. let geo = await lazy.GeolocationUtils.geolocation(); if (geo) { city = geo.city; region = geo.region_code; } } else { // The user specified a city and/or region -- at least we think they did. // If we can't find a matching location, assume they're typing something // unrelated to Yelp and discard the suggestion by returning null. let match = await this.#bestCityRegion(city, region); if (!match) { return null; } city = match.city; region = match.region; } let url = new URL(suggestion.url); let title = suggestion.title; let locationStr = [city, region].filter(s => !!s).join(", "); if (locationStr) { url.searchParams.set(suggestion.locationParam, locationStr); if (!suggestion.hasLocationSign) { title += " in"; } title += " " + locationStr; } url.searchParams.set("utm_medium", "partner"); url.searchParams.set("utm_source", "mozilla"); let resultProperties = { isRichSuggestion: true, showFeedbackMenu: true, isBestMatch: lazy.UrlbarPrefs.get("yelpSuggestPriority"), }; if (!resultProperties.isBestMatch) { let suggestedIndex = lazy.UrlbarPrefs.get("yelpSuggestNonPriorityIndex"); if (suggestedIndex !== null) { resultProperties.isSuggestedIndexRelativeToGroup = true; resultProperties.suggestedIndex = suggestedIndex; } } let titleHighlights = lazy.UrlbarUtils.getTokenMatches( queryContext.tokens, title, lazy.UrlbarUtils.HIGHLIGHT.TYPED ); let payload = { url: url.toString(), originalUrl: suggestion.url, bottomTextL10n: { id: "firefox-suggest-yelp-bottom-text" }, iconBlob: suggestion.icon_blob, }; let highlights = {}; if ( lazy.UrlbarPrefs.get("yelpServiceResultDistinction") && suggestion.subjectType === lazy.YelpSubjectType.SERVICE ) { payload.titleL10n = { id: "firefox-suggest-yelp-service-title", args: { service: title, }, argsHighlights: { service: titleHighlights, }, }; } else { payload.title = title; highlights.title = titleHighlights; } return Object.assign( new lazy.UrlbarResult( lazy.UrlbarUtils.RESULT_TYPE.URL, lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, payload, highlights ), resultProperties ); } getResultCommands() { let commands = [ { name: RESULT_MENU_COMMAND.INACCURATE_LOCATION, l10n: { id: "urlbar-result-menu-report-inaccurate-location", }, }, ]; if (this.canShowLessFrequently) { commands.push({ name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY, l10n: { id: "urlbar-result-menu-show-less-frequently", }, }); } commands.push( { l10n: { id: "firefox-suggest-command-dont-show-this", }, children: [ { name: RESULT_MENU_COMMAND.NOT_RELEVANT, l10n: { id: "firefox-suggest-command-not-relevant", }, }, { name: RESULT_MENU_COMMAND.NOT_INTERESTED, l10n: { id: "firefox-suggest-command-not-interested", }, }, ], }, { name: "separator" }, { name: RESULT_MENU_COMMAND.MANAGE, l10n: { id: "urlbar-result-menu-manage-firefox-suggest", }, } ); return commands; } onEngagement(queryContext, controller, details, searchString) { let { result } = details; switch (details.selType) { case RESULT_MENU_COMMAND.MANAGE: // "manage" is handled by UrlbarInput, no need to do anything here. break; case RESULT_MENU_COMMAND.INACCURATE_LOCATION: // Currently the only way we record this feedback is in the Glean // engagement event. As with all commands, it will be recorded with an // `engagement_type` value that is the command's name, in this case // `inaccurate_location`. controller.view.acknowledgeFeedback(result); break; // selType == "dismiss" when the user presses the dismiss key shortcut. case "dismiss": case RESULT_MENU_COMMAND.NOT_RELEVANT: lazy.QuickSuggest.dismissResult(result); result.acknowledgeDismissalL10n = { id: "firefox-suggest-dismissal-acknowledgment-one-yelp", }; controller.removeResult(result); break; case RESULT_MENU_COMMAND.NOT_INTERESTED: lazy.UrlbarPrefs.set("suggest.yelp", false); result.acknowledgeDismissalL10n = { id: "firefox-suggest-dismissal-acknowledgment-all-yelp", }; controller.removeResult(result); break; case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY: controller.view.acknowledgeFeedback(result); this.incrementShowLessFrequentlyCount(); if (!this.canShowLessFrequently) { controller.view.invalidateResultMenuCommands(); } lazy.UrlbarPrefs.set("yelp.minKeywordLength", searchString.length + 1); break; } } incrementShowLessFrequentlyCount() { if (this.canShowLessFrequently) { lazy.UrlbarPrefs.set( "yelp.showLessFrequentlyCount", this.showLessFrequentlyCount + 1 ); } } get #minKeywordLength() { // Use the pref value if it has a user value (which means the user clicked // "Show less frequently") or if there's no Nimbus value. Otherwise use the // Nimbus value. This lets us override the pref's default value using Nimbus // if necessary. let hasUserValue = Services.prefs.prefHasUserValue( "browser.urlbar.yelp.minKeywordLength" ); let nimbusValue = lazy.UrlbarPrefs.get("yelpMinKeywordLength"); let minLength = hasUserValue || nimbusValue === null ? lazy.UrlbarPrefs.get("yelp.minKeywordLength") : nimbusValue; return Math.max(minLength, 0); } #normalizeRustSuggestion(suggestion) { // TODO: The Rust component should be updated to return Yelp suggestions // that don't require us to make these modifications. // Rust Yelp suggestions don't currently specify the city and region // separately. Instead the location param in the URL contains whatever was // left over at the end of the search string. We'll assume it's a city. If // it's actually a region, then unfortunately we'll discard the suggestion // because it won't match any cities in our DB, but it's much more likely // for it to be a city. let url = new URL(suggestion.url); let loc = url.searchParams.get(suggestion.locationParam); if (loc) { // Normalized suggestion URLs should not include the location. See // `filterSuggestions()`. url.searchParams.delete(suggestion.locationParam); suggestion.url = url.toString(); suggestion.city = loc; // Rust includes the location in the title, but we'll want to replace it // with the location we compute in `makeResult()`, so remove it. if (suggestion.title.endsWith(loc)) { suggestion.title = suggestion.title .substring(0, suggestion.title.length - loc.length) .trimEnd(); } } return suggestion; } #normalizeMlSuggestion(ml) { // The ML model can return false positives, including Yelp-intent // suggestions with nothing but a city or region, no subject. Discard them. if (!ml.subject) { return null; } let url = new URL(this.#metadataCache.urlOrigin); url.pathname = this.#metadataCache.urlPathname; url.searchParams.set(this.#metadataCache.findDesc, ml.subject); return { ...ml, title: ml.subject, url: url.toString(), subjectExactMatch: false, hasLocationSign: false, locationParam: this.#metadataCache.findLoc, icon_blob: this.#metadataCache.iconBlob, score: this.#metadataCache.score, city: ml.location?.city, region: ml.location?.state, }; } /** * TODO Bug 1926782: ML suggestions don't include an icon, score, or URL, so * for now we directly query the Rust backend with a known Yelp keyword and * location to get all of that information and then cache it in * `#metadataCache`. If the known Yelp suggestion is absent for some reason, * we fall back to hardcoded values. This is a tad hacky and we should come up * with something better. * * @returns {object} * The metadata cache. */ async #makeMetadataCache() { let cache; this.logger.debug("Querying Rust backend to populate metadata cache"); let rs = await lazy.QuickSuggest.rustBackend.query("coffee in atlanta", { types: ["Yelp"], }); if (!rs.length) { this.logger.debug("Rust didn't return any Yelp suggestions!"); cache = {}; } else { let suggestion = rs[0]; let url = new URL(suggestion.url); let findParamWithValue = value => { let tuple = [...url.searchParams.entries()].find( ([_, v]) => v == value ); return tuple?.[0]; }; cache = { iconBlob: suggestion.icon_blob, score: suggestion.score, urlOrigin: url.origin, urlPathname: url.pathname, findDesc: findParamWithValue("coffee"), findLoc: findParamWithValue("atlanta"), }; } let defaults = { urlOrigin: "https://www.yelp.com", urlPathname: "/search", findDesc: "find_desc", findLoc: "find_loc", score: 0.25, }; for (let [key, value] of Object.entries(defaults)) { if (cache[key] === undefined) { cache[key] = value; } } return cache; } /** * Looks up a city-region in the Suggest database and returns the one that * best matches the client's geolocation. * * @param {string|null} city * The candidate city name or null if you're only matching regions. * @param {region|null} region * The candidate region name or abbreviation, or null if you're only * matching cities. * @returns {object|null} * If a city was passed in and it didn't match a city in the DB, or if a * region was passed in and it didn't match a region in the DB, null is * returned. Null is also returned if both were passed but they aren't a * valid city-region combination. Otherwise, an object `{ city, region }` is * returned: * * {string|null} city * The best matching city's name, or if the passed-in city was null and a * region was matched, this will be null. * {string} region * The best matching region. If a city was matched, it will be the ISO * code of the city's region (e.g., the usual two-letter abbreviation for * U.S. states). If a city wasn't passed in, this will be the best * matching region's name. */ async #bestCityRegion(city, region) { // Match the region first since we'll use region matches to filter city // matches. We'll do prefix matching on cities below, so to avoid even more // time and work that's probably unnecessary, don't do it for regions. let regionMatches; if (region) { regionMatches = await lazy.QuickSuggest.rustBackend.fetchGeonames( region, false, // prefix matching null // geonames filter array ); if (!regionMatches.length) { // The user typed something we thought was a region but isn't, so assume // the query is not Yelp-related after all. return null; } } if (city) { let cityMatches = await lazy.QuickSuggest.rustBackend.fetchGeonames( city, true, // prefix matching regionMatches?.map(m => m.geoname) ); // Discard prefix matches on any names that aren't full names, i.e., on // abbreviations and airport codes. Airport codes especially can sometimes // be surprising (e.g., "act" for Waco, TX), and we don't want to return // too many false positives. cityMatches = cityMatches.filter( match => match.matchType == lazy.GeonameMatchType.NAME || !match.prefix ); if (!cityMatches.length) { // The user typed something we thought was a city but isn't, so assume // the query is not Yelp-related after all. return null; } // Return the best city for the user's geolocation. let best = await lazy.GeolocationUtils.best( cityMatches, locationFromGeonameMatch ); return { city: best.geoname.name, region: best.geoname.adminDivisionCodes.get(1), }; } // We didn't detect a city in the query but we detected a region, so try to // return at least that, but only if a full name was matched, not an // abbreviation. Abbreviations are too short and make it too easy to return // false positives. For example, after the user types "ramen in", we // probably shouldn't match "in" to Indiana. regionMatches = regionMatches?.filter( match => match.matchType == lazy.GeonameMatchType.NAME ); if (regionMatches?.length) { let best = await lazy.GeolocationUtils.best( regionMatches, locationFromGeonameMatch ); return { city: null, region: best.geoname.name }; } return null; } _test_invalidateMetadataCache() { this.#metadataCache = null; } #metadataCache = null; } /** * A function that can be passed to `GeolocationUtils.best()` as * `locationFromItem`. It maps `GeonameMatch` objects to the location objects * required by that function. * * @param {GeonameMatch} match * A match object. * @returns {object} * A location object suitable for `GeolocationUtils`. */ function locationFromGeonameMatch(match) { return { latitude: match.geoname.latitude, longitude: match.geoname.longitude, country: match.geoname.countryCode, region: match.geoname.adminDivisionCodes.get(1), population: match.geoname.population, }; }