564 lines
19 KiB
JavaScript
564 lines
19 KiB
JavaScript
/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const { EventEmitter } = ChromeUtils.import(
|
|
"resource://gre/modules/EventEmitter.jsm"
|
|
);
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
QUICK_SUGGEST_SOURCE:
|
|
"resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
|
|
TaskQueue: "resource:///modules/UrlbarUtils.sys.mjs",
|
|
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
|
|
UrlbarProviderQuickSuggest:
|
|
"resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
|
|
});
|
|
|
|
XPCOMUtils.defineLazyModuleGetters(lazy, {
|
|
BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
|
|
NimbusFeatures: "resource://nimbus/ExperimentAPI.jsm",
|
|
RemoteSettings: "resource://services-settings/remote-settings.js",
|
|
});
|
|
|
|
const log = console.createInstance({
|
|
prefix: "QuickSuggest",
|
|
maxLogLevel: lazy.UrlbarPrefs.get("quicksuggest.log") ? "All" : "Warn",
|
|
});
|
|
|
|
const RS_COLLECTION = "quicksuggest";
|
|
|
|
// Categories that should show "Firefox Suggest" instead of "Sponsored"
|
|
const NONSPONSORED_IAB_CATEGORIES = new Set(["5 - Education"]);
|
|
|
|
const FEATURE_AVAILABLE = "quickSuggestEnabled";
|
|
const SEEN_DIALOG_PREF = "quicksuggest.showedOnboardingDialog";
|
|
const RESTARTS_PREF = "quicksuggest.seenRestarts";
|
|
const DIALOG_VERSION_PREF = "quicksuggest.onboardingDialogVersion";
|
|
const DIALOG_VARIATION_PREF = "quickSuggestOnboardingDialogVariation";
|
|
|
|
// Values returned by the onboarding dialog depending on the user's response.
|
|
// These values are used in telemetry events, so be careful about changing them.
|
|
export const ONBOARDING_CHOICE = {
|
|
ACCEPT_2: "accept_2",
|
|
CLOSE_1: "close_1",
|
|
DISMISS_1: "dismiss_1",
|
|
DISMISS_2: "dismiss_2",
|
|
LEARN_MORE_1: "learn_more_1",
|
|
LEARN_MORE_2: "learn_more_2",
|
|
NOT_NOW_2: "not_now_2",
|
|
REJECT_2: "reject_2",
|
|
};
|
|
|
|
const ONBOARDING_URI =
|
|
"chrome://browser/content/urlbar/quicksuggestOnboarding.html";
|
|
|
|
// This is a score in the range [0, 1] used by the provider to compare
|
|
// suggestions. All suggestions require a score, so if a remote settings
|
|
// suggestion does not have one, it's assigned this value. We choose a low value
|
|
// to allow Merino to experiment with a broad range of scores server side.
|
|
const DEFAULT_SUGGESTION_SCORE = 0.2;
|
|
|
|
// Entries are added to the `_resultsByKeyword` map in chunks, and each chunk
|
|
// will add at most this many entries.
|
|
const ADD_RESULTS_CHUNK_SIZE = 1000;
|
|
|
|
/**
|
|
* Fetches the suggestions data from RemoteSettings and builds the structures
|
|
* to provide suggestions for UrlbarProviderQuickSuggest.
|
|
*/
|
|
class QuickSuggest extends EventEmitter {
|
|
init() {
|
|
if (this._initialized) {
|
|
return;
|
|
}
|
|
this._initialized = true;
|
|
|
|
lazy.UrlbarPrefs.addObserver(this);
|
|
lazy.NimbusFeatures.urlbar.onUpdate(() => this._queueSettingsSetup());
|
|
this._queueSettingsSetup();
|
|
}
|
|
|
|
/**
|
|
* @returns {number}
|
|
* A score in the range [0, 1] that can be used to compare suggestions. All
|
|
* suggestions require a score, so if a remote settings suggestion does not
|
|
* have one, it's assigned this value.
|
|
*/
|
|
get DEFAULT_SUGGESTION_SCORE() {
|
|
return DEFAULT_SUGGESTION_SCORE;
|
|
}
|
|
|
|
/**
|
|
* @returns {Promise}
|
|
* Resolves when any ongoing updates to the suggestions data are done.
|
|
*/
|
|
get readyPromise() {
|
|
return this._settingsTaskQueue.emptyPromise;
|
|
}
|
|
|
|
/**
|
|
* @returns {object}
|
|
* Global quick suggest configuration from remote settings:
|
|
*
|
|
* {
|
|
* best_match: {
|
|
* min_search_string_length,
|
|
* blocked_suggestion_ids,
|
|
* },
|
|
* impression_caps: {
|
|
* nonsponsored: {
|
|
* lifetime,
|
|
* custom: [
|
|
* { interval_s, max_count },
|
|
* ],
|
|
* },
|
|
* sponsored: {
|
|
* lifetime,
|
|
* custom: [
|
|
* { interval_s, max_count },
|
|
* ],
|
|
* },
|
|
* },
|
|
* }
|
|
*/
|
|
get config() {
|
|
return this._config;
|
|
}
|
|
|
|
/**
|
|
* Handle queries from the Urlbar.
|
|
*
|
|
* @param {string} phrase
|
|
* The search string.
|
|
* @returns {array}
|
|
* The matched suggestion objects. If there are no matches, an empty array
|
|
* is returned.
|
|
*/
|
|
async query(phrase) {
|
|
log.info("Handling query for", phrase);
|
|
phrase = phrase.toLowerCase();
|
|
let object = this._resultsByKeyword.get(phrase);
|
|
if (!object) {
|
|
return [];
|
|
}
|
|
|
|
// `object` will be a single result object if there's only one match or an
|
|
// array of result objects if there's more than one match.
|
|
let results = [object].flat();
|
|
|
|
// Start each icon fetch at the same time and wait for them all to finish.
|
|
let icons = await Promise.all(
|
|
results.map(({ icon }) => this._fetchIcon(icon))
|
|
);
|
|
|
|
return results.map(result => ({
|
|
full_keyword: this.getFullKeyword(phrase, result.keywords),
|
|
title: result.title,
|
|
url: result.url,
|
|
click_url: result.click_url,
|
|
impression_url: result.impression_url,
|
|
block_id: result.id,
|
|
advertiser: result.advertiser,
|
|
iab_category: result.iab_category,
|
|
is_sponsored: !NONSPONSORED_IAB_CATEGORIES.has(result.iab_category),
|
|
score:
|
|
typeof result.score == "number"
|
|
? result.score
|
|
: DEFAULT_SUGGESTION_SCORE,
|
|
source: lazy.QUICK_SUGGEST_SOURCE.REMOTE_SETTINGS,
|
|
icon: icons.shift(),
|
|
position: result.position,
|
|
_test_is_best_match: result._test_is_best_match,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Records the Nimbus exposure event if it hasn't already been recorded during
|
|
* the app session. This method actually queues the recording on idle because
|
|
* it's potentially an expensive operation.
|
|
*/
|
|
ensureExposureEventRecorded() {
|
|
// `recordExposureEvent()` makes sure only one event is recorded per app
|
|
// session even if it's called many times, but since it may be expensive, we
|
|
// also keep `_recordedExposureEvent`.
|
|
if (!this._recordedExposureEvent) {
|
|
this._recordedExposureEvent = true;
|
|
Services.tm.idleDispatchToMainThread(() =>
|
|
lazy.NimbusFeatures.urlbar.recordExposureEvent({ once: true })
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the full keyword (i.e., suggestion) for a result and query. The 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 `result.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 that matched `result`.
|
|
* @param {array} keywords
|
|
* An array of result keywords.
|
|
* @returns {string}
|
|
* The full keyword.
|
|
*/
|
|
getFullKeyword(query, keywords) {
|
|
let longerPhrase;
|
|
let trimmedQuery = query.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;
|
|
}
|
|
|
|
/**
|
|
* An onboarding dialog can be shown to the users who are enrolled into
|
|
* the QuickSuggest experiments or rollouts. This behavior is controlled
|
|
* by the pref `browser.urlbar.quicksuggest.shouldShowOnboardingDialog`
|
|
* which can be remotely configured by Nimbus.
|
|
*
|
|
* Given that the release may overlap with another onboarding dialog, we may
|
|
* wait for a few restarts before showing the QuickSuggest dialog. This can
|
|
* be remotely configured by Nimbus through
|
|
* `quickSuggestShowOnboardingDialogAfterNRestarts`, the default is 0.
|
|
*
|
|
* @returns {boolean}
|
|
* True if the dialog was shown and false if not.
|
|
*/
|
|
async maybeShowOnboardingDialog() {
|
|
// The call to this method races scenario initialization on startup, and the
|
|
// Nimbus variables we rely on below depend on the scenario, so wait for it
|
|
// to be initialized.
|
|
await lazy.UrlbarPrefs.firefoxSuggestScenarioStartupPromise;
|
|
|
|
// If the feature is disabled, the user has already seen the dialog, or the
|
|
// user has already opted in, don't show the onboarding.
|
|
if (
|
|
!lazy.UrlbarPrefs.get(FEATURE_AVAILABLE) ||
|
|
lazy.UrlbarPrefs.get(SEEN_DIALOG_PREF) ||
|
|
lazy.UrlbarPrefs.get("quicksuggest.dataCollection.enabled")
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Wait a number of restarts before showing the dialog.
|
|
let restartsSeen = lazy.UrlbarPrefs.get(RESTARTS_PREF);
|
|
if (
|
|
restartsSeen <
|
|
lazy.UrlbarPrefs.get("quickSuggestShowOnboardingDialogAfterNRestarts")
|
|
) {
|
|
lazy.UrlbarPrefs.set(RESTARTS_PREF, restartsSeen + 1);
|
|
return false;
|
|
}
|
|
|
|
let win = lazy.BrowserWindowTracker.getTopWindow();
|
|
|
|
// Don't show the dialog on top of about:welcome for new users.
|
|
if (win.gBrowser?.currentURI?.spec == "about:welcome") {
|
|
return false;
|
|
}
|
|
|
|
if (lazy.UrlbarPrefs.get("experimentType") === "modal") {
|
|
this.ensureExposureEventRecorded();
|
|
}
|
|
|
|
if (!lazy.UrlbarPrefs.get("quickSuggestShouldShowOnboardingDialog")) {
|
|
return false;
|
|
}
|
|
|
|
let variationType;
|
|
try {
|
|
// An error happens if the pref is not in user prefs.
|
|
variationType = lazy.UrlbarPrefs.get(DIALOG_VARIATION_PREF).toLowerCase();
|
|
} catch (e) {}
|
|
|
|
let params = { choice: undefined, variationType, visitedMain: false };
|
|
await win.gDialogBox.open(ONBOARDING_URI, params);
|
|
|
|
lazy.UrlbarPrefs.set(SEEN_DIALOG_PREF, true);
|
|
lazy.UrlbarPrefs.set(
|
|
DIALOG_VERSION_PREF,
|
|
JSON.stringify({ version: 1, variation: variationType })
|
|
);
|
|
|
|
// Record the user's opt-in choice on the user branch. This pref is sticky,
|
|
// so it will retain its user-branch value regardless of what the particular
|
|
// default was at the time.
|
|
let optedIn = params.choice == ONBOARDING_CHOICE.ACCEPT_2;
|
|
lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", optedIn);
|
|
|
|
switch (params.choice) {
|
|
case ONBOARDING_CHOICE.LEARN_MORE_1:
|
|
case ONBOARDING_CHOICE.LEARN_MORE_2:
|
|
win.openTrustedLinkIn(lazy.UrlbarProviderQuickSuggest.helpUrl, "tab", {
|
|
fromChrome: true,
|
|
});
|
|
break;
|
|
case ONBOARDING_CHOICE.ACCEPT_2:
|
|
case ONBOARDING_CHOICE.REJECT_2:
|
|
case ONBOARDING_CHOICE.NOT_NOW_2:
|
|
case ONBOARDING_CHOICE.CLOSE_1:
|
|
// No other action required.
|
|
break;
|
|
default:
|
|
params.choice = params.visitedMain
|
|
? ONBOARDING_CHOICE.DISMISS_2
|
|
: ONBOARDING_CHOICE.DISMISS_1;
|
|
break;
|
|
}
|
|
|
|
lazy.UrlbarPrefs.set("quicksuggest.onboardingDialogChoice", params.choice);
|
|
|
|
Services.telemetry.recordEvent(
|
|
"contextservices.quicksuggest",
|
|
"opt_in_dialog",
|
|
params.choice
|
|
);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Called when a urlbar pref changes. The onboarding dialog will set the
|
|
* `browser.urlbar.suggest.quicksuggest` prefs if the user has opted in, at
|
|
* which point we can start showing results.
|
|
*
|
|
* @param {string} pref
|
|
* The name of the pref relative to `browser.urlbar`.
|
|
*/
|
|
onPrefChanged(pref) {
|
|
switch (pref) {
|
|
case "suggest.quicksuggest.nonsponsored":
|
|
case "suggest.quicksuggest.sponsored":
|
|
this._queueSettingsSetup();
|
|
break;
|
|
}
|
|
}
|
|
|
|
_initialized = false;
|
|
|
|
// The RemoteSettings client.
|
|
_rs = null;
|
|
|
|
// Task queue for serializing access to remote settings and related data.
|
|
// Methods in this class should use this when they need to to modify or access
|
|
// the settings client. It ensures settings accesses are serialized, do not
|
|
// overlap, and happen only one at a time. It also lets clients, especially
|
|
// tests, use this class without having to worry about whether a settings sync
|
|
// or initialization is ongoing; see `readyPromise`.
|
|
_settingsTaskQueue = new lazy.TaskQueue();
|
|
|
|
// Configuration data synced from remote settings. See the `config` getter.
|
|
_config = {};
|
|
|
|
// Maps each keyword in the dataset to one or more results for the keyword. If
|
|
// only one result uses a keyword, the keyword's value in the map will be the
|
|
// result object. If more than one result uses the keyword, the value will be
|
|
// an array of the results. The reason for not always using an array is that
|
|
// we expect the vast majority of keywords to be used by only one result, and
|
|
// since there are potentially very many keywords and results and we keep them
|
|
// in memory all the time, we want to save as much memory as possible.
|
|
_resultsByKeyword = new Map();
|
|
|
|
// This is only defined as a property so that tests can override it.
|
|
_addResultsChunkSize = ADD_RESULTS_CHUNK_SIZE;
|
|
|
|
/**
|
|
* Queues a task to ensure our remote settings client is initialized or torn
|
|
* down as appropriate.
|
|
*/
|
|
_queueSettingsSetup() {
|
|
this._settingsTaskQueue.queue(() => {
|
|
let enabled =
|
|
lazy.UrlbarPrefs.get(FEATURE_AVAILABLE) &&
|
|
(lazy.UrlbarPrefs.get("suggest.quicksuggest.nonsponsored") ||
|
|
lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored"));
|
|
if (enabled && !this._rs) {
|
|
this._onSettingsSync = (...args) => this._queueSettingsSync(...args);
|
|
this._rs = lazy.RemoteSettings(RS_COLLECTION);
|
|
this._rs.on("sync", this._onSettingsSync);
|
|
this._queueSettingsSync();
|
|
} else if (!enabled && this._rs) {
|
|
this._rs.off("sync", this._onSettingsSync);
|
|
this._rs = null;
|
|
this._onSettingsSync = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Queues a task to populate the results map from the remote settings data
|
|
* plus any other work that needs to be done on sync.
|
|
*
|
|
* @param {object} [event]
|
|
* The event object passed to the "sync" event listener if you're calling
|
|
* this from the listener.
|
|
*/
|
|
async _queueSettingsSync(event = null) {
|
|
await this._settingsTaskQueue.queue(async () => {
|
|
// Remove local files of deleted records
|
|
if (event?.data?.deleted) {
|
|
await Promise.all(
|
|
event.data.deleted
|
|
.filter(d => d.attachment)
|
|
.map(entry =>
|
|
Promise.all([
|
|
this._rs.attachments.deleteDownloaded(entry), // type: data
|
|
this._rs.attachments.deleteFromDisk(entry), // type: icon
|
|
])
|
|
)
|
|
);
|
|
}
|
|
|
|
let dataType = lazy.UrlbarPrefs.get("quickSuggestRemoteSettingsDataType");
|
|
log.debug("Loading data with type:", dataType);
|
|
|
|
let [configArray, data] = await Promise.all([
|
|
this._rs.get({ filters: { type: "configuration" } }),
|
|
this._rs.get({ filters: { type: dataType } }),
|
|
this._rs
|
|
.get({ filters: { type: "icon" } })
|
|
.then(icons =>
|
|
Promise.all(icons.map(i => this._rs.attachments.downloadToDisk(i)))
|
|
),
|
|
]);
|
|
|
|
log.debug("Got configuration:", configArray);
|
|
this._setConfig(configArray?.[0]?.configuration || {});
|
|
|
|
this._resultsByKeyword.clear();
|
|
|
|
log.debug(`Got data with ${data.length} records`);
|
|
for (let record of data) {
|
|
let { buffer } = await this._rs.attachments.download(record);
|
|
let results = JSON.parse(new TextDecoder("utf-8").decode(buffer));
|
|
log.debug(`Adding ${results.length} results`);
|
|
await this._addResults(results);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sets the quick suggest config and emits a "config-set" event.
|
|
*
|
|
* @param {object} config
|
|
*/
|
|
_setConfig(config) {
|
|
this._config = config || {};
|
|
this.emit("config-set");
|
|
}
|
|
|
|
/**
|
|
* Adds a list of result objects to the results map. This method is also used
|
|
* by tests to set up mock suggestions.
|
|
*
|
|
* @param {array} results
|
|
* Array of result objects.
|
|
*/
|
|
async _addResults(results) {
|
|
// There can be many results, and each result can have many keywords. To
|
|
// avoid blocking the main thread for too long, update the map in chunks,
|
|
// and to avoid blocking the UI and other higher priority work, do each
|
|
// chunk only when the main thread is idle. During each chunk, we'll add at
|
|
// most `_addResultsChunkSize` entries to the map.
|
|
let resultIndex = 0;
|
|
let keywordIndex = 0;
|
|
|
|
// Keep adding chunks until all results have been fully added.
|
|
while (resultIndex < results.length) {
|
|
await new Promise(resolve => {
|
|
Services.tm.idleDispatchToMainThread(() => {
|
|
// Keep updating the map until the current chunk is done.
|
|
let indexInChunk = 0;
|
|
while (
|
|
indexInChunk < this._addResultsChunkSize &&
|
|
resultIndex < results.length
|
|
) {
|
|
let result = results[resultIndex];
|
|
if (keywordIndex == result.keywords.length) {
|
|
resultIndex++;
|
|
keywordIndex = 0;
|
|
continue;
|
|
}
|
|
// If the keyword's only result is `result`, store it directly as
|
|
// the value. Otherwise store an array of results. For details, see
|
|
// the `_resultsByKeyword` comment.
|
|
let keyword = result.keywords[keywordIndex];
|
|
let object = this._resultsByKeyword.get(keyword);
|
|
if (!object) {
|
|
this._resultsByKeyword.set(keyword, result);
|
|
} else if (!Array.isArray(object)) {
|
|
this._resultsByKeyword.set(keyword, [object, result]);
|
|
} else {
|
|
object.push(result);
|
|
}
|
|
keywordIndex++;
|
|
indexInChunk++;
|
|
}
|
|
|
|
// The current chunk is done.
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the icon from RemoteSettings attachments.
|
|
*
|
|
* @param {string} path
|
|
* The icon's remote settings path.
|
|
*/
|
|
async _fetchIcon(path) {
|
|
if (!path || !this._rs) {
|
|
return null;
|
|
}
|
|
let record = (
|
|
await this._rs.get({
|
|
filters: { id: `icon-${path}` },
|
|
})
|
|
).pop();
|
|
if (!record) {
|
|
return null;
|
|
}
|
|
return this._rs.attachments.downloadToDisk(record);
|
|
}
|
|
}
|
|
|
|
export let UrlbarQuickSuggest = new QuickSuggest();
|