summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/private/Weather.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/components/urlbar/private/Weather.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/urlbar/private/Weather.sys.mjs')
-rw-r--r--browser/components/urlbar/private/Weather.sys.mjs896
1 files changed, 896 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/Weather.sys.mjs b/browser/components/urlbar/private/Weather.sys.mjs
new file mode 100644
index 0000000000..c4dfa8c618
--- /dev/null
+++ b/browser/components/urlbar/private/Weather.sys.mjs
@@ -0,0 +1,896 @@
+/* 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, {
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+ UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
+});
+
+const FETCH_DELAY_AFTER_COMING_ONLINE_MS = 3000; // 3s
+const FETCH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
+const MERINO_PROVIDER = "accuweather";
+const MERINO_TIMEOUT_MS = 5000; // 5s
+
+const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS";
+const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER";
+
+const NOTIFICATIONS = {
+ CAPTIVE_PORTAL_LOGIN: "captive-portal-login-success",
+ LINK_STATUS_CHANGED: "network:link-status-changed",
+ OFFLINE_STATUS_CHANGED: "network:offline-status-changed",
+ WAKE: "wake_notification",
+};
+
+const RESULT_MENU_COMMAND = {
+ HELP: "help",
+ INACCURATE_LOCATION: "inaccurate_location",
+ NOT_INTERESTED: "not_interested",
+ NOT_RELEVANT: "not_relevant",
+ SHOW_LESS_FREQUENTLY: "show_less_frequently",
+};
+
+const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather";
+
+const WEATHER_DYNAMIC_TYPE = "weather";
+const WEATHER_VIEW_TEMPLATE = {
+ attributes: {
+ selectable: true,
+ },
+ children: [
+ {
+ name: "currentConditions",
+ tag: "span",
+ children: [
+ {
+ name: "currently",
+ tag: "div",
+ },
+ {
+ name: "currentTemperature",
+ tag: "div",
+ children: [
+ {
+ name: "temperature",
+ tag: "span",
+ },
+ {
+ name: "weatherIcon",
+ tag: "img",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "summary",
+ tag: "span",
+ overflowable: true,
+ children: [
+ {
+ name: "top",
+ tag: "div",
+ children: [
+ {
+ name: "topNoWrap",
+ tag: "span",
+ children: [
+ { name: "title", tag: "span", classList: ["urlbarView-title"] },
+ {
+ name: "titleSeparator",
+ tag: "span",
+ classList: ["urlbarView-title-separator"],
+ },
+ ],
+ },
+ {
+ name: "url",
+ tag: "span",
+ classList: ["urlbarView-url"],
+ },
+ ],
+ },
+ {
+ name: "middle",
+ tag: "div",
+ children: [
+ {
+ name: "middleNoWrap",
+ tag: "span",
+ overflowable: true,
+ children: [
+ {
+ name: "summaryText",
+ tag: "span",
+ },
+ {
+ name: "summaryTextSeparator",
+ tag: "span",
+ },
+ {
+ name: "highLow",
+ tag: "span",
+ },
+ ],
+ },
+ {
+ name: "highLowWrap",
+ tag: "span",
+ },
+ ],
+ },
+ {
+ name: "bottom",
+ tag: "div",
+ },
+ ],
+ },
+ ],
+};
+
+/**
+ * A feature that periodically fetches weather suggestions from Merino.
+ */
+export class Weather extends BaseFeature {
+ constructor(...args) {
+ super(...args);
+ lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE);
+ lazy.UrlbarView.addDynamicViewTemplate(
+ WEATHER_DYNAMIC_TYPE,
+ WEATHER_VIEW_TEMPLATE
+ );
+ }
+
+ get shouldEnable() {
+ // The feature itself is enabled by setting these prefs regardless of
+ // whether any config is defined. This is necessary to allow the feature to
+ // sync the config from remote settings and Nimbus. Suggestion fetches will
+ // not start until the config has been either synced from remote settings or
+ // set by Nimbus.
+ return (
+ lazy.UrlbarPrefs.get("weatherFeatureGate") &&
+ lazy.UrlbarPrefs.get("suggest.weather")
+ );
+ }
+
+ get enablingPreferences() {
+ return ["suggest.weather"];
+ }
+
+ get rustSuggestionTypes() {
+ return ["Weather"];
+ }
+
+ isRustSuggestionTypeEnabled(type) {
+ // When weather keywords are defined in Nimbus, weather suggestions are
+ // served by UrlbarProviderWeather. Return false here so the quick suggest
+ // provider doesn't try to serve them too.
+ return !lazy.UrlbarPrefs.get("weatherKeywords");
+ }
+
+ getSuggestionTelemetryType(suggestion) {
+ return "weather";
+ }
+
+ /**
+ * @returns {object}
+ * The last weather suggestion fetched from Merino or null if none.
+ */
+ get suggestion() {
+ return this.#suggestion;
+ }
+
+ /**
+ * @returns {Set}
+ * The set of keywords that should trigger the weather suggestion. This will
+ * be null when the Rust backend is enabled and keywords are not defined by
+ * Nimbus because in that case Rust manages the keywords. Otherwise, it will
+ * also be null when no config is defined.
+ */
+ get keywords() {
+ return this.#keywords;
+ }
+
+ /**
+ * @returns {number}
+ * The minimum prefix length of a weather keyword the user must type to
+ * trigger the suggestion. Note that the strings returned from `keywords`
+ * already take this into account. The min length is determined from the
+ * first config source below whose value is non-zero. If no source has a
+ * non-zero value, zero will be returned, and `this.keywords` will contain
+ * only full keywords.
+ *
+ * 1. The `weather.minKeywordLength` pref, which is set when the user
+ * increments the min length
+ * 2. `weatherKeywordsMinimumLength` in Nimbus
+ * 3. `min_keyword_length` in the weather record in remote settings (i.e.,
+ * the weather config)
+ */
+ get minKeywordLength() {
+ let minLength =
+ lazy.UrlbarPrefs.get("weather.minKeywordLength") ||
+ lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength") ||
+ this.#config.minKeywordLength ||
+ 0;
+ return Math.max(minLength, 0);
+ }
+
+ /**
+ * @returns {boolean}
+ * Weather the min keyword length can be incremented. A cap on the min
+ * length can be set in remote settings and Nimbus.
+ */
+ get canIncrementMinKeywordLength() {
+ let nimbusMax =
+ lazy.UrlbarPrefs.get("weatherKeywordsMinimumLengthCap") || 0;
+
+ let maxKeywordLength;
+ if (nimbusMax) {
+ // In Nimbus, the cap is the max keyword length.
+ maxKeywordLength = nimbusMax;
+ } else {
+ // In the RS config, the cap is the max number of times the user can click
+ // "Show less frequently". The max keyword length is therefore the initial
+ // min length plus the cap.
+ let min = this.#config.minKeywordLength;
+ let cap = lazy.QuickSuggest.backend.config?.showLessFrequentlyCap;
+ if (min && cap) {
+ maxKeywordLength = min + cap;
+ }
+ }
+
+ return !maxKeywordLength || this.minKeywordLength < maxKeywordLength;
+ }
+
+ update() {
+ let wasEnabled = this.isEnabled;
+ super.update();
+
+ // This method is called by `QuickSuggest` in a
+ // `NimbusFeatures.urlbar.onUpdate()` callback, when a change occurs to a
+ // Nimbus variable or to a pref that's a fallback for a Nimbus variable. A
+ // config-related variable or pref may have changed, so update keywords, but
+ // only if the feature was already enabled because if it wasn't,
+ // `enable(true)` was just called, which calls `#init()`, which calls
+ // `#updateKeywords()`.
+ if (wasEnabled && this.isEnabled) {
+ this.#updateKeywords();
+ }
+ }
+
+ enable(enabled) {
+ if (enabled) {
+ this.#init();
+ } else {
+ this.#uninit();
+ }
+ }
+
+ /**
+ * Increments the minimum prefix length of a weather keyword the user must
+ * type to trigger the suggestion, if possible. A cap on the min length can be
+ * set in remote settings and Nimbus, and if the cap has been reached, the
+ * length is not incremented.
+ */
+ incrementMinKeywordLength() {
+ if (this.canIncrementMinKeywordLength) {
+ lazy.UrlbarPrefs.set(
+ "weather.minKeywordLength",
+ this.minKeywordLength + 1
+ );
+ }
+ }
+
+ /**
+ * Returns a promise that resolves when all pending fetches finish, if there
+ * are pending fetches. If there aren't, the promise resolves when all pending
+ * fetches starting with the next fetch finish.
+ *
+ * @returns {Promise}
+ */
+ waitForFetches() {
+ if (!this.#waitForFetchesDeferred) {
+ this.#waitForFetchesDeferred = Promise.withResolvers();
+ }
+ return this.#waitForFetchesDeferred.promise;
+ }
+
+ async onRemoteSettingsSync(rs) {
+ this.logger.debug("Loading weather config from remote settings");
+ let records = await rs.get({ filters: { type: "weather" } });
+ if (!this.isEnabled) {
+ return;
+ }
+
+ this.logger.debug("Got weather records: " + JSON.stringify(records));
+ this.#rsConfig = lazy.UrlbarUtils.copySnakeKeysToCamel(
+ records?.[0]?.weather || {}
+ );
+ this.#updateKeywords();
+ }
+
+ makeResult(queryContext, suggestion, searchString) {
+ // The Rust component doesn't enforce a minimum keyword length, so discard
+ // the suggestion if the search string isn't long enough. This conditional
+ // will always be false for the JS backend since in that case keywords are
+ // never shorter than `minKeywordLength`.
+ if (searchString.length < this.minKeywordLength) {
+ return null;
+ }
+
+ // The Rust component will return a dummy suggestion if the query matches a
+ // weather keyword. Here in this method we replace it with the actual cached
+ // weather suggestion from Merino. If there is no cached suggestion, discard
+ // the Rust suggestion.
+ if (!this.suggestion) {
+ return null;
+ }
+
+ if (suggestion.source == "rust") {
+ if (lazy.UrlbarPrefs.get("weatherKeywords")) {
+ // This shouldn't happen since this feature won't enable Rust weather
+ // suggestions in this case, but just to be safe, discard the suggestion
+ // if keywords are defined in Nimbus.
+ return null;
+ }
+ // Replace the dummy Rust suggestion with the actual weather suggestion
+ // from Merino.
+ suggestion = this.suggestion;
+ }
+
+ let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
+ return Object.assign(
+ new lazy.UrlbarResult(
+ lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
+ lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
+ {
+ url: suggestion.url,
+ iconId: suggestion.current_conditions.icon_id,
+ helpUrl: lazy.QuickSuggest.HELP_URL,
+ requestId: suggestion.request_id,
+ dynamicType: WEATHER_DYNAMIC_TYPE,
+ city: suggestion.city_name,
+ temperatureUnit: unit,
+ temperature: suggestion.current_conditions.temperature[unit],
+ currentConditions: suggestion.current_conditions.summary,
+ forecast: suggestion.forecast.summary,
+ high: suggestion.forecast.high[unit],
+ low: suggestion.forecast.low[unit],
+ shouldNavigate: true,
+ }
+ ),
+ {
+ showFeedbackMenu: true,
+ suggestedIndex: searchString ? 1 : 0,
+ }
+ );
+ }
+
+ getViewUpdate(result) {
+ let uppercaseUnit = result.payload.temperatureUnit.toUpperCase();
+ return {
+ currently: {
+ l10n: {
+ id: "firefox-suggest-weather-currently",
+ cacheable: true,
+ },
+ },
+ temperature: {
+ l10n: {
+ id: "firefox-suggest-weather-temperature",
+ args: {
+ value: result.payload.temperature,
+ unit: uppercaseUnit,
+ },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ weatherIcon: {
+ attributes: { iconId: result.payload.iconId },
+ },
+ title: {
+ l10n: {
+ id: "firefox-suggest-weather-title",
+ args: { city: result.payload.city },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ url: {
+ textContent: result.payload.url,
+ },
+ summaryText: {
+ l10n: {
+ id: "firefox-suggest-weather-summary-text",
+ args: {
+ currentConditions: result.payload.currentConditions,
+ forecast: result.payload.forecast,
+ },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ highLow: {
+ l10n: {
+ id: "firefox-suggest-weather-high-low",
+ args: {
+ high: result.payload.high,
+ low: result.payload.low,
+ unit: uppercaseUnit,
+ },
+ cacheable: true,
+ excludeArgsFromCacheKey: true,
+ },
+ },
+ highLowWrap: {
+ l10n: {
+ id: "firefox-suggest-weather-high-low",
+ args: {
+ high: result.payload.high,
+ low: result.payload.low,
+ unit: uppercaseUnit,
+ },
+ },
+ },
+ bottom: {
+ l10n: {
+ id: "firefox-suggest-weather-sponsored",
+ args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
+ cacheable: true,
+ },
+ },
+ };
+ }
+
+ getResultCommands(result) {
+ let commands = [
+ {
+ name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
+ l10n: {
+ id: "firefox-suggest-weather-command-inaccurate-location",
+ },
+ },
+ ];
+
+ if (this.canIncrementMinKeywordLength) {
+ commands.push({
+ name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
+ l10n: {
+ id: "firefox-suggest-command-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.HELP,
+ l10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ }
+ );
+
+ return commands;
+ }
+
+ handleCommand(view, result, selType) {
+ switch (selType) {
+ case RESULT_MENU_COMMAND.HELP:
+ // "help" is handled by UrlbarInput, no need to do anything here.
+ break;
+ // selType == "dismiss" when the user presses the dismiss key shortcut.
+ case "dismiss":
+ case RESULT_MENU_COMMAND.NOT_INTERESTED:
+ case RESULT_MENU_COMMAND.NOT_RELEVANT:
+ this.logger.info("Dismissing weather result");
+ lazy.UrlbarPrefs.set("suggest.weather", false);
+ result.acknowledgeDismissalL10n = {
+ id: "firefox-suggest-dismissal-acknowledgment-all",
+ };
+ view.controller.removeResult(result);
+ 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`.
+ view.acknowledgeFeedback(result);
+ break;
+ case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
+ view.acknowledgeFeedback(result);
+ this.incrementMinKeywordLength();
+ if (!this.canIncrementMinKeywordLength) {
+ view.invalidateResultMenuCommands();
+ }
+ break;
+ }
+ }
+
+ get #config() {
+ let { rustBackend } = lazy.QuickSuggest;
+ let config = rustBackend.isEnabled
+ ? rustBackend.getConfigForSuggestionType(this.rustSuggestionTypes[0])
+ : this.#rsConfig;
+ return config || {};
+ }
+
+ get #vpnDetected() {
+ if (lazy.UrlbarPrefs.get("weather.ignoreVPN")) {
+ return false;
+ }
+
+ let linkService =
+ this._test_linkService ||
+ Cc["@mozilla.org/network/network-link-service;1"].getService(
+ Ci.nsINetworkLinkService
+ );
+
+ // `platformDNSIndications` throws `NS_ERROR_NOT_IMPLEMENTED` on all
+ // platforms except Windows, so we can't detect a VPN on any other platform.
+ try {
+ return (
+ linkService.platformDNSIndications &
+ Ci.nsINetworkLinkService.VPN_DETECTED
+ );
+ } catch (e) {}
+ return false;
+ }
+
+ #init() {
+ // On feature init, we only update keywords and listen for changes that
+ // affect keywords. Suggestion fetches will not start until either keywords
+ // exist or Rust is enabled.
+ this.#updateKeywords();
+ lazy.UrlbarPrefs.addObserver(this);
+ lazy.QuickSuggest.jsBackend.register(this);
+ }
+
+ #uninit() {
+ this.#stopFetching();
+ lazy.QuickSuggest.jsBackend.unregister(this);
+ lazy.UrlbarPrefs.removeObserver(this);
+ this.#keywords = null;
+ }
+
+ #startFetching() {
+ if (this.#merino) {
+ this.logger.debug("Suggestion fetching already started");
+ return;
+ }
+
+ this.logger.debug("Starting suggestion fetching");
+
+ this.#merino = new lazy.MerinoClient(this.constructor.name);
+ this.#fetch();
+ for (let notif of Object.values(NOTIFICATIONS)) {
+ Services.obs.addObserver(this, notif);
+ }
+ }
+
+ #stopFetching() {
+ if (!this.#merino) {
+ this.logger.debug("Suggestion fetching already stopped");
+ return;
+ }
+
+ this.logger.debug("Stopping suggestion fetching");
+
+ for (let notif of Object.values(NOTIFICATIONS)) {
+ Services.obs.removeObserver(this, notif);
+ }
+ lazy.clearTimeout(this.#fetchTimer);
+ this.#merino = null;
+ this.#suggestion = null;
+ this.#fetchTimer = 0;
+ }
+
+ async #fetch() {
+ this.logger.info("Fetching suggestion");
+
+ if (this.#vpnDetected) {
+ // A VPN is detected, so Merino will not be able to accurately determine
+ // the user's location. Set the suggestion to null. We treat this as if
+ // the network is offline (see below). When the VPN is disconnected, a
+ // `network:link-status-changed` notification will be sent, triggering a
+ // new fetch.
+ this.logger.info("VPN detected, not fetching");
+ this.#suggestion = null;
+ if (!this.#pendingFetchCount) {
+ this.#waitForFetchesDeferred?.resolve();
+ this.#waitForFetchesDeferred = null;
+ }
+ return;
+ }
+
+ // This `Weather` instance may be uninitialized while awaiting the fetch or
+ // even uninitialized and re-initialized a number of times. Multiple fetches
+ // may also happen at once. Ignore the fetch below if `#merino` changes or
+ // another fetch happens in the meantime.
+ let merino = this.#merino;
+ let instance = (this.#fetchInstance = {});
+
+ this.#restartFetchTimer();
+ this.#lastFetchTimeMs = Date.now();
+ this.#pendingFetchCount++;
+
+ let suggestions;
+ try {
+ suggestions = await merino.fetch({
+ query: "",
+ providers: [MERINO_PROVIDER],
+ timeoutMs: this.#timeoutMs,
+ extraLatencyHistogram: HISTOGRAM_LATENCY,
+ extraResponseHistogram: HISTOGRAM_RESPONSE,
+ });
+ } finally {
+ this.#pendingFetchCount--;
+ }
+
+ // Reset the Merino client's session so different fetches use different
+ // sessions. A single session is intended to represent a single user
+ // engagement in the urlbar, which this is not. Practically this isn't
+ // necessary since the client automatically resets the session on a timer
+ // whose period is much shorter than our fetch period, but there's no reason
+ // to keep it ticking in the meantime.
+ merino.resetSession();
+
+ if (merino != this.#merino || instance != this.#fetchInstance) {
+ this.logger.info("Fetch finished but is out of date, ignoring");
+ } else {
+ let suggestion = suggestions?.[0];
+ if (!suggestion) {
+ // No suggestion was received. The network may be offline or there may
+ // be some other problem. Set the suggestion to null: Better to show
+ // nothing than outdated weather information. When the network comes
+ // back online, one or more network notifications will be sent,
+ // triggering a new fetch.
+ this.logger.info("No suggestion received");
+ this.#suggestion = null;
+ } else {
+ this.logger.info("Got suggestion");
+ this.logger.debug(JSON.stringify({ suggestion }));
+ this.#suggestion = { ...suggestion, source: "merino" };
+ }
+ }
+
+ if (!this.#pendingFetchCount) {
+ this.#waitForFetchesDeferred?.resolve();
+ this.#waitForFetchesDeferred = null;
+ }
+ }
+
+ #restartFetchTimer(ms = this.#fetchIntervalMs) {
+ this.logger.debug(
+ "Restarting fetch timer: " +
+ JSON.stringify({ ms, fetchIntervalMs: this.#fetchIntervalMs })
+ );
+
+ lazy.clearTimeout(this.#fetchTimer);
+ this.#fetchTimer = lazy.setTimeout(() => {
+ this.logger.debug("Fetch timer fired");
+ this.#fetch();
+ }, ms);
+ this._test_fetchTimerMs = ms;
+ }
+
+ #onMaybeCameOnline() {
+ this.logger.debug("Maybe came online");
+
+ // If the suggestion is null, we were offline the last time we tried to
+ // fetch, at the start of the current fetch period. Otherwise the suggestion
+ // was fetched successfully at the start of the current fetch period and is
+ // therefore still fresh.
+ if (!this.suggestion) {
+ // Multiple notifications can occur at once when the network comes online,
+ // and we don't want to do separate fetches for each. Start the timer with
+ // a small timeout. If another notification happens in the meantime, we'll
+ // start it again.
+ this.#restartFetchTimer(this.#fetchDelayAfterComingOnlineMs);
+ }
+ }
+
+ #onWake() {
+ // Calculate the elapsed time between the last fetch and now, and the
+ // remaining interval in the current fetch period.
+ let elapsedMs = Date.now() - this.#lastFetchTimeMs;
+ let remainingIntervalMs = this.#fetchIntervalMs - elapsedMs;
+ this.logger.debug(
+ "Wake: " +
+ JSON.stringify({
+ elapsedMs,
+ remainingIntervalMs,
+ fetchIntervalMs: this.#fetchIntervalMs,
+ })
+ );
+
+ // Regardless of the elapsed time, we need to restart the fetch timer
+ // because it didn't tick while the computer was asleep. If the elapsed time
+ // >= the fetch interval, the remaining interval will be negative and we
+ // need to fetch now, but do it after a brief delay in case other
+ // notifications occur soon when the network comes online. If the elapsed
+ // time < the fetch interval, the suggestion is still fresh so there's no
+ // need to fetch. Just restart the timer with the remaining interval.
+ if (remainingIntervalMs <= 0) {
+ remainingIntervalMs = this.#fetchDelayAfterComingOnlineMs;
+ }
+ this.#restartFetchTimer(remainingIntervalMs);
+ }
+
+ #updateKeywords() {
+ this.logger.debug("Starting keywords update");
+
+ let nimbusKeywords = lazy.UrlbarPrefs.get("weatherKeywords");
+
+ // If the Rust backend is enabled and weather keywords aren't defined in
+ // Nimbus, Rust will manage the keywords.
+ if (lazy.UrlbarPrefs.get("quickSuggestRustEnabled") && !nimbusKeywords) {
+ this.logger.debug(
+ "Rust enabled, no keywords in Nimbus. " +
+ "Starting fetches and deferring to Rust."
+ );
+ this.#keywords = null;
+ this.#startFetching();
+ return;
+ }
+
+ // If the JS backend is enabled but no keywords are defined, we can't
+ // possibly serve a weather suggestion.
+ if (
+ !lazy.UrlbarPrefs.get("quickSuggestRustEnabled") &&
+ !this.#config.keywords &&
+ !nimbusKeywords
+ ) {
+ this.logger.debug(
+ "Rust disabled, no keywords in RS or Nimbus. Stopping fetches."
+ );
+ this.#keywords = null;
+ this.#stopFetching();
+ return;
+ }
+
+ // At this point, keywords exist and this feature will manage them.
+ let fullKeywords = nimbusKeywords || this.#config.keywords;
+ let minLength = this.minKeywordLength;
+ this.logger.debug(
+ "Updating keywords: " + JSON.stringify({ fullKeywords, minLength })
+ );
+
+ if (!minLength) {
+ this.logger.debug("Min length is undefined or zero, using full keywords");
+ this.#keywords = new Set(fullKeywords);
+ } else {
+ // Create keywords that are prefixes of the full keywords starting at the
+ // specified minimum length.
+ this.#keywords = new Set();
+ for (let full of fullKeywords) {
+ for (let i = minLength; i <= full.length; i++) {
+ this.#keywords.add(full.substring(0, i));
+ }
+ }
+ }
+
+ this.#startFetching();
+ }
+
+ onPrefChanged(pref) {
+ if (pref == "weather.minKeywordLength") {
+ this.#updateKeywords();
+ }
+ }
+
+ observe(subject, topic, data) {
+ this.logger.debug(
+ "Observed notification: " + JSON.stringify({ topic, data })
+ );
+
+ switch (topic) {
+ case NOTIFICATIONS.CAPTIVE_PORTAL_LOGIN:
+ this.#onMaybeCameOnline();
+ break;
+ case NOTIFICATIONS.LINK_STATUS_CHANGED:
+ // This notificaton means the user's connection status changed. See
+ // nsINetworkLinkService.
+ if (data != "down") {
+ this.#onMaybeCameOnline();
+ }
+ break;
+ case NOTIFICATIONS.OFFLINE_STATUS_CHANGED:
+ // This notificaton means the user toggled the "Work Offline" pref.
+ // See nsIIOService.
+ if (data != "offline") {
+ this.#onMaybeCameOnline();
+ }
+ break;
+ case NOTIFICATIONS.WAKE:
+ this.#onWake();
+ break;
+ }
+ }
+
+ get _test_fetchDelayAfterComingOnlineMs() {
+ return this.#fetchDelayAfterComingOnlineMs;
+ }
+ set _test_fetchDelayAfterComingOnlineMs(ms) {
+ this.#fetchDelayAfterComingOnlineMs =
+ ms < 0 ? FETCH_DELAY_AFTER_COMING_ONLINE_MS : ms;
+ }
+
+ get _test_fetchIntervalMs() {
+ return this.#fetchIntervalMs;
+ }
+ set _test_fetchIntervalMs(ms) {
+ this.#fetchIntervalMs = ms < 0 ? FETCH_INTERVAL_MS : ms;
+ }
+
+ get _test_fetchTimer() {
+ return this.#fetchTimer;
+ }
+
+ get _test_lastFetchTimeMs() {
+ return this.#lastFetchTimeMs;
+ }
+
+ get _test_merino() {
+ return this.#merino;
+ }
+
+ get _test_pendingFetchCount() {
+ return this.#pendingFetchCount;
+ }
+
+ async _test_fetch() {
+ await this.#fetch();
+ }
+
+ _test_setSuggestionToNull() {
+ this.#suggestion = null;
+ }
+
+ _test_setTimeoutMs(ms) {
+ this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms;
+ }
+
+ #fetchDelayAfterComingOnlineMs = FETCH_DELAY_AFTER_COMING_ONLINE_MS;
+ #fetchInstance = null;
+ #fetchIntervalMs = FETCH_INTERVAL_MS;
+ #fetchTimer = 0;
+ #keywords = null;
+ #lastFetchTimeMs = 0;
+ #merino = null;
+ #pendingFetchCount = 0;
+ #rsConfig = null;
+ #suggestion = null;
+ #timeoutMs = MERINO_TIMEOUT_MS;
+ #waitForFetchesDeferred = null;
+}