commit 8e5f0ed2176163fcb4dc325f8033e8a2de12ba5e parent 350bf46d5a9066fa74bd0508e81c1813e19cd187 Author: Daisuke Akatsuka <daisuke@birchill.co.jp> Date: Tue, 14 Oct 2025 20:44:51 +0000 Bug 1990951: Basic implementation for flight status suggestions r=desktop-theme-reviewers,fluent-reviewers,adw,bolsson,emilio Differential Revision: https://phabricator.services.mozilla.com/D266312 Diffstat:
18 files changed, 1233 insertions(+), 9 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -779,6 +779,9 @@ pref("browser.urlbar.suggest.yelpRealtime", true); // settings. pref("browser.urlbar.yelpRealtime.minKeywordLength", 0); +// Feature gate pref for flight status suggestions in the urlbar. +pref("browser.urlbar.flightStatus.featureGate", false); + // Timestamp of the time the user last performed a search via the urlbar // so that experiments can target users who have / have not performed // urlbar searches. diff --git a/browser/components/urlbar/QuickSuggest.sys.mjs b/browser/components/urlbar/QuickSuggest.sys.mjs @@ -164,6 +164,8 @@ const FEATURES = { "moz-src:///browser/components/urlbar/private/AmpSuggestions.sys.mjs", DynamicSuggestions: "moz-src:///browser/components/urlbar/private/DynamicSuggestions.sys.mjs", + FlightStatusSuggestions: + "moz-src:///browser/components/urlbar/private/FlightStatusSuggestions.sys.mjs", ImportantDatesSuggestions: "moz-src:///browser/components/urlbar/private/ImportantDatesSuggestions.sys.mjs", ImpressionCaps: diff --git a/browser/components/urlbar/UrlbarPrefs.sys.mjs b/browser/components/urlbar/UrlbarPrefs.sys.mjs @@ -137,6 +137,18 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([ // When true, `javascript:` URLs are not included in search results. ["filter.javascript", true], + // Feature gate pref for flight status suggestions in the urlbar. + ["flightStatus.featureGate", false], + + // The minimum prefix length of a flight status keyword the user must type to + // trigger the suggestion. 0 means the min length should be taken from Nimbus + // or remote settings. + ["flightStatus.minKeywordLength", 0], + + // The number of times the user has clicked the "Show less frequently" command + // for flight status suggestions. + ["flightStatus.showLessFrequentlyCount", 0], + // Focus the content document when pressing the Escape key, if there's no // remaining typed history. ["focusContentDocumentOnEsc", true], @@ -460,6 +472,10 @@ const PREF_URLBAR_DEFAULTS = /** @type {PreferenceDefinition[]} */ ([ // Whether results will include search engines (e.g. tab-to-search). ["suggest.engines", true], + // If `browser.urlbar.flightStatus.featureGate` is true, this controls whether + // flight status suggestions are turned on. + ["suggest.flightStatus", true], + // Whether results will include the user's history. ["suggest.history", true], diff --git a/browser/components/urlbar/content/enUS-searchFeatures.ftl b/browser/components/urlbar/content/enUS-searchFeatures.ftl @@ -286,9 +286,64 @@ urlbar-result-yelp-realtime-business-hours-open = urlbar-result-yelp-realtime-business-hours-closed = <span>Closed</span> until { $timeUntil } - # This string is shown as popularity by the rating and the review count. # Variables: # $rating (float) - The rating of this. # $review_count (integer) - The review count of this. urlbar-result-yelp-realtime-popularity = { $rating } ({ $review_count }) + +## These strings are used for flight status suggestions in the urlbar. +## The flight status suggestions shows the flight time, origin and destination +## and the status like delayed, etc. + +# This string is shown in the result menu. +urlbar-result-menu-dont-show-flight-status = + .label = Don’t show flight status suggestions + +# A message that replaces a result when the user dismisses Yelp realtime +# suggestions. +urlbar-result-dismissal-acknowledgment-flight-status = Thanks for your feedback. You won’t see flight status suggestions anymore. + +# This string is shown as the statis of 'On time'. +urlbar-result-flight-status-status-ontime = On time + +# This string is shown as the statis of 'In flight'. +urlbar-result-flight-status-status-inflight = In flight + +# This string is shown as the statis of 'Arrived'. +urlbar-result-flight-status-status-arrived = Arrived + +# This string is shown as the statis of 'Cancelled'. +urlbar-result-flight-status-status-cancelled = Cancelled + +# This string is shown as the statis of 'Delayed'. +# This label needs to show the estimated departure time too. +# e.g. Delayed until 5:50pm +# Variables: +# $departureEstimatedTime (string) - The estimated departure time. +urlbar-result-flight-status-status-delayed = + Delayed until { $departureEstimatedTime } + +# This string is shown as the time left minutes. +# e.g. 30 min left +# Variables: +# $timeLeftMinutes (number) - The time left minutes. +urlbar-result-flight-status-time-left-minutes = + { $timeLeftMinutes -> + [one] { $timeLeftMinutes } min left + *[other] { $timeLeftMinutes } mins left + } + +# This string is shown as the airport. +# e.g. Los Angeles (LAX) to New York (JFK) +# Variables: +# $city (string) - The city of the airport. +# $code (string) - The code of the airport. +urlbar-result-flight-status-airport = { $city } ({ $code }) + +# This string is shown as the flight number with the airline name. +# e.g. AC 8170, (Air Canada) +# Variables: +# $flightNumber (string) - The flight number. +# $airlineName (string) - The airline name. +urlbar-result-flight-status-flight-number-with-airline = { $flightNumber }, { $airlineName } diff --git a/browser/components/urlbar/moz.build b/browser/components/urlbar/moz.build @@ -67,6 +67,7 @@ MOZ_SRC_FILES += [ "private/AddonSuggestions.sys.mjs", "private/AmpSuggestions.sys.mjs", "private/DynamicSuggestions.sys.mjs", + "private/FlightStatusSuggestions.sys.mjs", "private/GeolocationUtils.sys.mjs", "private/ImportantDatesSuggestions.sys.mjs", "private/ImpressionCaps.sys.mjs", diff --git a/browser/components/urlbar/private/FlightStatusSuggestions.sys.mjs b/browser/components/urlbar/private/FlightStatusSuggestions.sys.mjs @@ -0,0 +1,253 @@ +/* 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 { RealtimeSuggestProvider } from "moz-src:///browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs"; + +/** + * A feature that supports flight status suggestions. + */ +export class FlightStatusSuggestions extends RealtimeSuggestProvider { + get realtimeType() { + return "flightStatus"; + } + + get isSponsored() { + return false; + } + + get merinoProvider() { + return "flightaware"; + } + + getViewTemplateForDescriptionTop(index) { + return [ + { + name: `departure_time_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-time"], + }, + { + name: `origin_airport_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-airport"], + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dash"], + }, + { + name: `arrival_time_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-time"], + }, + { + name: `destination_airport_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-airport"], + }, + ]; + } + + getViewTemplateForDescriptionBottom(index) { + return [ + { + name: `departure_date_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-departure-date"], + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dot"], + }, + { + name: `flight_number_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-flight-number"], + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dot"], + }, + { + name: `status_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-status"], + }, + { + tag: "span", + classList: ["urlbarView-realtime-description-separator-dot"], + }, + { + name: `time_left_minutes_${index}`, + tag: "span", + classList: ["urlbarView-flightStatus-time-left-minutes"], + }, + ]; + } + + getViewUpdateForValues(values) { + return Object.assign( + {}, + ...values.flatMap((v, i) => { + let status; + switch (v.status) { + case "Scheduled": { + status = "ontime"; + break; + } + case "En Route": { + status = "inflight"; + break; + } + case "Arrived": { + status = "arrived"; + break; + } + case "Cancelled": { + status = "cancelled"; + break; + } + case "Delayed": { + status = "delayed"; + break; + } + } + + let departureTime; + let departureTimeZone; + let arrivalTime; + let arrivalTimeZone; + if (status == "delayed" || !v.delayed) { + departureTime = new Date(v.departure.scheduled_time); + departureTimeZone = getTimeZone(v.departure.scheduled_time); + arrivalTime = new Date(v.arrival.scheduled_time); + arrivalTimeZone = getTimeZone(v.arrival.scheduled_time); + } else { + departureTime = new Date(v.departure.estimated_time); + departureTimeZone = getTimeZone(v.departure.estimated_time); + arrivalTime = new Date(v.arrival.estimated_time); + arrivalTimeZone = getTimeZone(v.arrival.estimated_time); + } + + let statusL10nId = `urlbar-result-flight-status-status-${status}`; + let statusL10nArgs; + if (status == "delayed") { + statusL10nArgs = { + departureEstimatedTime: new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "numeric", + timeZone: getTimeZone(v.departure.estimated_time), + }).format(new Date(v.departure.estimated_time)), + }; + } + + return { + [`item_${i}`]: { + attributes: { + status, + }, + }, + [`image_${i}`]: { + attributes: { + src: + v.airline.icon ?? + "chrome://browser/skin/urlbar/flight-airline.svg", + fallback: !v.airline.icon, + }, + }, + [`departure_time_${i}`]: { + textContent: new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "numeric", + timeZone: departureTimeZone, + }).format(departureTime), + }, + [`departure_date_${i}`]: { + textContent: new Intl.DateTimeFormat(undefined, { + month: "long", + day: "numeric", + weekday: "short", + timeZone: departureTimeZone, + }).format(departureTime), + }, + [`arrival_time_${i}`]: { + textContent: new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "numeric", + timeZone: arrivalTimeZone, + }).format(arrivalTime), + }, + [`origin_airport_${i}`]: { + l10n: { + id: "urlbar-result-flight-status-airport", + args: { + city: v.origin.city, + code: v.origin.code, + }, + cacheable: true, + excludeArgsFromCacheKey: true, + }, + }, + [`destination_airport_${i}`]: { + l10n: { + id: "urlbar-result-flight-status-airport", + args: { + city: v.destination.city, + code: v.destination.code, + }, + cacheable: true, + excludeArgsFromCacheKey: true, + }, + }, + [`flight_number_${i}`]: v.airline.name + ? { + l10n: { + id: "urlbar-result-flight-status-flight-number-with-airline", + args: { + flightNumber: v.flight_number, + airlineName: v.airline.name, + }, + cacheable: true, + excludeArgsFromCacheKey: !!statusL10nArgs, + }, + } + : { + textContent: v.flight_number, + }, + [`status_${i}`]: { + l10n: { + id: statusL10nId, + args: statusL10nArgs, + cacheable: true, + excludeArgsFromCacheKey: !!statusL10nArgs, + }, + }, + [`time_left_minutes_${i}`]: + v.time_left_minutes != undefined + ? { + l10n: { + id: "urlbar-result-flight-status-time-left-minutes", + args: { + timeLeftMinutes: v.time_left_minutes, + }, + cacheable: true, + excludeArgsFromCacheKey: !!statusL10nArgs, + }, + } + : null, + }; + }) + ); + } +} + +function getTimeZone(isoTimeString) { + let match = isoTimeString.match(/([+-]\d{2}:?\d{2}|Z)$/); + if (!match) { + return undefined; + } + + let timeZone = match[1]; + return timeZone == "Z" ? "UTC" : timeZone; +} diff --git a/browser/components/urlbar/private/MarketSuggestions.sys.mjs b/browser/components/urlbar/private/MarketSuggestions.sys.mjs @@ -58,7 +58,7 @@ export class MarketSuggestions extends RealtimeSuggestProvider { }, { tag: "span", - classList: ["urlbarView-realtime-description-separator"], + classList: ["urlbarView-realtime-description-separator-dot"], }, { name: `ticker_${index}`, @@ -76,7 +76,7 @@ export class MarketSuggestions extends RealtimeSuggestProvider { }, { tag: "span", - classList: ["urlbarView-realtime-description-separator"], + classList: ["urlbarView-realtime-description-separator-dot"], }, { name: `last_price_${index}`, @@ -85,7 +85,7 @@ export class MarketSuggestions extends RealtimeSuggestProvider { }, { tag: "span", - classList: ["urlbarView-realtime-description-separator"], + classList: ["urlbarView-realtime-description-separator-dot"], }, { name: `exchange_${index}`, diff --git a/browser/components/urlbar/private/YelpRealtimeSuggestions.sys.mjs b/browser/components/urlbar/private/YelpRealtimeSuggestions.sys.mjs @@ -51,7 +51,7 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { }, { tag: "span", - classList: ["urlbarView-realtime-description-separator"], + classList: ["urlbarView-realtime-description-separator-dot"], }, { name: `pricing_${index}`, @@ -60,7 +60,7 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { }, { tag: "span", - classList: ["urlbarView-realtime-description-separator"], + classList: ["urlbarView-realtime-description-separator-dot"], }, { name: `business_hours_${index}`, @@ -69,7 +69,7 @@ export class YelpRealtimeSuggestions extends RealtimeSuggestProvider { }, { tag: "span", - classList: ["urlbarView-realtime-description-separator"], + classList: ["urlbarView-realtime-description-separator-dot"], }, { tag: "span", diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml b/browser/components/urlbar/tests/engagementTelemetry/browser/browser.toml @@ -76,6 +76,8 @@ skip-if = ["verify"] # Bug 1852375 - MerinoTestUtils.initWeather() doesn't play ["browser_glean_telemetry_keyword_exposure.js"] +["browser_glean_telemetry_realtime_flight_status_engagement.js"] + ["browser_glean_telemetry_realtime_market_engagement.js"] ["browser_glean_telemetry_realtime_optin_engagement.js"] diff --git a/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_realtime_flight_status_engagement.js b/browser/components/urlbar/tests/engagementTelemetry/browser/browser_glean_telemetry_realtime_flight_status_engagement.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await setup(); + + await ensureQuickSuggestInit({ + merinoSuggestions: [ + { + provider: "flightaware", + is_sponsored: false, + score: 0, + title: "Flight Suggestion", + custom_details: { + flightaware: { + values: [ + { + flight_number: "A1", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin", + code: "O", + }, + destination: { + city: "Destination", + code: "D", + }, + departure: { + scheduled_time: "2025-09-17T14:05:00Z", + }, + arrival: { + scheduled_time: "2025-09-17T18:30:00Z", + }, + status: "Scheduled", + url: "https://example.com/A1", + }, + ], + }, + }, + }, + ], + prefs: [ + ["flightStatus.featureGate", true], + ["suggest.flightStatus", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); +}); + +add_task(async function click() { + await doTest(async () => { + await openPopup("a1"); + let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let target = element.row.querySelector(".urlbarView-realtime-item"); + let onLocationChange = BrowserTestUtils.waitForLocationChange(gBrowser); + EventUtils.synthesizeMouseAtCenter(target, {}); + await onLocationChange; + + assertEngagementTelemetry([ + { + engagement_type: "click", + selected_result: "merino_flightStatus", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_flightStatus", + }, + ]); + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); + +add_task(async function enter() { + await doTest(async () => { + await openPopup("a1"); + let onLocationChange = BrowserTestUtils.waitForLocationChange(gBrowser); + EventUtils.synthesizeKey("KEY_Tab"); + EventUtils.synthesizeKey("KEY_Enter"); + await onLocationChange; + + assertEngagementTelemetry([ + { + engagement_type: "enter", + selected_result: "merino_flightStatus", + selected_position: 2, + provider: "UrlbarProviderQuickSuggest", + results: "search_engine,merino_flightStatus", + }, + ]); + await PlacesUtils.history.clear(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser.toml b/browser/components/urlbar/tests/quicksuggest/browser/browser.toml @@ -38,6 +38,8 @@ tags = "search-telemetry" ["browser_quicksuggest_ping_merinoAndNullValues.js"] tags = "search-telemetry" +["browser_quicksuggest_realtime_flight_status.js"] + ["browser_quicksuggest_realtime_market.js"] ["browser_quicksuggest_realtime_optin.js"] diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_flight_status.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_flight_status.js @@ -0,0 +1,498 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_MERINO_SINGLE = [ + { + provider: "flightaware", + is_sponsored: false, + score: 0, + title: "Flight Suggestion", + custom_details: { + flightaware: { + values: [ + { + flight_number: "A1", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin", + code: "O", + }, + destination: { + city: "Destination", + code: "D", + }, + departure: { + scheduled_time: "2025-09-17T14:05:00Z", + }, + arrival: { + scheduled_time: "2025-09-17T18:30:00Z", + }, + status: "Scheduled", + url: "https://example.com/A1", + }, + ], + }, + }, + }, +]; + +const TEST_MERINO_MULTI = [ + { + provider: "flightaware", + is_sponsored: false, + score: 0, + title: "Flight Suggestion", + custom_details: { + flightaware: { + values: [ + { + flight_number: "A1", + airline: { + name: "A Air", + code: "A", + icon: "chrome://browser/skin/urlbar/market-up.svg", + }, + origin: { + city: "Origin 1", + code: "O1", + }, + destination: { + city: "Destination 1", + code: "D1", + }, + departure: { + scheduled_time: "2025-09-01T14:01:00Z", + }, + arrival: { + scheduled_time: "2025-09-01T18:31:00Z", + }, + status: "Scheduled", + url: "https://example.com/A1", + }, + { + flight_number: "A2", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin 2", + code: "O2", + }, + destination: { + city: "Destination 2", + code: "D2", + }, + departure: { + scheduled_time: "2025-09-02T14:02:00+02:00", + }, + arrival: { + scheduled_time: "2025-09-02T18:32:00-02:00", + }, + status: "En Route", + progress_percent: 72, + time_left_minutes: 1, + url: "https://example.com/A2", + }, + { + flight_number: "A3", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin 3", + code: "O3", + }, + destination: { + city: "Destination 3", + code: "D3", + }, + departure: { + scheduled_time: "2025-09-03T14:03:00+0300", + }, + arrival: { + scheduled_time: "2025-09-03T18:33:00-0300", + }, + status: "Arrived", + time_left_minutes: 0, + url: "https://example.com/A3", + }, + { + flight_number: "A4", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin 4", + code: "O4", + }, + destination: { + city: "Destination 4", + code: "D4", + }, + departure: { + scheduled_time: "2025-09-04T14:04:00+10:30", + }, + arrival: { + scheduled_time: "2025-09-04T18:34:00-10:30", + }, + status: "Cancelled", + url: "https://example.com/A4", + }, + // Delayed flight cases + { + flight_number: "D1", + airline: { + name: "Delay Air", + code: "D", + icon: "chrome://browser/skin/urlbar/market-down.svg", + }, + origin: { + city: "Origin D1", + code: "OD1", + }, + destination: { + city: "Destination D1", + code: "DD1", + }, + departure: { + scheduled_time: "2025-09-05T14:05:00+1130", + estimated_time: "2025-09-05T15:05:00-1130", + }, + arrival: { + scheduled_time: "2025-09-05T18:35:00Z", + estimated_time: "2025-09-05T19:35:00Z", + }, + status: "Delayed", + delayed: true, + url: "https://example.com/D1", + }, + { + flight_number: "D2", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin D2", + code: "OD2", + }, + destination: { + city: "Destination D2", + code: "DD2", + }, + departure: { + scheduled_time: "2025-09-06T14:06:00Z", + estimated_time: "2025-09-06T15:06:00Z", + }, + arrival: { + scheduled_time: "2025-09-06T18:36:00Z", + estimated_time: "2025-09-06T19:36:00Z", + }, + status: "En Route", + time_left_minutes: 10, + delayed: true, + url: "https://example.com/D2", + }, + { + flight_number: "D3", + airline: { + name: null, + code: null, + icon: null, + }, + origin: { + city: "Origin D3", + code: "OD3", + }, + destination: { + city: "Destination D3", + code: "DD3", + }, + departure: { + scheduled_time: "2025-09-07T14:07:00Z", + estimated_time: "2025-09-07T15:07:00Z", + }, + arrival: { + scheduled_time: "2025-09-07T18:37:00Z", + estimated_time: "2025-09-07T19:37:00Z", + }, + status: "Arrived", + delayed: true, + time_left_minutes: 0, + url: "https://example.com/D3", + }, + ], + }, + }, + }, +]; + +add_setup(async function () { + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: TEST_MERINO_SINGLE, + prefs: [ + ["flightStatus.featureGate", true], + ["suggest.flightStatus", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); +}); + +add_task(async function ui() { + MerinoTestUtils.server.response.body.suggestions = TEST_MERINO_MULTI; + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + const expectedList = [ + { + flight_number: "A1, A Air", + image: "chrome://browser/skin/urlbar/market-up.svg", + departure_time: "2:01 PM", + departure_date: "Mon, September 1", + arrival_time: "6:31 PM", + origin_airport: "Origin 1 (O1)", + destination_airport: "Destination 1 (D1)", + status: "On time", + }, + { + flight_number: "A2", + image: "chrome://browser/skin/urlbar/flight-airline.svg", + departure_time: "2:02 PM", + departure_date: "Tue, September 2", + arrival_time: "6:32 PM", + origin_airport: "Origin 2 (O2)", + destination_airport: "Destination 2 (D2)", + status: "In flight", + time_left_minutes: "1 min left", + }, + { + flight_number: "A3", + image: "chrome://browser/skin/urlbar/flight-airline.svg", + departure_time: "2:03 PM", + departure_date: "Wed, September 3", + arrival_time: "6:33 PM", + origin_airport: "Origin 3 (O3)", + destination_airport: "Destination 3 (D3)", + status: "Arrived", + time_left_minutes: "0 mins left", + }, + { + flight_number: "A4", + image: "chrome://browser/skin/urlbar/flight-airline.svg", + departure_time: "2:04 PM", + departure_date: "Thu, September 4", + arrival_time: "6:34 PM", + origin_airport: "Origin 4 (O4)", + destination_airport: "Destination 4 (D4)", + status: "Cancelled", + }, + { + flight_number: "D1, Delay Air", + image: "chrome://browser/skin/urlbar/market-down.svg", + departure_time: "2:05 PM", + departure_date: "Fri, September 5", + arrival_time: "6:35 PM", + origin_airport: "Origin D1 (OD1)", + destination_airport: "Destination D1 (DD1)", + status: "Delayed until 3:05 PM", + }, + { + flight_number: "D2", + image: "chrome://browser/skin/urlbar/flight-airline.svg", + departure_time: "3:06 PM", + departure_date: "Sat, September 6", + arrival_time: "7:36 PM", + origin_airport: "Origin D2 (OD2)", + destination_airport: "Destination D2 (DD2)", + status: "In flight", + time_left_minutes: "10 mins left", + }, + { + flight_number: "D3", + image: "chrome://browser/skin/urlbar/flight-airline.svg", + departure_time: "3:07 PM", + departure_date: "Sun, September 7", + arrival_time: "7:37 PM", + origin_airport: "Origin D3 (OD3)", + destination_airport: "Destination D3 (DD3)", + status: "Arrived", + time_left_minutes: "0 mins left", + }, + ]; + + let items = element.row.querySelectorAll(".urlbarView-realtime-item"); + Assert.equal(items.length, expectedList.length); + + for (let i = 0; i < items.length; i++) { + info(`Check the item[${i}]`); + let item = items[i]; + let expected = expectedList[i]; + + await TestUtils.waitForCondition( + () => + item.querySelector(`[name=status_${i}]`).textContent == expected.status, + "Wait until the status text will be applied by Fluent" + ); + Assert.equal( + item.querySelector(".urlbarView-realtime-image").src, + expected.image + ); + Assert.equal( + item.querySelector(`[name=departure_time_${i}]`).textContent, + expected.departure_time + ); + Assert.equal( + item.querySelector(`[name=departure_date_${i}]`).textContent, + expected.departure_date + ); + Assert.equal( + item.querySelector(`[name=arrival_time_${i}]`).textContent, + expected.arrival_time + ); + Assert.equal( + item.querySelector(`[name=origin_airport_${i}]`).textContent, + expected.origin_airport + ); + Assert.equal( + item.querySelector(`[name=destination_airport_${i}]`).textContent, + expected.destination_airport + ); + Assert.equal( + item.querySelector(`[name=flight_number_${i}]`).textContent, + expected.flight_number + ); + + let timeLeftMinutes = item.querySelector(`[name=time_left_minutes_${i}]`); + if (typeof expected.time_left_minutes != "undefined") { + Assert.equal(timeLeftMinutes.textContent, expected.time_left_minutes); + } else { + Assert.equal(timeLeftMinutes.textContent, ""); + let previousSeparator = timeLeftMinutes.previousElementSibling; + Assert.ok(BrowserTestUtils.isHidden(previousSeparator)); + } + } + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); +}); + +add_task(async function activate_single() { + MerinoTestUtils.server.response.body.suggestions = TEST_MERINO_SINGLE; + + for (let mouse of [true, false]) { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let { row } = element; + + let target = TEST_MERINO_SINGLE[0].custom_details.flightaware.values[0]; + let expectedURL = target.url; + let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL); + + if (mouse) { + info("Activate by mouse"); + let root = row.querySelector(".urlbarView-realtime-root"); + EventUtils.synthesizeMouseAtCenter(root, {}); + } else { + info("Activate by key"); + EventUtils.synthesizeKey("KEY_Tab"); + Assert.equal( + UrlbarTestUtils.getSelectedRow(window), + row, + "The row should be selected" + ); + EventUtils.synthesizeKey("KEY_Enter"); + } + + let newTab = await newTabOpened; + Assert.ok(true, `Expected URL is loaded [${expectedURL}]`); + + await UrlbarTestUtils.promisePopupClose(window); + BrowserTestUtils.removeTab(newTab); + await PlacesUtils.history.clear(); + } +}); + +add_task(async function activate_multi() { + MerinoTestUtils.server.response.body.suggestions = TEST_MERINO_MULTI; + + for (let [ + index, + value, + ] of TEST_MERINO_MULTI[0].custom_details.flightaware.values.entries()) { + info(`Activate item[${index}] by mouse`); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + let items = element.row.querySelectorAll(".urlbarView-realtime-item"); + let item = items[index]; + + let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, value.url); + await EventUtils.synthesizeMouseAtCenter(item, {}, item.ownerGlobal); + + let newTab = await newTabOpened; + Assert.ok(true, `Expected URL is loaded [${value.url}]`); + BrowserTestUtils.removeTab(newTab); + await PlacesUtils.history.clear(); + } + + for (let [ + index, + value, + ] of TEST_MERINO_MULTI[0].custom_details.flightaware.values.entries()) { + info(`Activate item[${index}] by key`); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "only match the Merino suggestion", + }); + let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + + for (let i = 0; i < index + 1; i++) { + EventUtils.synthesizeKey("KEY_Tab"); + } + + let items = element.row.querySelectorAll(".urlbarView-realtime-item"); + let item = items[index]; + + let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, value.url); + await EventUtils.synthesizeMouseAtCenter(item, {}, item.ownerGlobal); + let newTab = await newTabOpened; + Assert.ok(true, `Expected URL is loaded [${value.url}]`); + BrowserTestUtils.removeTab(newTab); + await PlacesUtils.history.clear(); + } + + await UrlbarTestUtils.promisePopupClose(window); + gURLBar.handleRevert(); +}); + +function toDate(timeString) { + let time = new Date(timeString); + time.setMinutes(time.getMinutes() + time.getTimezoneOffset()); + return time; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_flight_status.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_flight_status.js @@ -0,0 +1,210 @@ +/* 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/. */ + +// Tests flight status suggestions. + +"use strict"; + +const TEST_MERINO_SINGLE = [ + { + provider: "flightaware", + is_sponsored: false, + score: 0, + title: "Flight Suggestion", + custom_details: { + flightaware: { + values: [ + { + flight_number: "flight", + origin: { + city: "Origin", + code: "O", + }, + destination: { + city: "Destination", + code: "D", + }, + departure_scheduled_time: "2025-09-17T14:05:00Z", + arrival_scheduled_time: "2025-09-17T18:30:00Z", + status: "Scheduled", + url: "https://example.com/A1", + }, + ], + }, + }, + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + await Services.search.init(); + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: TEST_MERINO_SINGLE, + prefs: [ + ["flightStatus.featureGate", true], + ["suggest.flightStatus", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); +}); + +add_task(async function telemetry_type() { + Assert.equal( + QuickSuggest.getFeature( + "FlightStatusSuggestions" + ).getSuggestionTelemetryType({}), + "flightStatus", + "Telemetry type should be 'flightStatus'" + ); +}); + +add_task(async function not_interested_on_realtime() { + await doDismissAllTest({ + result: merinoResult(), + command: "not_interested", + feature: QuickSuggest.getFeature("FlightStatusSuggestions"), + pref: "suggest.flightStatus", + queries: [{ query: "a1" }], + }); +}); + +add_task(async function show_less_frequently() { + UrlbarPrefs.clear("flightStatus.showLessFrequentlyCount"); + UrlbarPrefs.clear("flightStatus.minKeywordLength"); + + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + realtimeMinKeywordLength: 0, + realtimeShowLessFrequentlyCap: 3, + }); + + let result = merinoResult(); + + const testData = [ + { + input: "fli", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 0, + minKeywordLength: 0, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 4, + }, + }, + { + input: "fligh", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 4, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 6, + }, + }, + { + input: "flight", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 6, + }, + after: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 7, + }, + }, + ]; + + for (let { input, before, after } of testData) { + let feature = QuickSuggest.getFeature("FlightStatusSuggestions"); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); + + Assert.equal( + UrlbarPrefs.get("flightStatus.minKeywordLength"), + before.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, before.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + before.showLessFrequentlyCount + ); + + triggerCommand({ + result, + feature, + command: "show_less_frequently", + searchString: input, + }); + + Assert.equal( + UrlbarPrefs.get("flightStatus.minKeywordLength"), + after.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, after.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + after.showLessFrequentlyCount + ); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + } + + await cleanUpNimbus(); + UrlbarPrefs.clear("flightStatus.showLessFrequentlyCount"); + UrlbarPrefs.clear("flightStatus.minKeywordLength"); +}); + +function merinoResult() { + return { + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isBestMatch: true, + hideRowLabel: true, + heuristic: false, + payload: { + source: "merino", + provider: "flightaware", + dynamicType: "flightStatus", + telemetryType: "flightStatus", + isSponsored: false, + flightaware: { + values: [ + { + flight_number: "flight", + origin: { + city: "Origin", + code: "O", + }, + destination: { city: "Destination", code: "D" }, + departure_scheduled_time: "2025-09-17T14:05:00Z", + arrival_scheduled_time: "2025-09-17T18:30:00Z", + status: "Scheduled", + url: "https://example.com/A1", + }, + ], + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml @@ -21,6 +21,8 @@ prefs = [ ["test_quicksuggest_exposures_locales.js"] +["test_quicksuggest_flight_status.js"] + ["test_quicksuggest_importantDatesSuggestions.js"] ["test_quicksuggest_impressionCaps.js"] diff --git a/browser/themes/shared/jar.inc.mn b/browser/themes/shared/jar.inc.mn @@ -28,6 +28,7 @@ skin/classic/browser/sidebar.css (../shared/sidebar.css) skin/classic/browser/toolbarbuttons.css (../shared/toolbarbuttons.css) skin/classic/browser/toolbarbutton-icons.css (../shared/toolbarbutton-icons.css) + skin/classic/browser/urlbar/flight-airline.svg (../shared/urlbar/flight-airline.svg) skin/classic/browser/urlbar/market-down.svg (../shared/urlbar/market-down.svg) skin/classic/browser/urlbar/market-unchanged.svg (../shared/urlbar/market-unchanged.svg) skin/classic/browser/urlbar/market-up.svg (../shared/urlbar/market-up.svg) diff --git a/browser/themes/shared/urlbar/flight-airline.svg b/browser/themes/shared/urlbar/flight-airline.svg @@ -0,0 +1,6 @@ +<!-- 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 https://mozilla.org/MPL/2.0/. --> +<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill="context-fill" fill-rule="evenodd" clip-rule="evenodd" d="M22.836 3.31262C22.0851 2.56178 20.8658 2.56178 20.115 3.31262L17.363 6.06457C17.0643 6.36335 16.6288 6.48005 16.2207 6.37072L8.20978 4.22492C8.2095 4.22485 8.21006 4.225 8.20978 4.22492C7.9457 4.15503 7.66576 4.23047 7.47495 4.42129L5.91146 5.98477L13.0273 7.89114C13.4355 8.00049 13.7544 8.31931 13.8637 8.72748C13.9731 9.13566 13.8564 9.57119 13.5576 9.86999L8.58203 14.8456C8.32481 15.1028 7.96353 15.2274 7.60243 15.1833L4.74916 14.8352L3.90222 15.6821L8.26029 17.1426C8.61202 17.2605 8.88807 17.5365 9.00595 17.8883L10.4666 22.247L11.3134 21.4007L10.9652 18.5461C10.9212 18.1851 11.0457 17.8239 11.3028 17.5667L16.2784 12.5895C16.5772 12.2906 17.0128 12.1739 17.421 12.2832C17.8292 12.3926 18.1481 12.7115 18.2574 13.1197L20.1639 20.237L21.7273 18.6736C21.9198 18.4811 21.9943 18.202 21.9243 17.9413L19.7778 9.92791C19.6685 9.51976 19.7852 9.0843 20.084 8.78552L22.836 6.03357C23.5868 5.28272 23.5868 4.06346 22.836 3.31262ZM18.4423 1.63991C20.1169 -0.0347536 22.834 -0.0347536 24.5087 1.63991C26.1833 3.31456 26.1833 6.03162 24.5087 7.70628L22.2395 9.97541L24.2091 17.3284C24.2091 17.3283 24.2092 17.3286 24.2091 17.3284C24.4986 18.4081 24.1883 19.558 23.4 20.3463L20.3878 23.3585C20.089 23.6573 19.6535 23.774 19.2453 23.6646C18.8371 23.5552 18.5183 23.2364 18.409 22.8282L16.5026 15.7112L13.3832 18.8316L13.7313 21.6866C13.7754 22.0478 13.6507 22.4092 13.3934 22.6664L10.7881 25.2701C10.4984 25.5596 10.0796 25.6787 9.68097 25.5849C9.28234 25.491 8.96061 25.1977 8.83048 24.8094L6.95013 19.1984L1.3392 17.3181C0.950841 17.1879 0.657456 16.8661 0.563672 16.4674C0.469889 16.0687 0.589057 15.6499 0.87868 15.3602L3.48397 12.755C3.74119 12.4977 4.10248 12.3732 4.46357 12.4172L7.31684 12.7654L10.4362 9.64596L3.32034 7.73959C2.91216 7.63024 2.59332 7.31142 2.48394 6.90325C2.37456 6.49507 2.49126 6.05955 2.79007 5.76074L5.80223 2.74857C6.59214 1.95866 7.74145 1.65264 8.81762 1.93878L8.81973 1.93934L16.1732 3.90903L18.4423 1.63991Z"/> +</svg> diff --git a/browser/themes/shared/urlbarView.css b/browser/themes/shared/urlbarView.css @@ -449,6 +449,14 @@ > .urlbarView-row-buttons > &:not([open]):first-child:empty { display: none; + + /* The realtime suggestions' style will be broken if the width will be + changed by hover. Thus we use the visibility instead of display to keep + the element size */ + .urlbarView-row:is([dynamicType="flightStatus"], [dynamicType="market"], [dynamicType="yelpRealtime"]) & { + display: unset; + visibility: hidden; + } } /* Labeled result menu button */ @@ -1319,6 +1327,8 @@ --green-status-color: light-dark(var(--color-green-60), var(--color-green-20)); --red-status-color: light-dark(var(--color-red-70), var(--color-red-40)); + /* Realtime suggestions can contain many items, which should always wrap. */ + flex-wrap: wrap; /* Remove the usual inner padding. Each item in the row will have its own. */ padding: 0; @@ -1383,12 +1393,19 @@ color: var(--urlbarView-secondary-text-color); } - > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator::before, - > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator::before { + > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dot::before, + > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dot::before { margin-inline: var(--space-xsmall); content: "·"; display: inline; } + + > .urlbarView-realtime-description-top > .urlbarView-realtime-description-separator-dash::before, + > .urlbarView-realtime-description-bottom > .urlbarView-realtime-description-separator-dash::before { + margin-inline: var(--space-xsmall); + content: "–"; + display: inline; + } } } } @@ -1513,6 +1530,59 @@ } } +/* Flight status suggestions specific */ + +.urlbarView-row[dynamicType="flightStatus"] > .urlbarView-realtime-root > .urlbarView-realtime-item { + > .urlbarView-realtime-image-container { + --airline-fallback-icon-color: #bac2ca; + + border-color: color-mix(in srgb, currentColor 10%, transparent); + background-color: var(--urlbar-box-focus-bgcolor); + + > .urlbarView-realtime-image[fallback] { + width: 26px; + height: 26px; + flex-basis: 26px; + fill: var(--airline-fallback-icon-color); + } + } + + > .urlbarView-realtime-description { + > .urlbarView-realtime-description-top > .urlbarView-flightStatus-time { + font-weight: var(--font-weight-bold); + margin-inline-end: var(--space-small); + } + + > .urlbarView-realtime-description-bottom { + > .urlbarView-flightStatus-status { + font-weight: var(--font-weight-bold); + } + + .urlbarView-row:not([selected]) + > .urlbarView-realtime-root + > .urlbarView-realtime-item:is([status="ontime"], [status="inflight"], [status="arrived"]):not([selected]) + > .urlbarView-realtime-description + > & + > .urlbarView-flightStatus-status { + color: var(--green-status-color); + } + + .urlbarView-row:not([selected]) + > .urlbarView-realtime-root + > .urlbarView-realtime-item:is([status="cancelled"], [status="delayed"]):not([selected]) + > .urlbarView-realtime-description + > & + > .urlbarView-flightStatus-status { + color: var(--red-status-color); + } + + > .urlbarView-realtime-description-separator-dot:has(+ .urlbarView-flightStatus-time-left-minutes:empty) { + display: none; + } + } + } +} + /* Search one-offs */ #urlbar .search-one-offs:not([hidden]) { diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml @@ -287,6 +287,12 @@ urlbar: description: >- Feature gate that controls whether all aspects of the Fakespot suggestion feature are exposed to the user. + flightStatusFeatureGate: + type: boolean + fallbackPref: browser.urlbar.flightStatus.featureGate + description: >- + Feature gate that controls whether all aspects of flight status suggestions + feature are exposed to the user. importantDatesFeatureGate: type: boolean fallbackPref: browser.urlbar.importantDates.featureGate