diff options
Diffstat (limited to 'browser/components/urlbar/private/AdmWikipedia.sys.mjs')
-rw-r--r-- | browser/components/urlbar/private/AdmWikipedia.sys.mjs | 280 |
1 files changed, 280 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/AdmWikipedia.sys.mjs b/browser/components/urlbar/private/AdmWikipedia.sys.mjs new file mode 100644 index 0000000000..4393ae317f --- /dev/null +++ b/browser/components/urlbar/private/AdmWikipedia.sys.mjs @@ -0,0 +1,280 @@ +/* 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 { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + SuggestionsMap: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +const NONSPONSORED_IAB_CATEGORIES = new Set(["5 - Education"]); + +/** + * A feature that manages sponsored adM and non-sponsored Wikpedia (sometimes + * called "expanded Wikipedia") suggestions in remote settings. + */ +export class AdmWikipedia extends BaseFeature { + constructor() { + super(); + this.#suggestionsMap = new lazy.SuggestionsMap(); + } + + get shouldEnable() { + return ( + lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsEnabled") && + (lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") || + lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) + ); + } + + get enablingPreferences() { + return [ + "suggest.quicksuggest.nonsponsored", + "suggest.quicksuggest.sponsored", + ]; + } + + enable(enabled) { + if (enabled) { + lazy.QuickSuggestRemoteSettings.register(this); + } else { + lazy.QuickSuggestRemoteSettings.unregister(this); + } + } + + async queryRemoteSettings(searchString) { + let suggestions = this.#suggestionsMap.get(searchString); + if (!suggestions) { + return []; + } + + // Start each icon fetch at the same time and wait for them all to finish. + let icons = await Promise.all( + suggestions.map(({ icon }) => this.#fetchIcon(icon)) + ); + + return suggestions.map(suggestion => ({ + full_keyword: this.#getFullKeyword(searchString, suggestion.keywords), + title: suggestion.title, + url: suggestion.url, + click_url: suggestion.click_url, + impression_url: suggestion.impression_url, + block_id: suggestion.id, + advertiser: suggestion.advertiser, + iab_category: suggestion.iab_category, + is_sponsored: !NONSPONSORED_IAB_CATEGORIES.has(suggestion.iab_category), + score: suggestion.score, + position: suggestion.position, + icon: icons.shift(), + })); + } + + async onRemoteSettingsSync(rs) { + let dataType = lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsDataType"); + this.logger.debug("Loading remote settings with type: " + dataType); + + let [data] = await Promise.all([ + rs.get({ filters: { type: dataType } }), + rs + .get({ filters: { type: "icon" } }) + .then(icons => + Promise.all(icons.map(i => rs.attachments.downloadToDisk(i))) + ), + ]); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + let suggestionsMap = new lazy.SuggestionsMap(); + + this.logger.debug(`Got data with ${data.length} records`); + for (let record of data) { + let { buffer } = await rs.attachments.download(record); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + + let results = JSON.parse(new TextDecoder("utf-8").decode(buffer)); + this.logger.debug(`Adding ${results.length} results`); + await suggestionsMap.add(results); + if (rs != lazy.QuickSuggestRemoteSettings.rs) { + return; + } + } + + this.#suggestionsMap = suggestionsMap; + } + + makeResult(queryContext, suggestion, searchString) { + // Replace the suggestion's template substrings, but first save the original + // URL before its timestamp template is replaced. + let originalUrl = suggestion.url; + lazy.QuickSuggest.replaceSuggestionTemplates(suggestion); + + let payload = { + originalUrl, + url: suggestion.url, + icon: suggestion.icon, + isSponsored: suggestion.is_sponsored, + source: suggestion.source, + telemetryType: suggestion.is_sponsored + ? "adm_sponsored" + : "adm_nonsponsored", + requestId: suggestion.request_id, + urlTimestampIndex: suggestion.urlTimestampIndex, + sponsoredImpressionUrl: suggestion.impression_url, + sponsoredClickUrl: suggestion.click_url, + sponsoredBlockId: suggestion.block_id, + sponsoredAdvertiser: suggestion.advertiser, + sponsoredIabCategory: suggestion.iab_category, + helpUrl: lazy.QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }; + + // Determine if the suggestion itself is a best match. + let isSuggestionBestMatch = false; + if (lazy.QuickSuggestRemoteSettings.config.best_match) { + let { best_match } = lazy.QuickSuggestRemoteSettings.config; + isSuggestionBestMatch = + best_match.min_search_string_length <= searchString.length && + !best_match.blocked_suggestion_ids.includes(suggestion.block_id); + } + + // Determine if the urlbar result should be a best match. + let isResultBestMatch = + isSuggestionBestMatch && + lazy.UrlbarPrefs.get("bestMatchEnabled") && + lazy.UrlbarPrefs.get("suggest.bestmatch"); + if (isResultBestMatch) { + // Show the result as a best match. Best match titles don't include the + // `full_keyword`, and the user's search string is highlighted. + payload.title = [suggestion.title, lazy.UrlbarUtils.HIGHLIGHT.TYPED]; + } else { + // Show the result as a usual quick suggest. Include the `full_keyword` + // and highlight the parts that aren't in the search string. + payload.title = suggestion.title; + payload.qsSuggestion = [ + suggestion.full_keyword, + lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED, + ]; + } + payload.isBlockable = lazy.UrlbarPrefs.get( + isResultBestMatch + ? "bestMatchBlockingEnabled" + : "quickSuggestBlockingEnabled" + ); + + let result = new lazy.UrlbarResult( + lazy.UrlbarUtils.RESULT_TYPE.URL, + lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + ...lazy.UrlbarResult.payloadAndSimpleHighlights( + queryContext.tokens, + payload + ) + ); + + if (isResultBestMatch) { + result.isBestMatch = true; + result.suggestedIndex = 1; + } + + return result; + } + + /** + * Gets the "full keyword" (i.e., suggestion) for a query from a list of + * keywords. The suggestions data doesn't include full keywords, so we make + * our own based on the result's keyword phrases and a particular query. We + * use two heuristics: + * + * (1) Find the first keyword phrase that has more words than the query. Use + * its first `queryWords.length` words as the full keyword. e.g., if the + * query is "moz" and `keywords` is ["moz", "mozi", "mozil", "mozill", + * "mozilla", "mozilla firefox"], pick "mozilla firefox", pop off the + * "firefox" and use "mozilla" as the full keyword. + * (2) If there isn't any keyword phrase with more words, then pick the + * longest phrase. e.g., pick "mozilla" in the previous example (assuming + * the "mozilla firefox" phrase isn't there). That might be the query + * itself. + * + * @param {string} query + * The query string. + * @param {Array} keywords + * An array of suggestion keywords. + * @returns {string} + * The full keyword. + */ + #getFullKeyword(query, keywords) { + let longerPhrase; + let trimmedQuery = query.toLocaleLowerCase().trim(); + let queryWords = trimmedQuery.split(" "); + + for (let phrase of keywords) { + if (phrase.startsWith(query)) { + let trimmedPhrase = phrase.trim(); + let phraseWords = trimmedPhrase.split(" "); + // As an exception to (1), if the query ends with a space, then look for + // phrases with one more word so that the suggestion includes a word + // following the space. + let extra = query.endsWith(" ") ? 1 : 0; + let len = queryWords.length + extra; + if (len < phraseWords.length) { + // We found a phrase with more words. + return phraseWords.slice(0, len).join(" "); + } + if ( + query.length < phrase.length && + (!longerPhrase || longerPhrase.length < trimmedPhrase.length) + ) { + // We found a longer phrase with the same number of words. + longerPhrase = trimmedPhrase; + } + } + } + return longerPhrase || trimmedQuery; + } + + /** + * Fetch the icon from RemoteSettings attachments. + * + * @param {string} path + * The icon's remote settings path. + */ + async #fetchIcon(path) { + if (!path) { + return null; + } + + let { rs } = lazy.QuickSuggestRemoteSettings; + if (!rs) { + return null; + } + + let record = ( + await rs.get({ + filters: { id: `icon-${path}` }, + }) + ).pop(); + if (!record) { + return null; + } + return rs.attachments.downloadToDisk(record); + } + + #suggestionsMap; +} |