Bug 1856736: Revert group labels and result labels to their previous appearance r=adw
[gecko.git] / browser / components / urlbar / private / AdmWikipedia.sys.mjs
blobbac27f0f6251a8399d9277183a9a587e5a174ffa
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/. */
5 import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
11   SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
12   UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
13   UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
14   UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
15 });
17 const NONSPONSORED_IAB_CATEGORIES = new Set(["5 - Education"]);
19 /**
20  * A feature that manages sponsored adM and non-sponsored Wikpedia (sometimes
21  * called "expanded Wikipedia") suggestions in remote settings.
22  */
23 export class AdmWikipedia extends BaseFeature {
24   constructor() {
25     super();
26     this.#suggestionsMap = new lazy.SuggestionsMap();
27   }
29   get shouldEnable() {
30     return (
31       lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsEnabled") &&
32       (lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") ||
33         lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored"))
34     );
35   }
37   get enablingPreferences() {
38     return [
39       "suggest.quicksuggest.nonsponsored",
40       "suggest.quicksuggest.sponsored",
41     ];
42   }
44   get merinoProvider() {
45     return "adm";
46   }
48   get rustSuggestionTypes() {
49     return ["Amp", "Wikipedia"];
50   }
52   getSuggestionTelemetryType(suggestion) {
53     return suggestion.is_sponsored ? "adm_sponsored" : "adm_nonsponsored";
54   }
56   enable(enabled) {
57     if (enabled) {
58       lazy.QuickSuggest.jsBackend.register(this);
59     } else {
60       lazy.QuickSuggest.jsBackend.unregister(this);
61     }
62   }
64   async queryRemoteSettings(searchString) {
65     let suggestions = this.#suggestionsMap.get(searchString);
66     if (!suggestions) {
67       return [];
68     }
70     // Start each icon fetch at the same time and wait for them all to finish.
71     let icons = await Promise.all(
72       suggestions.map(({ icon }) => this.#fetchIcon(icon))
73     );
75     return suggestions.map(suggestion => ({
76       full_keyword: this.#getFullKeyword(searchString, suggestion.keywords),
77       title: suggestion.title,
78       url: suggestion.url,
79       click_url: suggestion.click_url,
80       impression_url: suggestion.impression_url,
81       block_id: suggestion.id,
82       advertiser: suggestion.advertiser,
83       iab_category: suggestion.iab_category,
84       is_sponsored: !NONSPONSORED_IAB_CATEGORIES.has(suggestion.iab_category),
85       score: suggestion.score,
86       position: suggestion.position,
87       icon: icons.shift(),
88     }));
89   }
91   async onRemoteSettingsSync(rs) {
92     let dataType = lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsDataType");
93     this.logger.debug("Loading remote settings with type: " + dataType);
95     let [data] = await Promise.all([
96       rs.get({ filters: { type: dataType } }),
97       rs
98         .get({ filters: { type: "icon" } })
99         .then(icons =>
100           Promise.all(icons.map(i => rs.attachments.downloadToDisk(i)))
101         ),
102     ]);
103     if (!this.isEnabled) {
104       return;
105     }
107     let suggestionsMap = new lazy.SuggestionsMap();
109     this.logger.debug(`Got data with ${data.length} records`);
110     for (let record of data) {
111       let { buffer } = await rs.attachments.download(record);
112       if (!this.isEnabled) {
113         return;
114       }
116       let results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
117       this.logger.debug(`Adding ${results.length} results`);
118       await suggestionsMap.add(results);
119       if (!this.isEnabled) {
120         return;
121       }
122     }
124     this.#suggestionsMap = suggestionsMap;
125   }
127   makeResult(queryContext, suggestion, searchString) {
128     if (suggestion.source == "rust") {
129       suggestion = {
130         title: suggestion.title,
131         url: suggestion.url,
132         icon: suggestion.icon,
133         is_sponsored: suggestion.is_sponsored,
134         full_keyword: suggestion.fullKeyword,
135         impression_url: suggestion.impressionUrl,
136         click_url: suggestion.clickUrl,
137         block_id: suggestion.blockId,
138         advertiser: suggestion.advertiser,
139         iab_category: suggestion.iabCategory,
140       };
141     }
143     // Replace the suggestion's template substrings, but first save the original
144     // URL before its timestamp template is replaced.
145     let originalUrl = suggestion.url;
146     lazy.QuickSuggest.replaceSuggestionTemplates(suggestion);
148     let payload = {
149       originalUrl,
150       url: suggestion.url,
151       icon: suggestion.icon,
152       isSponsored: suggestion.is_sponsored,
153       requestId: suggestion.request_id,
154       urlTimestampIndex: suggestion.urlTimestampIndex,
155       sponsoredImpressionUrl: suggestion.impression_url,
156       sponsoredClickUrl: suggestion.click_url,
157       sponsoredBlockId: suggestion.block_id,
158       sponsoredAdvertiser: suggestion.advertiser,
159       sponsoredIabCategory: suggestion.iab_category,
160       helpUrl: lazy.QuickSuggest.HELP_URL,
161       helpL10n: {
162         id: "urlbar-result-menu-learn-more-about-firefox-suggest",
163       },
164       blockL10n: {
165         id: "urlbar-result-menu-dismiss-firefox-suggest",
166       },
167     };
169     // Determine if the suggestion itself is a best match.
170     let isSuggestionBestMatch = false;
171     if (lazy.QuickSuggest.jsBackend.config.best_match) {
172       let { best_match } = lazy.QuickSuggest.jsBackend.config;
173       isSuggestionBestMatch =
174         best_match.min_search_string_length <= searchString.length &&
175         !best_match.blocked_suggestion_ids.includes(suggestion.block_id);
176     }
178     // Determine if the urlbar result should be a best match.
179     let isResultBestMatch =
180       isSuggestionBestMatch &&
181       lazy.UrlbarPrefs.get("bestMatchEnabled") &&
182       lazy.UrlbarPrefs.get("suggest.bestmatch");
183     if (isResultBestMatch) {
184       // Show the result as a best match. Best match titles don't include the
185       // `full_keyword`, and the user's search string is highlighted.
186       payload.title = [suggestion.title, lazy.UrlbarUtils.HIGHLIGHT.TYPED];
187     } else {
188       // Show the result as a usual quick suggest. Include the `full_keyword`
189       // and highlight the parts that aren't in the search string.
190       payload.title = suggestion.title;
191       payload.qsSuggestion = [
192         suggestion.full_keyword,
193         lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED,
194       ];
195     }
197     // Set `is_top_pick` on the suggestion to tell the provider to set
198     // best-match related properties on the result.
199     suggestion.is_top_pick = isResultBestMatch;
201     payload.isBlockable = lazy.UrlbarPrefs.get(
202       isResultBestMatch
203         ? "bestMatchBlockingEnabled"
204         : "quickSuggestBlockingEnabled"
205     );
207     let result = new lazy.UrlbarResult(
208       lazy.UrlbarUtils.RESULT_TYPE.URL,
209       lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
210       ...lazy.UrlbarResult.payloadAndSimpleHighlights(
211         queryContext.tokens,
212         payload
213       )
214     );
216     if (suggestion.is_sponsored) {
217       if (!lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) {
218         result.richSuggestionIconSize = 16;
219       }
221       result.payload.descriptionL10n = {
222         id: "urlbar-result-action-sponsored",
223       };
224       result.isRichSuggestion = true;
225     }
227     return result;
228   }
230   /**
231    * Gets the "full keyword" (i.e., suggestion) for a query from a list of
232    * keywords. The suggestions data doesn't include full keywords, so we make
233    * our own based on the result's keyword phrases and a particular query. We
234    * use two heuristics:
235    *
236    * (1) Find the first keyword phrase that has more words than the query. Use
237    *     its first `queryWords.length` words as the full keyword. e.g., if the
238    *     query is "moz" and `keywords` is ["moz", "mozi", "mozil", "mozill",
239    *     "mozilla", "mozilla firefox"], pick "mozilla firefox", pop off the
240    *     "firefox" and use "mozilla" as the full keyword.
241    * (2) If there isn't any keyword phrase with more words, then pick the
242    *     longest phrase. e.g., pick "mozilla" in the previous example (assuming
243    *     the "mozilla firefox" phrase isn't there). That might be the query
244    *     itself.
245    *
246    * @param {string} query
247    *   The query string.
248    * @param {Array} keywords
249    *   An array of suggestion keywords.
250    * @returns {string}
251    *   The full keyword.
252    */
253   #getFullKeyword(query, keywords) {
254     let longerPhrase;
255     let trimmedQuery = query.toLocaleLowerCase().trim();
256     let queryWords = trimmedQuery.split(" ");
258     for (let phrase of keywords) {
259       if (phrase.startsWith(query)) {
260         let trimmedPhrase = phrase.trim();
261         let phraseWords = trimmedPhrase.split(" ");
262         // As an exception to (1), if the query ends with a space, then look for
263         // phrases with one more word so that the suggestion includes a word
264         // following the space.
265         let extra = query.endsWith(" ") ? 1 : 0;
266         let len = queryWords.length + extra;
267         if (len < phraseWords.length) {
268           // We found a phrase with more words.
269           return phraseWords.slice(0, len).join(" ");
270         }
271         if (
272           query.length < phrase.length &&
273           (!longerPhrase || longerPhrase.length < trimmedPhrase.length)
274         ) {
275           // We found a longer phrase with the same number of words.
276           longerPhrase = trimmedPhrase;
277         }
278       }
279     }
280     return longerPhrase || trimmedQuery;
281   }
283   /**
284    * Fetch the icon from RemoteSettings attachments.
285    *
286    * @param {string} path
287    *   The icon's remote settings path.
288    */
289   async #fetchIcon(path) {
290     if (!path) {
291       return null;
292     }
294     let { rs } = lazy.QuickSuggest.jsBackend;
295     if (!rs) {
296       return null;
297     }
299     let record = (
300       await rs.get({
301         filters: { id: `icon-${path}` },
302       })
303     ).pop();
304     if (!record) {
305       return null;
306     }
307     return rs.attachments.downloadToDisk(record);
308   }
310   #suggestionsMap;