зеркало из https://github.com/mozilla/gecko-dev.git
Bug 1821826 - Refactor FeatureCallout to support generic triggers. r=omc-reviewers,fxview-reviewers,tabbrowser-reviewers,dao,jprickett,sclements
Also disable the Firefox View feature tour to avoid any risk of regressions. The feature tour code will be removed in a later patch. It's still present for now for testing purposes. Differential Revision: https://phabricator.services.mozilla.com/D180927
This commit is contained in:
Родитель
d981109bac
Коммит
9c7a1ae2e1
|
@ -299,10 +299,6 @@
|
|||
|
||||
_hoverTabTimer: null,
|
||||
|
||||
_featureCallout: null,
|
||||
|
||||
_featureCalloutPanelId: null,
|
||||
|
||||
get tabContainer() {
|
||||
delete this.tabContainer;
|
||||
return (this.tabContainer = document.getElementById("tabbrowser-tabs"));
|
||||
|
@ -367,37 +363,6 @@
|
|||
return this._selectedBrowser;
|
||||
},
|
||||
|
||||
get featureCallout() {
|
||||
return this._featureCallout;
|
||||
},
|
||||
|
||||
set featureCallout(val) {
|
||||
this._featureCallout = val;
|
||||
},
|
||||
|
||||
get instantiateFeatureCalloutTour() {
|
||||
return this._instantiateFeatureCalloutTour;
|
||||
},
|
||||
|
||||
get featureCalloutPanelId() {
|
||||
return this._featureCalloutPanelId;
|
||||
},
|
||||
|
||||
_instantiateFeatureCalloutTour(browser, panelId) {
|
||||
this._featureCalloutPanelId = panelId;
|
||||
const { FeatureCallout } = ChromeUtils.importESModule(
|
||||
"resource:///modules/FeatureCallout.sys.mjs"
|
||||
);
|
||||
// Note - once we have additional browser chrome messages,
|
||||
// only use PDF.js pref value when navigating to PDF viewer
|
||||
this._featureCallout = new FeatureCallout({
|
||||
win: window,
|
||||
browser,
|
||||
prefName: "browser.pdfjs.feature-tour",
|
||||
page: "chrome",
|
||||
theme: { preset: "pdfjs", simulateContent: true },
|
||||
});
|
||||
},
|
||||
_setupInitialBrowserAndTab() {
|
||||
// See browser.js for the meaning of window.arguments.
|
||||
// Bug 1485961 covers making this more sane.
|
||||
|
@ -1126,25 +1091,6 @@
|
|||
|
||||
let newTab = this.getTabForBrowser(newBrowser);
|
||||
|
||||
if (
|
||||
this._featureCallout &&
|
||||
this._featureCalloutPanelId !== newTab.linkedPanel
|
||||
) {
|
||||
this._featureCallout.endTour(true);
|
||||
this._featureCallout = null;
|
||||
}
|
||||
|
||||
// For now, only check for Feature Callout messages
|
||||
// when viewing PDFs. Later, we can expand this to check
|
||||
// for callout messages on every change of tab location.
|
||||
if (
|
||||
!this._featureCallout &&
|
||||
newBrowser.contentPrincipal.originNoSuffix === "resource://pdf.js"
|
||||
) {
|
||||
this._instantiateFeatureCalloutTour(newBrowser, newTab.linkedPanel);
|
||||
window.gBrowser.featureCallout.showFeatureCallout();
|
||||
}
|
||||
|
||||
if (!aForceUpdate) {
|
||||
TelemetryStopwatch.start("FX_TAB_SWITCH_UPDATE_MS");
|
||||
|
||||
|
@ -6980,30 +6926,6 @@
|
|||
gBrowser._tabLayerCache.splice(tabCacheIndex, 1);
|
||||
gBrowser._getSwitcher().cleanUpTabAfterEviction(this.mTab);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
gBrowser.featureCallout &&
|
||||
(gBrowser.featureCalloutPanelId !==
|
||||
gBrowser.selectedTab.linkedPanel ||
|
||||
gBrowser.contentPrincipal.originNoSuffix !== "resource://pdf.js")
|
||||
) {
|
||||
gBrowser.featureCallout.endTour(true);
|
||||
gBrowser.featureCallout = null;
|
||||
}
|
||||
|
||||
// For now, only check for Feature Callout messages
|
||||
// when viewing PDFs. Later, we can expand this to check
|
||||
// for callout messages on every change of tab location.
|
||||
if (
|
||||
!gBrowser.featureCallout &&
|
||||
gBrowser.contentPrincipal.originNoSuffix === "resource://pdf.js"
|
||||
) {
|
||||
gBrowser.instantiateFeatureCalloutTour(
|
||||
gBrowser.selectedBrowser,
|
||||
gBrowser.selectedTab.linkedPanel
|
||||
);
|
||||
gBrowser.featureCallout.showFeatureCallout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,20 +2,6 @@
|
|||
* 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/. */
|
||||
|
||||
const { FeatureCallout } = ChromeUtils.importESModule(
|
||||
"resource:///modules/FeatureCallout.sys.mjs"
|
||||
);
|
||||
|
||||
const launchFeatureTour = () => {
|
||||
let callout = new FeatureCallout({
|
||||
win: window,
|
||||
prefName: "browser.firefox-view.feature-tour",
|
||||
page: "about:firefoxview",
|
||||
theme: { preset: "themed-content" },
|
||||
});
|
||||
callout.showFeatureCallout();
|
||||
};
|
||||
|
||||
window.addEventListener("DOMContentLoaded", async () => {
|
||||
Services.telemetry.setEventRecordingEnabled("firefoxview", true);
|
||||
Services.telemetry.recordEvent("firefoxview", "entered", "firefoxview", null);
|
||||
|
@ -30,7 +16,6 @@ window.addEventListener("DOMContentLoaded", async () => {
|
|||
) {
|
||||
await document.getElementById("tab-pickup-container").onReload();
|
||||
}
|
||||
launchFeatureTour();
|
||||
});
|
||||
|
||||
window.addEventListener("unload", () => {
|
||||
|
|
|
@ -15,7 +15,6 @@ skip-if = true # Bug 1783684
|
|||
[browser_feature_callout_theme.js]
|
||||
[browser_firefoxview.js]
|
||||
[browser_firefoxview_accessibility.js]
|
||||
[browser_firefoxview_feature_callout_a11y.js]
|
||||
[browser_firefoxview_next.js]
|
||||
[browser_firefoxview_tab.js]
|
||||
[browser_keyboard_focus.js]
|
||||
|
|
|
@ -10,7 +10,6 @@ const { BuiltInThemes } = ChromeUtils.importESModule(
|
|||
"resource:///modules/BuiltInThemes.sys.mjs"
|
||||
);
|
||||
|
||||
const featureTourPref = "browser.firefox-view.feature-tour";
|
||||
const defaultPrefValue = getPrefValueByScreen(1);
|
||||
|
||||
add_setup(async function () {
|
||||
|
@ -30,6 +29,8 @@ add_task(async function feature_callout_renders_in_firefox_view() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
|
||||
ok(
|
||||
|
@ -43,7 +44,7 @@ add_task(async function feature_callout_renders_in_firefox_view() {
|
|||
add_task(async function feature_callout_is_not_shown_twice() {
|
||||
// Third comma-separated value of the pref is set to a string value once a user completes the tour
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, '{"message":"","screen":"","complete":true}']],
|
||||
set: [[featureTourPref, '{"screen":"","complete":true}']],
|
||||
});
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -54,6 +55,8 @@ add_task(async function feature_callout_is_not_shown_twice() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
ok(
|
||||
!document.querySelector(calloutSelector),
|
||||
"Feature Callout tour does not render if the user finished it previously"
|
||||
|
@ -75,6 +78,7 @@ add_task(async function feature_callout_syncs_across_visits_and_tabs() {
|
|||
"about:firefoxview"
|
||||
);
|
||||
let tab1Doc = tab1.linkedBrowser.contentWindow.document;
|
||||
launchFeatureTourIn(tab1.linkedBrowser.contentWindow);
|
||||
await waitForCalloutScreen(tab1Doc, "FEATURE_CALLOUT_1");
|
||||
|
||||
ok(
|
||||
|
@ -88,6 +92,7 @@ add_task(async function feature_callout_syncs_across_visits_and_tabs() {
|
|||
"about:firefoxview"
|
||||
);
|
||||
let tab2Doc = tab2.linkedBrowser.contentWindow.document;
|
||||
launchFeatureTourIn(tab2.linkedBrowser.contentWindow);
|
||||
await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_1");
|
||||
|
||||
ok(
|
||||
|
@ -96,6 +101,7 @@ add_task(async function feature_callout_syncs_across_visits_and_tabs() {
|
|||
);
|
||||
|
||||
await clickPrimaryButton(tab2Doc);
|
||||
await waitForCalloutScreen(tab2Doc, "FEATURE_CALLOUT_2");
|
||||
|
||||
gBrowser.selectedTab = tab1;
|
||||
tab1.focus();
|
||||
|
@ -129,9 +135,10 @@ add_task(async function feature_callout_syncs_across_visits_and_tabs() {
|
|||
});
|
||||
|
||||
add_task(async function feature_callout_closes_on_dismiss() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, '{"screen":"FEATURE_CALLOUT_2","complete":false}']],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
const spy = new TelemetrySpy(sandbox);
|
||||
|
||||
|
@ -143,6 +150,8 @@ add_task(async function feature_callout_closes_on_dismiss() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
|
||||
|
||||
document.querySelector(".dismiss-button").click();
|
||||
|
@ -185,9 +194,7 @@ add_task(async function feature_callout_closes_on_dismiss() {
|
|||
|
||||
add_task(async function feature_callout_not_rendered_when_it_has_no_parent() {
|
||||
Services.telemetry.clearEvents();
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].parent_selector = "#fake-selector";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
|
@ -199,6 +206,8 @@ add_task(async function feature_callout_not_rendered_when_it_has_no_parent() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
const CONTAINER_NOT_CREATED_EVENT = [
|
||||
[
|
||||
"messaging_experiments",
|
||||
|
@ -232,9 +241,7 @@ add_task(async function feature_callout_not_rendered_when_it_has_no_parent() {
|
|||
});
|
||||
|
||||
add_task(async function feature_callout_only_highlights_existing_elements() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].parent_selector = "#fake-selector";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
|
@ -245,6 +252,9 @@ add_task(async function feature_callout_only_highlights_existing_elements() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
ok(
|
||||
!document.querySelector(`${calloutSelector}:not(.hidden)`),
|
||||
"Feature Callout screen does not render if its parent element does not exist"
|
||||
|
@ -255,9 +265,7 @@ add_task(async function feature_callout_only_highlights_existing_elements() {
|
|||
});
|
||||
|
||||
add_task(async function feature_callout_arrow_class_exists() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -267,6 +275,9 @@ add_task(async function feature_callout_arrow_class_exists() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
|
||||
const arrowParent = document.querySelector(".callout-arrow.arrow-top");
|
||||
|
@ -277,9 +288,7 @@ add_task(async function feature_callout_arrow_class_exists() {
|
|||
});
|
||||
|
||||
add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].content.arrow_position = "start";
|
||||
testMessage.message.content.screens[0].parent_selector = "span.brand-icon";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
@ -290,6 +299,9 @@ add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
return document.querySelector(
|
||||
`${calloutSelector}.arrow-inline-start:not(.hidden)`
|
||||
|
@ -305,19 +317,22 @@ add_task(async function feature_callout_arrow_is_not_flipped_on_ltr() {
|
|||
});
|
||||
|
||||
add_task(async function feature_callout_respects_cfr_features_pref() {
|
||||
async function toggleCFRFeaturesPref(value, extraPrefs = []) {
|
||||
async function toggleCFRFeaturesPref(value) {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
[
|
||||
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
|
||||
value,
|
||||
],
|
||||
...extraPrefs,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
await toggleCFRFeaturesPref(true, [[featureTourPref, defaultPrefValue]]);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
|
||||
await toggleCFRFeaturesPref(true);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
{
|
||||
|
@ -327,21 +342,19 @@ add_task(async function feature_callout_respects_cfr_features_pref() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
ok(
|
||||
document.querySelector(calloutSelector),
|
||||
"Feature Callout element exists"
|
||||
);
|
||||
|
||||
await toggleCFRFeaturesPref(false);
|
||||
await waitForCalloutRemoved(document);
|
||||
ok(
|
||||
!document.querySelector(calloutSelector),
|
||||
"Feature Callout element was removed because CFR pref was disabled"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await SpecialPowers.popPrefEnv();
|
||||
await toggleCFRFeaturesPref(false);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
{
|
||||
gBrowser,
|
||||
|
@ -350,19 +363,16 @@ add_task(async function feature_callout_respects_cfr_features_pref() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
ok(
|
||||
!document.querySelector(calloutSelector),
|
||||
"Feature Callout element was not created because CFR pref was disabled"
|
||||
);
|
||||
|
||||
await toggleCFRFeaturesPref(true);
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
ok(
|
||||
document.querySelector(calloutSelector),
|
||||
"Feature Callout element was created because CFR pref was enabled"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await SpecialPowers.popPrefEnv();
|
||||
});
|
||||
|
||||
add_task(
|
||||
|
@ -384,6 +394,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
let tabOpened = new Promise(resolve => {
|
||||
gBrowser.tabContainer.addEventListener(
|
||||
"TabOpen",
|
||||
|
@ -423,7 +436,7 @@ add_task(
|
|||
|
||||
add_task(async function feature_callout_dismiss_on_page_click() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, `{"message":"","screen":"","complete":true}`]],
|
||||
set: [[featureTourPref, `{"screen":"","complete":true}`]],
|
||||
});
|
||||
const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER";
|
||||
const testClickSelector = "#recently-closed-tabs-container";
|
||||
|
@ -451,6 +464,8 @@ add_task(async function feature_callout_dismiss_on_page_click() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
info("Waiting for callout to render");
|
||||
await waitForCalloutScreen(document, screenId);
|
||||
|
||||
|
@ -491,13 +506,11 @@ add_task(async function feature_callout_dismiss_on_page_click() {
|
|||
});
|
||||
|
||||
add_task(async function feature_callout_advance_tour_on_page_click() {
|
||||
let sandbox = sinon.createSandbox();
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
[
|
||||
featureTourPref,
|
||||
JSON.stringify({
|
||||
message: "FIREFOX_VIEW_FEATURE_TOUR",
|
||||
screen: "FEATURE_CALLOUT_1",
|
||||
complete: false,
|
||||
}),
|
||||
|
@ -506,30 +519,19 @@ add_task(async function feature_callout_advance_tour_on_page_click() {
|
|||
});
|
||||
|
||||
// Add page action listeners to the built-in messages.
|
||||
const TEST_MESSAGES = FeatureCalloutMessages.getMessages().filter(msg =>
|
||||
[
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS",
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS",
|
||||
].includes(msg.id)
|
||||
);
|
||||
TEST_MESSAGES.forEach(msg => {
|
||||
let { content } = msg.content.screens[msg.content.startScreen ?? 0];
|
||||
content.page_event_listeners = [
|
||||
let testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
// Configure message with a dismiss action on tab container click
|
||||
testMessage.message.content.screens.forEach(screen => {
|
||||
screen.content.page_event_listeners = [
|
||||
{
|
||||
params: {
|
||||
type: "click",
|
||||
selectors: ".brand-logo",
|
||||
},
|
||||
action: JSON.parse(JSON.stringify(content.primary_button.action)),
|
||||
params: { type: "click", selectors: ".brand-logo" },
|
||||
action: JSON.parse(
|
||||
JSON.stringify(screen.content.primary_button.action)
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
|
||||
getMessagesStub.returns(TEST_MESSAGES);
|
||||
await ASRouter._updateMessageProviders();
|
||||
await ASRouter.loadMessagesFromAllProviders(
|
||||
ASRouter.state.providers.filter(p => p.id === "onboarding")
|
||||
);
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
{
|
||||
|
@ -539,6 +541,8 @@ add_task(async function feature_callout_advance_tour_on_page_click() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
info("Clicking page button");
|
||||
document.querySelector(".brand-logo").click();
|
||||
|
@ -559,15 +563,12 @@ add_task(async function feature_callout_advance_tour_on_page_click() {
|
|||
);
|
||||
|
||||
sandbox.restore();
|
||||
await ASRouter._updateMessageProviders();
|
||||
await ASRouter.loadMessagesFromAllProviders(
|
||||
ASRouter.state.providers.filter(p => p.id === "onboarding")
|
||||
);
|
||||
ASRouter.resetMessageState();
|
||||
});
|
||||
|
||||
add_task(async function feature_callout_dismiss_on_escape() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, `{"message":"","screen":"","complete":true}`]],
|
||||
set: [[featureTourPref, `{"screen":"","complete":true}`]],
|
||||
});
|
||||
const screenId = "FIREFOX_VIEW_TAB_PICKUP_REMINDER";
|
||||
let testMessage = getCalloutMessageById(screenId);
|
||||
|
@ -582,6 +583,8 @@ add_task(async function feature_callout_dismiss_on_escape() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
info("Waiting for callout to render");
|
||||
await waitForCalloutScreen(document, screenId);
|
||||
|
||||
|
@ -635,6 +638,8 @@ add_task(async function test_firefox_view_spotlight_promo() {
|
|||
url: "about:firefoxview",
|
||||
},
|
||||
async browser => {
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
info("Waiting for the Fx View Spotlight promo to open");
|
||||
let dialogBrowser = await dialogOpenPromise;
|
||||
let primaryBtnSelector = ".action-buttons button.primary";
|
||||
|
@ -646,13 +651,6 @@ add_task(async function test_firefox_view_spotlight_promo() {
|
|||
dialogBrowser.document.querySelector(primaryBtnSelector).click();
|
||||
info("Fx View Spotlight promo clicked");
|
||||
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
() =>
|
||||
browser.contentWindow.performance.navigation.type ==
|
||||
browser.contentWindow.performance.navigation.TYPE_RELOAD
|
||||
);
|
||||
info("Spotlight modal cleared, entering feature tour");
|
||||
|
||||
const { document } = browser.contentWindow;
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
ok(
|
||||
|
@ -666,13 +664,15 @@ add_task(async function test_firefox_view_spotlight_promo() {
|
|||
|
||||
ok(remoteSettingsStub.called, "Tried to load CFR messages");
|
||||
sandbox.restore();
|
||||
await SpecialPowers.popPrefEnv();
|
||||
ASRouter.resetMessageState();
|
||||
});
|
||||
|
||||
add_task(async function feature_callout_returns_default_fxview_focus_to_top() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -682,6 +682,9 @@ add_task(async function feature_callout_returns_default_fxview_focus_to_top() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
|
||||
ok(
|
||||
|
@ -699,6 +702,8 @@ add_task(async function feature_callout_returns_default_fxview_focus_to_top() {
|
|||
}
|
||||
);
|
||||
sandbox.restore();
|
||||
await SpecialPowers.popPrefEnv();
|
||||
ASRouter.resetMessageState();
|
||||
});
|
||||
|
||||
add_task(
|
||||
|
@ -715,6 +720,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(
|
||||
document,
|
||||
"FIREFOX_VIEW_TAB_PICKUP_REMINDER"
|
||||
|
@ -742,9 +750,10 @@ add_task(
|
|||
);
|
||||
|
||||
add_task(async function feature_callout_does_not_display_arrow_if_hidden() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].content.hide_arrow = true;
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -754,6 +763,11 @@ add_task(async function feature_callout_does_not_display_arrow_if_hidden() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
|
||||
ok(
|
||||
getComputedStyle(
|
||||
document.querySelector(".callout-arrow"),
|
||||
|
|
|
@ -5,14 +5,18 @@
|
|||
|
||||
requestLongerTimeout(2);
|
||||
|
||||
const featureTourPref = "browser.firefox-view.feature-tour";
|
||||
const defaultPrefValue = getPrefValueByScreen(1);
|
||||
|
||||
const arrowWidth = 12;
|
||||
const arrowHeight = Math.hypot(arrowWidth, arrowWidth);
|
||||
let overlap = 5 - arrowHeight;
|
||||
|
||||
add_task(
|
||||
async function feature_callout_first_screen_positioned_below_element() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -22,6 +26,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
let parentBottom = document
|
||||
.querySelector("#tab-pickup-container")
|
||||
|
@ -30,10 +37,11 @@ add_task(
|
|||
.querySelector(calloutSelector)
|
||||
.getBoundingClientRect().top;
|
||||
|
||||
Assert.lessOrEqual(
|
||||
parentBottom,
|
||||
containerTop + 5 + 1, // Add 5px for overlap and 1px for fuzziness to account for possible subpixel rounding
|
||||
"Feature Callout is positioned below parent element with 5px overlap"
|
||||
isfuzzy(
|
||||
parentBottom - containerTop,
|
||||
overlap,
|
||||
1, // add 1px fuzziness to account for possible subpixel rounding
|
||||
"Feature Callout is positioned below parent element with the arrow overlapping by 5px"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -42,11 +50,13 @@ add_task(
|
|||
);
|
||||
|
||||
add_task(
|
||||
async function feature_callout_second_screen_positioned_left_of_element() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
|
||||
);
|
||||
testMessage.message.content.screens[1].content.arrow_position = "end";
|
||||
async function feature_callout_second_screen_positioned_right_of_element() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, getPrefValueByScreen(2)]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[1].content.arrow_position = "start";
|
||||
testMessage.message.content.screens[1].parent_selector = ".brand-logo";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -56,20 +66,22 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
const parent = document.querySelector(
|
||||
"#recently-closed-tabs-container"
|
||||
);
|
||||
parent.style.gridArea = "1/2";
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
|
||||
let parentLeft = parent.getBoundingClientRect().left;
|
||||
let containerRight = document
|
||||
.querySelector(calloutSelector)
|
||||
.getBoundingClientRect().right;
|
||||
|
||||
Assert.greaterOrEqual(
|
||||
parentLeft,
|
||||
containerRight - 5 - 1, // Subtract 5px for overlap and 1px for fuzziness to account for possible subpixel rounding
|
||||
"Feature Callout is positioned left of parent element with 5px overlap"
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
|
||||
|
||||
let parentRight = document
|
||||
.querySelector(".brand-logo")
|
||||
.getBoundingClientRect().right;
|
||||
let containerLeft = document
|
||||
.querySelector(calloutSelector)
|
||||
.getBoundingClientRect().left;
|
||||
isfuzzy(
|
||||
parentRight - containerLeft,
|
||||
overlap,
|
||||
1,
|
||||
"Feature Callout is positioned right of parent element with the arrow overlapping by 5px"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -79,9 +91,10 @@ add_task(
|
|||
|
||||
add_task(
|
||||
async function feature_callout_second_screen_positioned_above_element() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, getPrefValueByScreen(2)]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -91,6 +104,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
|
||||
let parentTop = document
|
||||
.querySelector("#recently-closed-tabs-container")
|
||||
|
@ -116,12 +132,11 @@ add_task(
|
|||
set: [
|
||||
// Set layout direction to right to left
|
||||
["intl.l10n.pseudo", "bidi"],
|
||||
[featureTourPref, getPrefValueByScreen(2)],
|
||||
],
|
||||
});
|
||||
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -131,6 +146,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
const parent = document.querySelector(
|
||||
"#recently-closed-tabs-container"
|
||||
);
|
||||
|
@ -156,9 +174,10 @@ add_task(
|
|||
|
||||
add_task(
|
||||
async function feature_callout_is_repositioned_if_parent_container_is_toggled() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -168,6 +187,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
const parentEl = document.querySelector("#tab-pickup-container");
|
||||
const calloutStartingTopPosition =
|
||||
|
@ -196,9 +218,10 @@ add_task(
|
|||
|
||||
// This test should be moved into a surface agnostic test suite with bug 1793656.
|
||||
add_task(async function feature_callout_top_end_positioning() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].content.arrow_position = "top-end";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
|
@ -209,6 +232,9 @@ add_task(async function feature_callout_top_end_positioning() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
let parent = document.querySelector("#tab-pickup-container");
|
||||
let container = document.querySelector(calloutSelector);
|
||||
|
@ -234,9 +260,10 @@ add_task(async function feature_callout_top_end_positioning() {
|
|||
|
||||
// This test should be moved into a surface agnostic test suite with bug 1793656.
|
||||
add_task(async function feature_callout_top_start_positioning() {
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].content.arrow_position = "top-start";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
|
@ -247,6 +274,9 @@ add_task(async function feature_callout_top_start_positioning() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
let parent = document.querySelector("#tab-pickup-container");
|
||||
let container = document.querySelector(calloutSelector);
|
||||
|
@ -277,12 +307,11 @@ add_task(
|
|||
set: [
|
||||
// Set layout direction to right to left
|
||||
["intl.l10n.pseudo", "bidi"],
|
||||
[featureTourPref, defaultPrefValue],
|
||||
],
|
||||
});
|
||||
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
testMessage.message.content.screens[0].content.arrow_position = "top-end";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
|
@ -293,6 +322,9 @@ add_task(
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
let parent = document.querySelector("#tab-pickup-container");
|
||||
let container = document.querySelector(calloutSelector);
|
||||
|
@ -321,7 +353,7 @@ add_task(
|
|||
add_task(async function feature_callout_is_larger_than_its_parent() {
|
||||
let testMessage = {
|
||||
message: {
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS",
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR",
|
||||
|
@ -359,7 +391,7 @@ add_task(async function feature_callout_is_larger_than_its_parent() {
|
|||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[featureTourPref, getPrefValueByScreen(1)]],
|
||||
set: [[featureTourPref, defaultPrefValue]],
|
||||
});
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -369,6 +401,9 @@ add_task(async function feature_callout_is_larger_than_its_parent() {
|
|||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
let parent = document.querySelector(".brand-icon");
|
||||
let container = document.querySelector(calloutSelector);
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
|
||||
"use strict";
|
||||
|
||||
const featureTourPref = "browser.firefox-view.feature-tour";
|
||||
function getArrowPosition(doc) {
|
||||
let callout = doc.querySelector(calloutSelector);
|
||||
return [...callout.classList].find(c => c.startsWith("arrow"));
|
||||
}
|
||||
|
||||
add_setup(async function setup() {
|
||||
let originalWidth = window.outerWidth;
|
||||
|
@ -25,9 +28,7 @@ add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() {
|
|||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["browser.sessionstore.max_tabs_undo", 1]],
|
||||
});
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -35,24 +36,42 @@ add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
browser.contentWindow.resizeTo(1550, 1000);
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
ok(
|
||||
document.querySelector(`${calloutSelector}.arrow-top`),
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
if (getArrowPosition(document) === "arrow-top") {
|
||||
return true;
|
||||
}
|
||||
browser.contentWindow.resizeTo(1550, 1000);
|
||||
return false;
|
||||
});
|
||||
is(
|
||||
getArrowPosition(document),
|
||||
"arrow-top",
|
||||
"On first screen at 1550x1000, the callout is positioned below the parent element"
|
||||
);
|
||||
|
||||
let startingTop = document.querySelector(calloutSelector).style.top;
|
||||
browser.contentWindow.resizeTo(1600, 400);
|
||||
browser.contentWindow.resizeTo(1800, 400);
|
||||
// Wait for callout to be repositioned
|
||||
await BrowserTestUtils.waitForMutationCondition(
|
||||
document.querySelector(calloutSelector),
|
||||
{ attributeFilter: ["style"], attributes: true },
|
||||
() => document.querySelector(calloutSelector).style.top != startingTop
|
||||
);
|
||||
ok(
|
||||
document.querySelector(`${calloutSelector}.arrow-inline-start`),
|
||||
"On first screen at 1600x400, the callout is positioned to the right of the parent element"
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
if (getArrowPosition(document) === "arrow-inline-start") {
|
||||
return true;
|
||||
}
|
||||
browser.contentWindow.resizeTo(1800, 400);
|
||||
return false;
|
||||
});
|
||||
is(
|
||||
getArrowPosition(document),
|
||||
"arrow-inline-start",
|
||||
"On first screen at 1800x400, the callout is positioned to the right of the parent element"
|
||||
);
|
||||
|
||||
startingTop = document.querySelector(calloutSelector).style.top;
|
||||
|
@ -62,8 +81,16 @@ add_task(async function feature_callout_is_repositioned_if_it_does_not_fit() {
|
|||
{ attributeFilter: ["style"], attributes: true },
|
||||
() => document.querySelector(calloutSelector).style.top != startingTop
|
||||
);
|
||||
ok(
|
||||
document.querySelector(`${calloutSelector}.arrow-top`),
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
if (getArrowPosition(document) === "arrow-top") {
|
||||
return true;
|
||||
}
|
||||
browser.contentWindow.resizeTo(1100, 600);
|
||||
return false;
|
||||
});
|
||||
is(
|
||||
getArrowPosition(document),
|
||||
"arrow-top",
|
||||
"On first screen at 1100x600, the callout is positioned below the parent element"
|
||||
);
|
||||
}
|
||||
|
@ -80,9 +107,7 @@ add_task(async function feature_callout_is_repositioned_rtl() {
|
|||
],
|
||||
});
|
||||
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS"
|
||||
);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage);
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
|
@ -90,24 +115,42 @@ add_task(async function feature_callout_is_repositioned_rtl() {
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
browser.contentWindow.resizeTo(1550, 1000);
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
ok(
|
||||
document.querySelector(`${calloutSelector}.arrow-top`),
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
if (getArrowPosition(document) === "arrow-top") {
|
||||
return true;
|
||||
}
|
||||
browser.contentWindow.resizeTo(1550, 1000);
|
||||
return false;
|
||||
});
|
||||
is(
|
||||
getArrowPosition(document),
|
||||
"arrow-top",
|
||||
"On first screen at 1550x1000, the callout is positioned below the parent element"
|
||||
);
|
||||
|
||||
let startingTop = document.querySelector(calloutSelector).style.top;
|
||||
browser.contentWindow.resizeTo(1600, 400);
|
||||
browser.contentWindow.resizeTo(1800, 400);
|
||||
// Wait for callout to be repositioned
|
||||
await BrowserTestUtils.waitForMutationCondition(
|
||||
document.querySelector(calloutSelector),
|
||||
{ attributeFilter: ["style"], attributes: true },
|
||||
() => document.querySelector(calloutSelector).style.top != startingTop
|
||||
);
|
||||
ok(
|
||||
document.querySelector(`${calloutSelector}.arrow-inline-end`),
|
||||
"On first screen at 1600x400, the callout is positioned to the right of the parent element"
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
if (getArrowPosition(document) === "arrow-inline-end") {
|
||||
return true;
|
||||
}
|
||||
browser.contentWindow.resizeTo(1800, 400);
|
||||
return false;
|
||||
});
|
||||
is(
|
||||
getArrowPosition(document),
|
||||
"arrow-inline-end",
|
||||
"On first screen at 1800x400, the callout is positioned to the right of the parent element"
|
||||
);
|
||||
|
||||
startingTop = document.querySelector(calloutSelector).style.top;
|
||||
|
@ -117,8 +160,16 @@ add_task(async function feature_callout_is_repositioned_rtl() {
|
|||
{ attributeFilter: ["style"], attributes: true },
|
||||
() => document.querySelector(calloutSelector).style.top != startingTop
|
||||
);
|
||||
ok(
|
||||
document.querySelector(`${calloutSelector}.arrow-top`),
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
if (getArrowPosition(document) === "arrow-top") {
|
||||
return true;
|
||||
}
|
||||
browser.contentWindow.resizeTo(1100, 600);
|
||||
return false;
|
||||
});
|
||||
is(
|
||||
getArrowPosition(document),
|
||||
"arrow-top",
|
||||
"On first screen at 1100x600, the callout is positioned below the parent element"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -30,6 +30,8 @@ add_task(
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(
|
||||
document,
|
||||
"FIREFOX_VIEW_TAB_PICKUP_REMINDER"
|
||||
|
@ -81,6 +83,8 @@ add_task(
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(
|
||||
document,
|
||||
"FIREFOX_VIEW_TAB_PICKUP_REMINDER"
|
||||
|
@ -132,6 +136,8 @@ add_task(
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
ok(
|
||||
!document.querySelector(".featureCallout"),
|
||||
"Tab Pickup reminder should not be displayed when the Spotlight message introducing the tour was viewed less than 24 hours ago."
|
||||
|
@ -153,6 +159,8 @@ add_task(
|
|||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
|
||||
launchFeatureTourIn(browser.contentWindow);
|
||||
|
||||
await waitForCalloutScreen(
|
||||
document,
|
||||
"FIREFOX_VIEW_TAB_PICKUP_REMINDER"
|
||||
|
|
|
@ -9,16 +9,14 @@ const { FeatureCallout } = ChromeUtils.importESModule(
|
|||
|
||||
async function testCallout(config) {
|
||||
const featureCallout = new FeatureCallout(config);
|
||||
const testMessage = getCalloutMessageById(
|
||||
"FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS"
|
||||
);
|
||||
const screen = testMessage.message.content.screens.find(s => s.id);
|
||||
const testMessage = getCalloutMessageById("FIREFOX_VIEW_FEATURE_TOUR");
|
||||
const screen = testMessage.message.content.screens[1];
|
||||
screen.parent_selector = "body";
|
||||
const sandbox = createSandboxWithCalloutTriggerStub(testMessage, config.page);
|
||||
featureCallout.showFeatureCallout();
|
||||
testMessage.message.content.screens = [screen];
|
||||
featureCallout.showFeatureCallout(testMessage.message);
|
||||
await waitForCalloutScreen(config.win.document, screen.id);
|
||||
testStyles(config.win);
|
||||
return { featureCallout, sandbox };
|
||||
return { featureCallout };
|
||||
}
|
||||
|
||||
function testStyles(win) {
|
||||
|
@ -36,28 +34,26 @@ function testStyles(win) {
|
|||
|
||||
add_task(async function feature_callout_chrome_theme() {
|
||||
const win = await BrowserTestUtils.openNewBrowserWindow();
|
||||
const { sandbox } = await testCallout({
|
||||
await testCallout({
|
||||
win,
|
||||
location: "chrome",
|
||||
context: "chrome",
|
||||
browser: win.gBrowser.selectedBrowser,
|
||||
prefName: "fakepref",
|
||||
page: "chrome",
|
||||
theme: { preset: "chrome" },
|
||||
});
|
||||
await BrowserTestUtils.closeWindow(win);
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
add_task(async function feature_callout_pdfjs_theme() {
|
||||
const win = await BrowserTestUtils.openNewBrowserWindow();
|
||||
const { sandbox } = await testCallout({
|
||||
await testCallout({
|
||||
win,
|
||||
location: "pdfjs",
|
||||
context: "chrome",
|
||||
browser: win.gBrowser.selectedBrowser,
|
||||
prefName: "fakepref",
|
||||
page: "chrome",
|
||||
theme: { preset: "pdfjs", simulateContent: true },
|
||||
});
|
||||
await BrowserTestUtils.closeWindow(win);
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
add_task(async function feature_callout_content_theme() {
|
||||
|
@ -66,14 +62,12 @@ add_task(async function feature_callout_content_theme() {
|
|||
gBrowser,
|
||||
url: "about:firefoxview",
|
||||
},
|
||||
async browser => {
|
||||
const { sandbox } = await testCallout({
|
||||
browser =>
|
||||
testCallout({
|
||||
win: browser.contentWindow,
|
||||
prefName: "fakepref",
|
||||
page: "about:firefoxview",
|
||||
location: "about:firefoxview",
|
||||
context: "content",
|
||||
theme: { preset: "themed-content" },
|
||||
});
|
||||
sandbox.restore();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
|
@ -29,19 +29,6 @@ add_task(async function test_keyboard_focus_after_tab_pickup_opened() {
|
|||
"browser.tabs.firefox-view.ui-state.tab-pickup.open"
|
||||
);
|
||||
|
||||
// make sure the feature tour doesn't get in the way
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
[
|
||||
"browser.firefox-view.feature-tour",
|
||||
JSON.stringify({
|
||||
screen: `FEATURE_CALLOUT_1`,
|
||||
complete: true,
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
// Let's be deterministic about the basic UI state!
|
||||
const sandbox = setupMocks({
|
||||
state: UIState.STATUS_NOT_CONFIGURED,
|
||||
|
|
|
@ -1,55 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Tests that are related to the accessibility of the feature callout
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ensure feature tour is accessible using a screen reader and with
|
||||
* keyboard navigation.
|
||||
*/
|
||||
add_task(async function feature_callout_is_accessible() {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [["browser.firefox-view.feature-tour", getPrefValueByScreen(1)]],
|
||||
});
|
||||
|
||||
await BrowserTestUtils.withNewTab(
|
||||
{
|
||||
gBrowser,
|
||||
url: "about:firefoxview",
|
||||
},
|
||||
async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_1");
|
||||
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
() => document.activeElement.value === "primary_button",
|
||||
`Feature Callout primary button is focused on page load}`
|
||||
);
|
||||
ok(true, "Feature Callout primary button was focused on page load");
|
||||
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
() =>
|
||||
document.querySelector(
|
||||
`${calloutSelector}[aria-describedby="#${calloutId} .welcome-text"]`
|
||||
),
|
||||
"The callout container has an aria-describedby value equal to the screen welcome text"
|
||||
);
|
||||
ok(true, "The callout container has the correct aria-describedby value");
|
||||
|
||||
// Advance to second screen
|
||||
clickPrimaryButton(document);
|
||||
await waitForCalloutScreen(document, "FEATURE_CALLOUT_2");
|
||||
|
||||
ok(true, "FEATURE_CALLOUT_2 was successfully displayed");
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
() => document.activeElement.value == "primary_button",
|
||||
"Feature Callout primary button is focused after advancing screens"
|
||||
);
|
||||
ok(true, "Feature Callout primary button was successfully focused");
|
||||
}
|
||||
);
|
||||
});
|
|
@ -67,18 +67,6 @@ add_setup(async function () {
|
|||
|
||||
add_task(async function test_unconfigured_initial_state() {
|
||||
await clearAllParentTelemetryEvents();
|
||||
// test with the pref set to show FEATURE TOUR CALLOUT
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [
|
||||
[
|
||||
"browser.firefox-view.feature-tour",
|
||||
JSON.stringify({
|
||||
screen: `FEATURE_CALLOUT_1`,
|
||||
complete: false,
|
||||
}),
|
||||
],
|
||||
],
|
||||
});
|
||||
const sandbox = setupMocks({
|
||||
state: UIState.STATUS_NOT_CONFIGURED,
|
||||
syncEnabled: false,
|
||||
|
@ -396,7 +384,6 @@ add_task(async function test_tab_sync_enabled() {
|
|||
mobilePromo: false,
|
||||
mobileConfirmation: false,
|
||||
});
|
||||
await waitForElementVisible(browser, ".featureCallout .FEATURE_CALLOUT_1");
|
||||
ok(true, "Tab pickup product tour screen renders when sync is enabled");
|
||||
ok(
|
||||
Services.prefs.getBoolPref("services.sync.engine.tabs", false),
|
||||
|
|
|
@ -345,6 +345,22 @@ async function tearDown(sandbox) {
|
|||
Services.prefs.clearUserPref(MOBILE_PROMO_DISMISSED_PREF);
|
||||
}
|
||||
|
||||
const featureTourPref = "browser.firefox-view.feature-tour";
|
||||
const launchFeatureTourIn = win => {
|
||||
const { FeatureCallout } = ChromeUtils.importESModule(
|
||||
"resource:///modules/FeatureCallout.sys.mjs"
|
||||
);
|
||||
let callout = new FeatureCallout({
|
||||
win,
|
||||
pref: { name: featureTourPref },
|
||||
location: "about:firefoxview",
|
||||
context: "content",
|
||||
theme: { preset: "themed-content" },
|
||||
});
|
||||
callout.showFeatureCallout();
|
||||
return callout;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a value that can be used to set
|
||||
* `browser.firefox-view.feature-tour` to change the feature tour's
|
||||
|
|
|
@ -15,6 +15,8 @@ const lazy = {};
|
|||
ChromeUtils.defineESModuleGetters(lazy, {
|
||||
Downloader: "resource://services-settings/Attachments.sys.mjs",
|
||||
ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
|
||||
FeatureCalloutBroker:
|
||||
"resource://activity-stream/lib/FeatureCalloutBroker.sys.mjs",
|
||||
KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs",
|
||||
MacAttribution: "resource:///modules/MacAttribution.sys.mjs",
|
||||
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
|
||||
|
@ -1409,6 +1411,19 @@ class _ASRouter {
|
|||
this.dispatchCFRAction
|
||||
);
|
||||
break;
|
||||
case "feature_callout":
|
||||
// featureCalloutCheck only comes from within FeatureCallout, where it
|
||||
// is used to request a matching message. It is not a real trigger.
|
||||
// pdfJsFeatureCalloutCheck is used for PDF.js feature callouts, which
|
||||
// are managed by the trigger listener itself.
|
||||
switch (trigger.id) {
|
||||
case "featureCalloutCheck":
|
||||
case "pdfJsFeatureCalloutCheck":
|
||||
break;
|
||||
default:
|
||||
lazy.FeatureCalloutBroker.showFeatureCallout(browser, message);
|
||||
}
|
||||
break;
|
||||
case "toast_notification":
|
||||
lazy.ToastNotification.showToastNotification(
|
||||
message,
|
||||
|
|
|
@ -13,6 +13,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
|
|||
AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
|
||||
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
|
||||
EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
|
||||
FeatureCalloutBroker:
|
||||
"resource://activity-stream/lib/FeatureCalloutBroker.sys.mjs",
|
||||
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
|
||||
clearTimeout: "resource://gre/modules/Timer.sys.mjs",
|
||||
setTimeout: "resource://gre/modules/Timer.sys.mjs",
|
||||
|
@ -350,13 +352,11 @@ const ASRouterTriggerListeners = new Map([
|
|||
this.id,
|
||||
win => {
|
||||
if (!isPrivateWindow(win)) {
|
||||
win.addEventListener("TabSelect", this.onTabSwitch);
|
||||
win.gBrowser.addTabsProgressListener(this);
|
||||
}
|
||||
},
|
||||
win => {
|
||||
if (!isPrivateWindow(win)) {
|
||||
win.removeEventListener("TabSelect", this.onTabSwitch);
|
||||
win.gBrowser.removeTabsProgressListener(this);
|
||||
}
|
||||
}
|
||||
|
@ -1079,6 +1079,156 @@ const ASRouterTriggerListeners = new Map([
|
|||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"pdfJsFeatureCalloutCheck",
|
||||
{
|
||||
id: "pdfJsFeatureCalloutCheck",
|
||||
_initialized: false,
|
||||
_triggerHandler: null,
|
||||
_callouts: new WeakMap(),
|
||||
|
||||
init(triggerHandler) {
|
||||
if (!this._initialized) {
|
||||
this.onLocationChange = this.onLocationChange.bind(this);
|
||||
this.onStateChange = this.onLocationChange;
|
||||
lazy.EveryWindow.registerCallback(
|
||||
this.id,
|
||||
win => {
|
||||
this.onBrowserWindow(win);
|
||||
win.addEventListener("TabSelect", this);
|
||||
win.addEventListener("TabClose", this);
|
||||
win.addEventListener("SSTabRestored", this);
|
||||
win.gBrowser.addTabsProgressListener(this);
|
||||
},
|
||||
win => {
|
||||
win.removeEventListener("TabSelect", this);
|
||||
win.removeEventListener("TabClose", this);
|
||||
win.removeEventListener("SSTabRestored", this);
|
||||
win.gBrowser.removeTabsProgressListener(this);
|
||||
}
|
||||
);
|
||||
this._initialized = true;
|
||||
}
|
||||
this._triggerHandler = triggerHandler;
|
||||
},
|
||||
|
||||
uninit() {
|
||||
if (this._initialized) {
|
||||
lazy.EveryWindow.unregisterCallback(this.id);
|
||||
this._initialized = false;
|
||||
this._triggerHandler = null;
|
||||
for (let key of ChromeUtils.nondeterministicGetWeakMapKeys(
|
||||
this._callouts
|
||||
)) {
|
||||
const item = this._callouts.get(key);
|
||||
if (item) {
|
||||
item.callout.endTour(true);
|
||||
item.cleanup();
|
||||
this._callouts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async showFeatureCalloutTour(win, browser, panelId, context) {
|
||||
const result = await this._triggerHandler(browser, {
|
||||
id: "pdfJsFeatureCalloutCheck",
|
||||
context,
|
||||
});
|
||||
if (result.message.trigger) {
|
||||
const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout(
|
||||
{
|
||||
win,
|
||||
browser,
|
||||
pref: { name: "browser.pdfjs.feature-tour" },
|
||||
location: "pdfjs",
|
||||
theme: { preset: "pdfjs", simulateContent: true },
|
||||
cleanup: () => {
|
||||
this._callouts.delete(win);
|
||||
},
|
||||
},
|
||||
result.message
|
||||
);
|
||||
callout.panelId = panelId;
|
||||
this._callouts.set(win, callout);
|
||||
}
|
||||
},
|
||||
|
||||
onLocationChange(browser) {
|
||||
const tabbrowser = browser.getTabBrowser();
|
||||
if (browser !== tabbrowser.selectedBrowser) {
|
||||
return;
|
||||
}
|
||||
const win = tabbrowser.ownerGlobal;
|
||||
const tab = tabbrowser.selectedTab;
|
||||
const existingCallout = this._callouts.get(win);
|
||||
const isPDFJS =
|
||||
browser.contentPrincipal.originNoSuffix === "resource://pdf.js";
|
||||
if (
|
||||
existingCallout &&
|
||||
(existingCallout.panelId !== tab.linkedPanel || !isPDFJS)
|
||||
) {
|
||||
existingCallout.callout.endTour(true);
|
||||
existingCallout.cleanup();
|
||||
}
|
||||
if (!this._callouts.has(win) && isPDFJS) {
|
||||
this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
|
||||
source: "open",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleEvent(event) {
|
||||
const tab = event.target;
|
||||
const win = tab.ownerGlobal;
|
||||
const { gBrowser } = win;
|
||||
if (!gBrowser) {
|
||||
return;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "SSTabRestored":
|
||||
if (tab !== gBrowser.selectedTab) {
|
||||
return;
|
||||
}
|
||||
// fall through
|
||||
case "TabSelect": {
|
||||
const browser = gBrowser.getBrowserForTab(tab);
|
||||
const existingCallout = this._callouts.get(win);
|
||||
const isPDFJS =
|
||||
browser.contentPrincipal.originNoSuffix === "resource://pdf.js";
|
||||
if (
|
||||
existingCallout &&
|
||||
(existingCallout.panelId !== tab.linkedPanel || !isPDFJS)
|
||||
) {
|
||||
existingCallout.callout.endTour(true);
|
||||
existingCallout.cleanup();
|
||||
}
|
||||
if (!this._callouts.has(win) && isPDFJS) {
|
||||
this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
|
||||
source: "open",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TabClose": {
|
||||
const existingCallout = this._callouts.get(win);
|
||||
if (
|
||||
existingCallout &&
|
||||
existingCallout.panelId === tab.linkedPanel
|
||||
) {
|
||||
existingCallout.callout.endTour(true);
|
||||
existingCallout.cleanup();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onBrowserWindow(win) {
|
||||
this.onLocationChange(win.gBrowser.selectedBrowser);
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const EXPORTED_SYMBOLS = ["ASRouterTriggerListeners"];
|
||||
|
|
|
@ -0,0 +1,197 @@
|
|||
/* 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/. */
|
||||
|
||||
const lazy = {};
|
||||
|
||||
ChromeUtils.defineESModuleGetters(lazy, {
|
||||
FeatureCallout: "resource:///modules/FeatureCallout.sys.mjs",
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} FeatureCalloutOptions
|
||||
* @property {Window} win window in which messages will be rendered.
|
||||
* @property {{name: String, defaultValue?: String}} [pref] optional pref used
|
||||
* to track progress through a given feature tour. for example:
|
||||
* {
|
||||
* name: "browser.pdfjs.feature-tour",
|
||||
* defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
|
||||
* }
|
||||
* or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
|
||||
* @property {String} [location] string to pass as the page when requesting
|
||||
* messages from ASRouter and sending telemetry.
|
||||
* @property {MozBrowser} [browser] <browser> element responsible for the
|
||||
* feature callout. for content pages, this is the browser element that the
|
||||
* callout is being shown in. for chrome, this is the active browser.
|
||||
* @property {Function} [cleanup] callback to be invoked when the callout is
|
||||
* removed or the window is unloaded.
|
||||
* @property {FeatureCalloutTheme} [theme] optional dynamic color theme.
|
||||
*/
|
||||
|
||||
/** @typedef {import("resource:///modules/FeatureCallout.sys.mjs").FeatureCalloutTheme} FeatureCalloutTheme */
|
||||
|
||||
/**
|
||||
* @typedef {Object} FeatureCalloutItem
|
||||
* @property {lazy.FeatureCallout} callout instance of FeatureCallout.
|
||||
* @property {Function} [cleanup] cleanup callback.
|
||||
* @property {Boolean} showing whether the callout is currently showing.
|
||||
*/
|
||||
|
||||
export class _FeatureCalloutBroker {
|
||||
/**
|
||||
* Make a new FeatureCallout instance and store it in the callout map. Also
|
||||
* add an unload listener to the window to clean up the callout when the
|
||||
* window is unloaded.
|
||||
* @param {FeatureCalloutOptions} config
|
||||
*/
|
||||
makeFeatureCallout(config) {
|
||||
const { win, pref, location, browser, theme } = config;
|
||||
// Use an AbortController to clean up the unload listener in case the
|
||||
// callout is cleaned up before the window is unloaded.
|
||||
const controller = new AbortController();
|
||||
const cleanup = () => {
|
||||
this.#calloutMap.delete(win);
|
||||
controller.abort();
|
||||
config.cleanup?.();
|
||||
};
|
||||
this.#calloutMap.set(win, {
|
||||
callout: new lazy.FeatureCallout({
|
||||
win,
|
||||
pref,
|
||||
location,
|
||||
context: "chrome",
|
||||
browser,
|
||||
listener: this.handleFeatureCalloutCallback.bind(this),
|
||||
theme,
|
||||
}),
|
||||
cleanup,
|
||||
showing: false,
|
||||
});
|
||||
win.addEventListener("unload", cleanup, { signal: controller.signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a feature callout message. For use by ASRouter, to be invoked when a
|
||||
* trigger has matched to a feature_callout message.
|
||||
* @param {MozBrowser} browser <browser> element associated with the trigger.
|
||||
* @param {Object} message feature_callout message from ASRouter.
|
||||
* @see {@link FeatureCalloutMessages.sys.mjs}
|
||||
* @returns {Promise<Boolean>} whether the callout was shown.
|
||||
*/
|
||||
async showFeatureCallout(browser, message) {
|
||||
// Only show one callout at a time, across all windows.
|
||||
if (this.isCalloutShowing) {
|
||||
return false;
|
||||
}
|
||||
const win = browser.ownerGlobal;
|
||||
const currentCallout = this.#calloutMap.get(win);
|
||||
// If a custom callout was previously showing, but is no longer showing,
|
||||
// tear down the FeatureCallout instance. We avoid tearing them down when
|
||||
// they stop showing because they may be shown again, and we want to avoid
|
||||
// the overhead of creating a new FeatureCallout instance. But the custom
|
||||
// callout instance may be incompatible with the new ASRouter message, so
|
||||
// we tear it down and create a new one.
|
||||
if (currentCallout && currentCallout.callout.location !== "chrome") {
|
||||
currentCallout.cleanup();
|
||||
}
|
||||
let item = this.#calloutMap.get(win);
|
||||
let callout = item?.callout;
|
||||
if (item) {
|
||||
// If a callout previously showed in this instance, but the new message's
|
||||
// tour_pref_name is different, update the old instance's tour properties.
|
||||
callout.teardownFeatureTourProgress();
|
||||
if (message.content.tour_pref_name) {
|
||||
callout.pref = {
|
||||
name: message.content.tour_pref_name,
|
||||
defaultValue: message.content.tour_pref_default_value,
|
||||
};
|
||||
callout.setupFeatureTourProgress();
|
||||
} else {
|
||||
callout.pref = null;
|
||||
}
|
||||
} else {
|
||||
const options = {
|
||||
win,
|
||||
location: "chrome",
|
||||
browser,
|
||||
theme: { preset: "chrome" },
|
||||
};
|
||||
if (message.content.tour_pref_name) {
|
||||
options.pref = {
|
||||
name: message.content.tour_pref_name,
|
||||
defaultValue: message.content.tour_pref_default_value,
|
||||
};
|
||||
}
|
||||
this.makeFeatureCallout(options);
|
||||
item = this.#calloutMap.get(win);
|
||||
callout = item.callout;
|
||||
}
|
||||
// Set this to true for now so that we can't be interrupted by another
|
||||
// invocation. We'll set it to false below if it ended up not showing.
|
||||
item.showing = true;
|
||||
item.showing = await callout.showFeatureCallout(message);
|
||||
return item.showing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a new FeatureCallout instance specific to a special location, tearing
|
||||
* down the existing generic FeatureCallout if it exists, and (if no message
|
||||
* is passed) requesting a feature callout message to show. Does nothing if a
|
||||
* callout is already in progress. This allows the PDF.js feature tour, which
|
||||
* simulates content, to be shown in the chrome window without interfering
|
||||
* with chrome feature callouts.
|
||||
* @param {FeatureCalloutOptions} config
|
||||
* @param {Object} message feature_callout message from ASRouter.
|
||||
* @see {@link FeatureCalloutMessages.sys.mjs}
|
||||
* @returns {FeatureCalloutItem|null} the callout item, if one was created.
|
||||
*/
|
||||
showCustomFeatureCallout(config, message) {
|
||||
if (this.isCalloutShowing) {
|
||||
return null;
|
||||
}
|
||||
const { win, pref, location } = config;
|
||||
const currentCallout = this.#calloutMap.get(win);
|
||||
if (currentCallout && currentCallout.location !== location) {
|
||||
currentCallout.cleanup();
|
||||
}
|
||||
let item = this.#calloutMap.get(win);
|
||||
let callout = item?.callout;
|
||||
if (item) {
|
||||
callout.teardownFeatureTourProgress();
|
||||
callout.pref = pref;
|
||||
if (pref) {
|
||||
callout.setupFeatureTourProgress();
|
||||
}
|
||||
} else {
|
||||
this.makeFeatureCallout(config);
|
||||
item = this.#calloutMap.get(win);
|
||||
callout = item.callout;
|
||||
}
|
||||
item.showing = true;
|
||||
// In this case, callers are not necessarily async, so we don't await.
|
||||
callout.showFeatureCallout(message).then(showing => {
|
||||
item.showing = showing;
|
||||
});
|
||||
/** @type {FeatureCalloutItem} */
|
||||
return item;
|
||||
}
|
||||
|
||||
handleFeatureCalloutCallback(win, event, data) {
|
||||
switch (event) {
|
||||
case "end":
|
||||
const item = this.#calloutMap.get(win);
|
||||
if (item) item.showing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Boolean} whether a callout is currently showing. */
|
||||
get isCalloutShowing() {
|
||||
return [...this.#calloutMap.values()].some(({ showing }) => showing);
|
||||
}
|
||||
|
||||
/** @type {Map<Window, FeatureCalloutItem>} */
|
||||
#calloutMap = new Map();
|
||||
}
|
||||
|
||||
export const FeatureCalloutBroker = new _FeatureCalloutBroker();
|
|
@ -9,25 +9,32 @@ const FIREFOX_VIEW_PREF = "browser.firefox-view.feature-tour";
|
|||
const PDFJS_PREF = "browser.pdfjs.feature-tour";
|
||||
// Empty screens are included as placeholders to ensure step
|
||||
// indicator shows the correct number of total steps in the tour
|
||||
const EMPTY_SCREEN = { content: {} };
|
||||
const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Generate a JEXL targeting string based on the current screen
|
||||
// id found in a given Feature Callout tour progress preference
|
||||
// and the `complete` property being true
|
||||
const matchCurrentScreenTargeting = (prefName, screenId) => {
|
||||
// Generate a JEXL targeting string based on the `complete` property being true
|
||||
// in a given Feature Callout tour progress preference value (which is JSON).
|
||||
const matchIncompleteTargeting = (prefName, defaultValue = false) => {
|
||||
// regExpMatch() is a JEXL filter expression. Here we check if 'complete'
|
||||
// exists in the pref's value, and returns true if the tour is incomplete.
|
||||
const prefVal = `'${prefName}' | preferenceValue`;
|
||||
//regExpMatch() is a JEXL filter expression. Here we check if 'screen' and 'complete' exist in the pref's value (which is stringified JSON), and return their values. Returns null otherwise
|
||||
const screenRegEx = '(?<=screen":)"(.*)(?=",)';
|
||||
const completeRegEx = '(?<=complete":)(.*)(?=})';
|
||||
// prefVal might be null if the preference doesn't exist. in this case, don't
|
||||
// try to pipe into regExpMatch.
|
||||
const completeMatch = `${prefVal} | regExpMatch('(?<=complete":)(.*)(?=})')`;
|
||||
return `((${prefVal}) ? ((${completeMatch}) ? (${completeMatch}[1] != "true") : ${String(
|
||||
defaultValue
|
||||
)}) : ${String(defaultValue)})`;
|
||||
};
|
||||
|
||||
const screenMatch = `${prefVal} | regExpMatch('${screenRegEx}')`;
|
||||
const completeMatch = `${prefVal} | regExpMatch('${completeRegEx}')`;
|
||||
//We are checking the return of regExpMatch() here. If it's truthy, we grab the matched string and compare it to the desired value
|
||||
const screenVal = `(${screenMatch}) ? (${screenMatch}[1] == '${screenId}') : false`;
|
||||
const completeVal = `(${completeMatch}) ? (${completeMatch}[1] != "true") : false`;
|
||||
|
||||
return `(${screenVal}) && (${completeVal})`;
|
||||
// Generate a JEXL targeting string based on the current screen id found in a
|
||||
// given Feature Callout tour progress preference.
|
||||
const matchCurrentScreenTargeting = (prefName, screenIdRegEx = ".*") => {
|
||||
// regExpMatch() is a JEXL filter expression. Here we check if 'screen' exists
|
||||
// in the pref's value, and if it matches the screenIdRegEx. Returns
|
||||
// null otherwise.
|
||||
const prefVal = `'${prefName}' | preferenceValue`;
|
||||
const screenMatch = `${prefVal} | regExpMatch('(?<=screen"\s*:)\s*"(${screenIdRegEx})(?="\s*,)')`;
|
||||
const screenValMatches = `(${screenMatch}) ? !!(${screenMatch}[1]) : false`;
|
||||
return `(${screenValMatches})`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -84,6 +91,7 @@ const MESSAGES = () => {
|
|||
id: "FIREFOX_VIEW_PROMO",
|
||||
template: "multistage",
|
||||
modal: "tab",
|
||||
tour_pref_name: FIREFOX_VIEW_PREF,
|
||||
screens: [
|
||||
{
|
||||
id: "DEFAULT_MODAL_UI",
|
||||
|
@ -155,10 +163,10 @@ const MESSAGES = () => {
|
|||
${matchCurrentScreenTargeting(
|
||||
FIREFOX_VIEW_PREF,
|
||||
"FIREFOX_VIEW_SPOTLIGHT"
|
||||
)}`,
|
||||
)} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`,
|
||||
},
|
||||
{
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR_1_NO_CWS",
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR",
|
||||
|
@ -166,6 +174,7 @@ const MESSAGES = () => {
|
|||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
tour_pref_name: FIREFOX_VIEW_PREF,
|
||||
screens: [
|
||||
{
|
||||
id: "FEATURE_CALLOUT_1",
|
||||
|
@ -218,28 +227,6 @@ const MESSAGES = () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
EMPTY_SCREEN,
|
||||
],
|
||||
},
|
||||
priority: 3,
|
||||
targeting: `!inMr2022Holdback && source == "about:firefoxview" && ${matchCurrentScreenTargeting(
|
||||
FIREFOX_VIEW_PREF,
|
||||
"FEATURE_CALLOUT_1"
|
||||
)}`,
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
},
|
||||
{
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR_2_NO_CWS",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "FIREFOX_VIEW_FEATURE_TOUR",
|
||||
startScreen: 1,
|
||||
template: "multistage",
|
||||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
screens: [
|
||||
EMPTY_SCREEN,
|
||||
{
|
||||
id: "FEATURE_CALLOUT_2",
|
||||
parent_selector: "#recently-closed-tabs-container",
|
||||
|
@ -290,8 +277,8 @@ const MESSAGES = () => {
|
|||
priority: 3,
|
||||
targeting: `!inMr2022Holdback && source == "about:firefoxview" && ${matchCurrentScreenTargeting(
|
||||
FIREFOX_VIEW_PREF,
|
||||
"FEATURE_CALLOUT_2"
|
||||
)}`,
|
||||
"FEATURE_CALLOUT_[0-9]"
|
||||
)} && ${matchIncompleteTargeting(FIREFOX_VIEW_PREF)}`,
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
},
|
||||
{
|
||||
|
@ -355,7 +342,7 @@ const MESSAGES = () => {
|
|||
trigger: { id: "featureCalloutCheck" },
|
||||
},
|
||||
{
|
||||
id: "PDFJS_FEATURE_TOUR_1_A",
|
||||
id: "PDFJS_FEATURE_TOUR_A",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "PDFJS_FEATURE_TOUR",
|
||||
|
@ -363,6 +350,7 @@ const MESSAGES = () => {
|
|||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
tour_pref_name: PDFJS_PREF,
|
||||
screens: [
|
||||
{
|
||||
id: "FEATURE_CALLOUT_1_A",
|
||||
|
@ -413,28 +401,6 @@ const MESSAGES = () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
EMPTY_SCREEN,
|
||||
],
|
||||
},
|
||||
priority: 1,
|
||||
targeting: `source == "chrome" && ${matchCurrentScreenTargeting(
|
||||
PDFJS_PREF,
|
||||
"FEATURE_CALLOUT_1_A"
|
||||
)}`,
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
},
|
||||
{
|
||||
id: "PDFJS_FEATURE_TOUR_2_A",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "PDFJS_FEATURE_TOUR",
|
||||
startScreen: 1,
|
||||
template: "multistage",
|
||||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
screens: [
|
||||
EMPTY_SCREEN,
|
||||
{
|
||||
id: "FEATURE_CALLOUT_2_A",
|
||||
parent_selector: "hbox#browser",
|
||||
|
@ -487,14 +453,14 @@ const MESSAGES = () => {
|
|||
],
|
||||
},
|
||||
priority: 1,
|
||||
targeting: `source == "chrome" && ${matchCurrentScreenTargeting(
|
||||
targeting: `source == "open" && ${matchCurrentScreenTargeting(
|
||||
PDFJS_PREF,
|
||||
"FEATURE_CALLOUT_2_A"
|
||||
)}`,
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
"FEATURE_CALLOUT_[0-9]_A"
|
||||
)} && ${matchIncompleteTargeting(PDFJS_PREF)}`,
|
||||
trigger: { id: "pdfJsFeatureCalloutCheck" },
|
||||
},
|
||||
{
|
||||
id: "PDFJS_FEATURE_TOUR_1_B",
|
||||
id: "PDFJS_FEATURE_TOUR_B",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "PDFJS_FEATURE_TOUR",
|
||||
|
@ -502,6 +468,7 @@ const MESSAGES = () => {
|
|||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
tour_pref_name: PDFJS_PREF,
|
||||
screens: [
|
||||
{
|
||||
id: "FEATURE_CALLOUT_1_B",
|
||||
|
@ -552,28 +519,6 @@ const MESSAGES = () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
EMPTY_SCREEN,
|
||||
],
|
||||
},
|
||||
priority: 1,
|
||||
targeting: `source == "chrome" && ${matchCurrentScreenTargeting(
|
||||
PDFJS_PREF,
|
||||
"FEATURE_CALLOUT_1_B"
|
||||
)}`,
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
},
|
||||
{
|
||||
id: "PDFJS_FEATURE_TOUR_2_B",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "PDFJS_FEATURE_TOUR",
|
||||
startScreen: 1,
|
||||
template: "multistage",
|
||||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
screens: [
|
||||
EMPTY_SCREEN,
|
||||
{
|
||||
id: "FEATURE_CALLOUT_2_B",
|
||||
parent_selector: "hbox#browser",
|
||||
|
@ -626,11 +571,11 @@ const MESSAGES = () => {
|
|||
],
|
||||
},
|
||||
priority: 1,
|
||||
targeting: `source == "chrome" && ${matchCurrentScreenTargeting(
|
||||
targeting: `source == "open" && ${matchCurrentScreenTargeting(
|
||||
PDFJS_PREF,
|
||||
"FEATURE_CALLOUT_2_B"
|
||||
)}`,
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
"FEATURE_CALLOUT_[0-9]_B"
|
||||
)} && ${matchIncompleteTargeting(PDFJS_PREF)}`,
|
||||
trigger: { id: "pdfJsFeatureCalloutCheck" },
|
||||
},
|
||||
];
|
||||
messages = add24HourImpressionJEXLTargeting(
|
||||
|
|
|
@ -6,6 +6,14 @@
|
|||
const { ASRouter } = ChromeUtils.import(
|
||||
"resource://activity-stream/lib/ASRouter.jsm"
|
||||
);
|
||||
const { FeatureCalloutMessages } = ChromeUtils.importESModule(
|
||||
"resource://activity-stream/lib/FeatureCalloutMessages.sys.mjs"
|
||||
);
|
||||
const lazy = {};
|
||||
ChromeUtils.defineESModuleGetters(lazy, {
|
||||
FeatureCalloutBroker:
|
||||
"resource://activity-stream/lib/FeatureCalloutBroker.sys.mjs",
|
||||
});
|
||||
|
||||
const calloutId = "multi-stage-message-root";
|
||||
const calloutSelector = `#${calloutId}.featureCallout`;
|
||||
|
@ -35,9 +43,10 @@ async function openURLInNewTab(window, url) {
|
|||
return BrowserTestUtils.openNewForegroundTab(window.gBrowser, url);
|
||||
}
|
||||
|
||||
const pdfMatch = sinon.match(val => {
|
||||
return val?.id === "featureCalloutCheck" && val?.context?.source === "chrome";
|
||||
});
|
||||
const pdfMatch = sinon.match(
|
||||
val =>
|
||||
val?.id === "pdfJsFeatureCalloutCheck" && val?.context?.source === "open"
|
||||
);
|
||||
|
||||
const validateCalloutCustomPosition = (element, positionOverride, doc) => {
|
||||
const browserBox = doc.querySelector("hbox#browser");
|
||||
|
@ -125,7 +134,7 @@ const testMessage = {
|
|||
},
|
||||
priority: 1,
|
||||
targeting: "true",
|
||||
trigger: { id: "featureCalloutCheck" },
|
||||
trigger: { id: "pdfJsFeatureCalloutCheck" },
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -135,6 +144,317 @@ add_setup(async function () {
|
|||
requestLongerTimeout(2);
|
||||
});
|
||||
|
||||
// Test that a feature callout message can be loaded into ASRouter and displayed
|
||||
// via a standard trigger. Also test that the callout can be a feature tour,
|
||||
// even if its tour pref doesn't exist in Firefox. The tour pref will be created
|
||||
// and cleaned up automatically. This allows a feature callout to be implemented
|
||||
// entirely off-train in an experiment, without landing anything in tree.
|
||||
add_task(async function triggered_feature_tour_with_custom_pref() {
|
||||
let sandbox = sinon.createSandbox();
|
||||
const TEST_MESSAGES = [
|
||||
{
|
||||
id: "TEST_FEATURE_TOUR",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "TEST_FEATURE_TOUR",
|
||||
template: "multistage",
|
||||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
tour_pref_name: "messaging-system-action.browser.test.feature-tour",
|
||||
tour_pref_default_value: JSON.stringify({
|
||||
screen: "FEATURE_CALLOUT_1",
|
||||
complete: false,
|
||||
}),
|
||||
screens: [
|
||||
{
|
||||
id: "FEATURE_CALLOUT_1",
|
||||
parent_selector: "#PanelUI-menu-button",
|
||||
content: {
|
||||
position: "callout",
|
||||
arrow_position: "top-center-arrow-end",
|
||||
title: {
|
||||
string_id: "callout-pdfjs-edit-title",
|
||||
},
|
||||
subtitle: {
|
||||
string_id: "callout-pdfjs-edit-body-b",
|
||||
},
|
||||
primary_button: {
|
||||
label: {
|
||||
string_id: "callout-pdfjs-edit-button",
|
||||
},
|
||||
action: {
|
||||
type: "SET_PREF",
|
||||
data: {
|
||||
pref: {
|
||||
name: "messaging-system-action.browser.test.feature-tour",
|
||||
value: JSON.stringify({
|
||||
screen: "FEATURE_CALLOUT_2",
|
||||
complete: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dismiss_button: {
|
||||
action: {
|
||||
type: "MULTI_ACTION",
|
||||
dismiss: true,
|
||||
data: {
|
||||
actions: [
|
||||
{
|
||||
type: "BLOCK_MESSAGE",
|
||||
data: { id: "TEST_FEATURE_TOUR" },
|
||||
},
|
||||
{
|
||||
type: "SET_PREF",
|
||||
data: {
|
||||
pref: {
|
||||
name: "messaging-system-action.browser.test.feature-tour",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "FEATURE_CALLOUT_2",
|
||||
parent_selector: "#back-button",
|
||||
content: {
|
||||
position: "callout",
|
||||
arrow_position: "top-center-arrow-start",
|
||||
title: {
|
||||
string_id: "callout-pdfjs-draw-title",
|
||||
},
|
||||
subtitle: {
|
||||
string_id: "callout-pdfjs-draw-body-b",
|
||||
},
|
||||
primary_button: {
|
||||
label: {
|
||||
string_id: "callout-pdfjs-draw-button",
|
||||
},
|
||||
action: {
|
||||
type: "MULTI_ACTION",
|
||||
dismiss: true,
|
||||
data: {
|
||||
actions: [
|
||||
{
|
||||
type: "BLOCK_MESSAGE",
|
||||
data: { id: "TEST_FEATURE_TOUR" },
|
||||
},
|
||||
{
|
||||
type: "SET_PREF",
|
||||
data: {
|
||||
pref: {
|
||||
name: "messaging-system-action.browser.test.feature-tour",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
dismiss_button: {
|
||||
action: {
|
||||
type: "MULTI_ACTION",
|
||||
dismiss: true,
|
||||
data: {
|
||||
actions: [
|
||||
{
|
||||
type: "BLOCK_MESSAGE",
|
||||
data: { id: "TEST_FEATURE_TOUR" },
|
||||
},
|
||||
{
|
||||
type: "SET_PREF",
|
||||
data: {
|
||||
pref: {
|
||||
name: "messaging-system-action.browser.test.feature-tour",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
priority: 2,
|
||||
targeting: `(('messaging-system-action.browser.test.feature-tour' | preferenceValue) ? (('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')) ? ('messaging-system-action.browser.test.feature-tour' | preferenceValue | regExpMatch('(?<=complete":)(.*)(?=})')[1] != "true") : true) : true)`,
|
||||
trigger: { id: "nthTabClosed" },
|
||||
},
|
||||
{
|
||||
id: "TEST_FEATURE_TOUR_2",
|
||||
template: "feature_callout",
|
||||
content: {
|
||||
id: "TEST_FEATURE_TOUR_2",
|
||||
template: "multistage",
|
||||
backdrop: "transparent",
|
||||
transitions: false,
|
||||
disableHistoryUpdates: true,
|
||||
screens: [
|
||||
{
|
||||
id: "FEATURE_CALLOUT_TEST",
|
||||
parent_selector: "#urlbar-container",
|
||||
content: {
|
||||
position: "callout",
|
||||
arrow_position: "top-center-arrow-end",
|
||||
title: {
|
||||
string_id: "callout-pdfjs-edit-title",
|
||||
},
|
||||
subtitle: {
|
||||
string_id: "callout-pdfjs-edit-body-b",
|
||||
},
|
||||
primary_button: {
|
||||
label: {
|
||||
string_id: "callout-pdfjs-edit-button",
|
||||
},
|
||||
action: {
|
||||
dismiss: true,
|
||||
},
|
||||
},
|
||||
dismiss_button: {
|
||||
action: {
|
||||
dismiss: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
priority: 1,
|
||||
targeting: "true",
|
||||
trigger: { id: "nthTabClosed" },
|
||||
},
|
||||
];
|
||||
const getMessagesStub = sandbox.stub(FeatureCalloutMessages, "getMessages");
|
||||
getMessagesStub.returns(TEST_MESSAGES);
|
||||
await ASRouter._updateMessageProviders();
|
||||
await ASRouter.loadMessagesFromAllProviders(
|
||||
ASRouter.state.providers.filter(p => p.id === "onboarding")
|
||||
);
|
||||
|
||||
// Test that callout is triggered and shown in browser chrome
|
||||
const win1 = await BrowserTestUtils.openNewBrowserWindow();
|
||||
win1.focus();
|
||||
const tab1 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser);
|
||||
win1.gBrowser.removeTab(tab1);
|
||||
await waitForCalloutScreen(
|
||||
win1.document,
|
||||
TEST_MESSAGES[0].content.screens[0].id
|
||||
);
|
||||
ok(
|
||||
win1.document.querySelector(calloutSelector),
|
||||
"Feature Callout is rendered in the browser chrome when a message is available"
|
||||
);
|
||||
|
||||
// Test that a callout does NOT appear if another is already shown in any window.
|
||||
const showFeatureCalloutSpy = sandbox.spy(
|
||||
lazy.FeatureCalloutBroker,
|
||||
"showFeatureCallout"
|
||||
);
|
||||
const win2 = await BrowserTestUtils.openNewBrowserWindow();
|
||||
win2.focus();
|
||||
const tab2 = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser);
|
||||
win2.gBrowser.removeTab(tab2);
|
||||
await BrowserTestUtils.waitForCondition(async () => {
|
||||
const rvs = await Promise.all(showFeatureCalloutSpy.returnValues);
|
||||
return (
|
||||
showFeatureCalloutSpy.calledWith(
|
||||
win2.gBrowser.selectedBrowser,
|
||||
sinon.match(TEST_MESSAGES[0])
|
||||
) && rvs.every(rv => !rv)
|
||||
);
|
||||
}, "Waiting for showFeatureCallout to be called");
|
||||
ok(
|
||||
!win2.document.querySelector(calloutSelector),
|
||||
"Feature Callout is not rendered when a callout is already shown in any window"
|
||||
);
|
||||
await BrowserTestUtils.closeWindow(win2);
|
||||
win1.focus();
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
async () => Services.focus.activeWindow === win1,
|
||||
"Waiting for window 1 to be active"
|
||||
);
|
||||
|
||||
// Test that the tour pref doesn't exist yet
|
||||
ok(
|
||||
!Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name),
|
||||
"Tour pref does not exist yet"
|
||||
);
|
||||
|
||||
// Test that the callout advances screen and sets the tour pref
|
||||
win1.document.querySelector(primaryButtonSelector).click();
|
||||
await BrowserTestUtils.waitForCondition(
|
||||
() =>
|
||||
Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name),
|
||||
"Waiting for tour pref to be set"
|
||||
);
|
||||
ok(true, "Tour pref is set");
|
||||
await waitForCalloutScreen(
|
||||
win1.document,
|
||||
TEST_MESSAGES[0].content.screens[1].id
|
||||
);
|
||||
ok(
|
||||
win1.document.querySelector(calloutSelector),
|
||||
"Feature Callout screen 2 is rendered"
|
||||
);
|
||||
SimpleTest.isDeeply(
|
||||
JSON.parse(
|
||||
Services.prefs.getStringPref(
|
||||
TEST_MESSAGES[0].content.tour_pref_name,
|
||||
"{}"
|
||||
)
|
||||
),
|
||||
{ screen: "FEATURE_CALLOUT_2", complete: false },
|
||||
"Tour pref is set correctly"
|
||||
);
|
||||
|
||||
// Test that the callout is dismissed and cleans up the tour pref
|
||||
win1.document.querySelector(primaryButtonSelector).click();
|
||||
await waitForRemoved(win1.document);
|
||||
ok(
|
||||
!win1.document.querySelector(calloutSelector),
|
||||
"Feature Callout is not rendered after being dismissed"
|
||||
);
|
||||
ok(
|
||||
!Services.prefs.prefHasUserValue(TEST_MESSAGES[0].content.tour_pref_name),
|
||||
"Tour pref is cleaned up correctly"
|
||||
);
|
||||
|
||||
// Test that the message was blocked so a different callout is shown
|
||||
const tab3 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser);
|
||||
win1.gBrowser.removeTab(tab3);
|
||||
await waitForCalloutScreen(
|
||||
win1.document,
|
||||
TEST_MESSAGES[1].content.screens[0].id
|
||||
);
|
||||
ok(
|
||||
win1.document.querySelector(calloutSelector),
|
||||
"A different Feature Callout is rendered"
|
||||
);
|
||||
win1.document.querySelector(primaryButtonSelector).click();
|
||||
await waitForRemoved(win1.document);
|
||||
ok(
|
||||
!lazy.FeatureCalloutBroker.isCalloutShowing,
|
||||
"No Feature Callout is shown"
|
||||
);
|
||||
|
||||
BrowserTestUtils.closeWindow(win1);
|
||||
|
||||
sandbox.restore();
|
||||
await ASRouter.unblockMessageById(TEST_MESSAGES[0].id);
|
||||
await ASRouter.resetMessageState();
|
||||
await ASRouter._updateMessageProviders();
|
||||
await ASRouter.loadMessagesFromAllProviders(
|
||||
ASRouter.state.providers.filter(p => p.id === "onboarding")
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function feature_callout_renders_in_browser_chrome_for_pdf() {
|
||||
const sandbox = sinon.createSandbox();
|
||||
const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
|
||||
|
|
|
@ -27,71 +27,97 @@ const BUNDLE_SRC =
|
|||
export class FeatureCallout {
|
||||
/**
|
||||
* @typedef {Object} FeatureCalloutOptions
|
||||
* @property {Window} win window in which messages will be rendered
|
||||
* @property {String} prefName name of the pref used to track progress through
|
||||
* a given feature tour, e.g. "browser.pdfjs.feature-tour"
|
||||
* @property {String} [page] string to pass as the page when requesting
|
||||
* messages from ASRouter and sending telemetry. for browser chrome, the
|
||||
* string "chrome" is used
|
||||
* @property {Window} win window in which messages will be rendered.
|
||||
* @property {{name: String, defaultValue?: String}} [pref] optional pref used
|
||||
* to track progress through a given feature tour. for example:
|
||||
* {
|
||||
* name: "browser.pdfjs.feature-tour",
|
||||
* defaultValue: '{ screen: "FEATURE_CALLOUT_1", complete: false }',
|
||||
* }
|
||||
* or { name: "browser.pdfjs.feature-tour" } (defaultValue is optional)
|
||||
* @property {String} [location] string to pass as the page when requesting
|
||||
* messages from ASRouter and sending telemetry.
|
||||
* @property {String} context either "chrome" or "content". "chrome" is used
|
||||
* when the callout is shown in the browser chrome, and "content" is used
|
||||
* when the callout is shown in a content page like Firefox View.
|
||||
* @property {MozBrowser} [browser] <browser> element responsible for the
|
||||
* feature callout. for content pages, this is the browser element that the
|
||||
* callout is being shown in. for chrome, this is the active browser
|
||||
* callout is being shown in. for chrome, this is the active browser.
|
||||
* @property {Function} [listener] callback to be invoked on various callout
|
||||
* events to keep the broker informed of the callout's state.
|
||||
* @property {FeatureCalloutTheme} [theme] @see FeatureCallout.themePresets
|
||||
*/
|
||||
|
||||
/** @param {FeatureCalloutOptions} options */
|
||||
constructor({ win, prefName, page, browser, theme = {} } = {}) {
|
||||
constructor({
|
||||
win,
|
||||
pref,
|
||||
location,
|
||||
context,
|
||||
browser,
|
||||
listener,
|
||||
theme = {},
|
||||
} = {}) {
|
||||
this.win = win;
|
||||
this.doc = win.document;
|
||||
this.browser = browser || this.win.docShell.chromeEventHandler;
|
||||
this.config = null;
|
||||
this.loadingConfig = false;
|
||||
this.message = null;
|
||||
if (pref?.name) {
|
||||
this.pref = pref;
|
||||
}
|
||||
this._featureTourProgress = null;
|
||||
this.currentScreen = null;
|
||||
this.renderObserver = null;
|
||||
this.savedActiveElement = null;
|
||||
this.ready = false;
|
||||
this._positionListenersRegistered = false;
|
||||
this.AWSetup = false;
|
||||
this.page = page;
|
||||
this.location = location;
|
||||
this.context = context;
|
||||
this.listener = listener;
|
||||
this._initTheme(theme);
|
||||
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
this,
|
||||
"featureTourProgress",
|
||||
prefName,
|
||||
'{"screen":"","complete":true}',
|
||||
this._handlePrefChange.bind(this),
|
||||
val => {
|
||||
try {
|
||||
return JSON.parse(val);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
);
|
||||
this._handlePrefChange = this._handlePrefChange.bind(this);
|
||||
|
||||
XPCOMUtils.defineLazyPreferenceGetter(
|
||||
this,
|
||||
"cfrFeaturesUserPref",
|
||||
"browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
|
||||
true,
|
||||
function (pref, previous, latest) {
|
||||
if (latest) {
|
||||
this.showFeatureCallout();
|
||||
} else {
|
||||
this._handlePrefChange();
|
||||
}
|
||||
}.bind(this)
|
||||
true
|
||||
);
|
||||
this.featureTourProgress; // Load initial value of progress pref
|
||||
this.setupFeatureTourProgress();
|
||||
|
||||
// When the window is focused, ensure tour is synced with tours in any other
|
||||
// instances of the parent page. This does not apply when the Callout is
|
||||
// shown in the browser chrome.
|
||||
if (this.page !== "chrome") {
|
||||
if (this.context !== "chrome") {
|
||||
this.win.addEventListener("visibilitychange", this);
|
||||
}
|
||||
|
||||
this.win.addEventListener("unload", this);
|
||||
}
|
||||
|
||||
setupFeatureTourProgress() {
|
||||
if (this.featureTourProgress) {
|
||||
return;
|
||||
}
|
||||
if (this.pref?.name) {
|
||||
this._handlePrefChange(null, null, this.pref.name);
|
||||
Services.prefs.addObserver(this.pref.name, this._handlePrefChange);
|
||||
}
|
||||
}
|
||||
|
||||
teardownFeatureTourProgress() {
|
||||
if (this.pref?.name) {
|
||||
Services.prefs.removeObserver(this.pref.name, this._handlePrefChange);
|
||||
}
|
||||
this._featureTourProgress = null;
|
||||
}
|
||||
|
||||
get featureTourProgress() {
|
||||
return this._featureTourProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -129,44 +155,86 @@ export class FeatureCallout {
|
|||
}
|
||||
}
|
||||
|
||||
async _handlePrefChange() {
|
||||
_handlePrefChange(subject, topic, prefName) {
|
||||
switch (prefName) {
|
||||
case this.pref?.name:
|
||||
try {
|
||||
this._featureTourProgress = JSON.parse(
|
||||
Services.prefs.getStringPref(
|
||||
this.pref.name,
|
||||
this.pref.defaultValue ?? null
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this._featureTourProgress = null;
|
||||
}
|
||||
if (topic === "nsPref:changed") {
|
||||
this._maybeAdvanceScreens();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async _maybeAdvanceScreens() {
|
||||
if (this.doc.visibilityState === "hidden" || !this.featureTourProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have more than one screen, it means that we're
|
||||
// displaying a feature tour, and transitions are handled
|
||||
// based on the value of a tour progress pref. Otherwise,
|
||||
// just show the feature callout.
|
||||
if (this.config?.screens.length === 1) {
|
||||
// If we have more than one screen, it means that we're displaying a feature
|
||||
// tour, and transitions are handled based on the value of a tour progress
|
||||
// pref. Otherwise, just show the feature callout. If a pref change results
|
||||
// from an event in a Spotlight message, initialize the feature callout with
|
||||
// the next message in the tour.
|
||||
if (
|
||||
this.config?.screens.length === 1 ||
|
||||
this.currentScreen == "spotlight"
|
||||
) {
|
||||
this.showFeatureCallout();
|
||||
return;
|
||||
}
|
||||
|
||||
// If a pref change results from an event in a Spotlight message,
|
||||
// reload the page to clear the Spotlight and initialize the
|
||||
// feature callout with the next message in the tour.
|
||||
if (this.currentScreen == "spotlight") {
|
||||
this.win.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
let prefVal = this.featureTourProgress;
|
||||
// End the tour according to the tour progress pref or if the user disabled
|
||||
// contextual feature recommendations.
|
||||
if (prefVal.complete || !this.cfrFeaturesUserPref) {
|
||||
this.endTour();
|
||||
this.currentScreen = null;
|
||||
} else if (prefVal.screen !== this.currentScreen?.id) {
|
||||
// Pref changes only matter to us insofar as they let us advance an
|
||||
// ongoing tour. If the tour was closed and the pref changed later, e.g.
|
||||
// by editing the pref directly, we don't want to start up the tour again.
|
||||
// This is more important in the chrome, which is always open.
|
||||
if (this.context === "chrome" && !this.currentScreen) {
|
||||
return;
|
||||
}
|
||||
this.ready = false;
|
||||
this._container?.classList.add("hidden");
|
||||
this._pageEventManager?.clear();
|
||||
// wait for fade out transition
|
||||
this.win.setTimeout(async () => {
|
||||
await this._loadConfig();
|
||||
// If the initial message was deployed from outside by ASRouter as a
|
||||
// result of a trigger, we can't continue it through _loadConfig, since
|
||||
// that effectively requests a message with a `featureCalloutCheck`
|
||||
// trigger. So we need to load up the same message again, merely
|
||||
// changing the startScreen index. Just check that the next screen and
|
||||
// the current screen are both within the message's screens array.
|
||||
let nextMessage = null;
|
||||
if (
|
||||
this.context === "chrome" &&
|
||||
this.message?.trigger.id !== "featureCalloutCheck"
|
||||
) {
|
||||
if (
|
||||
this.config?.screens.some(s => s.id === this.currentScreen?.id) &&
|
||||
this.config.screens.some(s => s.id === prefVal.screen)
|
||||
) {
|
||||
nextMessage = this.message;
|
||||
}
|
||||
}
|
||||
await this._updateConfig(nextMessage);
|
||||
this._container?.remove();
|
||||
this._removePositionListeners();
|
||||
this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
|
||||
this._addCalloutLinkElements();
|
||||
this._setupWindowFunctions();
|
||||
await this._renderCallout();
|
||||
}, TRANSITION_MS);
|
||||
}
|
||||
|
@ -203,7 +271,7 @@ export class FeatureCallout {
|
|||
return;
|
||||
}
|
||||
let focusedElement =
|
||||
this.page === "chrome"
|
||||
this.context === "chrome"
|
||||
? Services.focus.focusedElement
|
||||
: this.doc.activeElement;
|
||||
// If the window has a focused element, let it handle the ESC key instead.
|
||||
|
@ -217,7 +285,7 @@ export class FeatureCallout {
|
|||
event: "DISMISS",
|
||||
event_context: {
|
||||
source: `KEY_${event.key}`,
|
||||
page: this.page,
|
||||
page: this.location,
|
||||
},
|
||||
message_id: this.config?.id.toUpperCase(),
|
||||
});
|
||||
|
@ -228,12 +296,18 @@ export class FeatureCallout {
|
|||
}
|
||||
|
||||
case "visibilitychange":
|
||||
this._handlePrefChange();
|
||||
this._maybeAdvanceScreens();
|
||||
break;
|
||||
|
||||
case "resize":
|
||||
case "toggle":
|
||||
this._positionCallout();
|
||||
this.win.requestAnimationFrame(() => this._positionCallout());
|
||||
break;
|
||||
|
||||
case "unload":
|
||||
try {
|
||||
this.teardownFeatureTourProgress();
|
||||
} catch (error) {}
|
||||
break;
|
||||
|
||||
default:
|
||||
|
@ -305,7 +379,7 @@ export class FeatureCallout {
|
|||
);
|
||||
this._container.id = CONTAINER_ID;
|
||||
// This value is reported as the "page" in about:welcome telemetry
|
||||
this._container.dataset.page = this.page;
|
||||
this._container.dataset.page = this.location;
|
||||
this._container.setAttribute(
|
||||
"aria-describedby",
|
||||
`#${CONTAINER_ID} .welcome-text`
|
||||
|
@ -338,18 +412,17 @@ export class FeatureCallout {
|
|||
"start",
|
||||
"top-end",
|
||||
"top-start",
|
||||
"top-center-arrow-end",
|
||||
"top-center-arrow-start",
|
||||
];
|
||||
const arrowPosition = this.currentScreen?.content?.arrow_position || "top";
|
||||
// Callout should overlap the parent element by 17px (so the box, not
|
||||
// including the arrow, will overlap by 5px)
|
||||
// Callout arrow should overlap the parent element by 5px
|
||||
const arrowWidth = 12;
|
||||
let overlap = 17;
|
||||
// If we have no overlap, we send the callout the same number of pixels
|
||||
// in the opposite direction
|
||||
overlap = this.currentScreen?.content?.noCalloutOverlap
|
||||
? overlap * -1
|
||||
: overlap;
|
||||
overlap -= arrowWidth;
|
||||
const arrowHeight = Math.hypot(arrowWidth, arrowWidth);
|
||||
// If the message specifies no overlap, we move the callout away so the
|
||||
// arrow doesn't overlap at all.
|
||||
const overlapAmount = this.currentScreen?.content?.noCalloutOverlap ? 0 : 5;
|
||||
let overlap = overlapAmount - arrowHeight;
|
||||
// Is the document layout right to left?
|
||||
const RTL = this.doc.dir === "rtl";
|
||||
const customPosition =
|
||||
|
@ -398,9 +471,11 @@ export class FeatureCallout {
|
|||
className = "arrow-inline-end";
|
||||
break;
|
||||
case "top-start":
|
||||
case "top-center-arrow-start":
|
||||
className = RTL ? "arrow-top-end" : "arrow-top-start";
|
||||
break;
|
||||
case "top-end":
|
||||
case "top-center-arrow-end":
|
||||
className = RTL ? "arrow-top-start" : "arrow-top-end";
|
||||
break;
|
||||
case "top":
|
||||
|
@ -454,7 +529,8 @@ export class FeatureCallout {
|
|||
if (customPosition.right) {
|
||||
const rightPosition = subtractPixelValueFromValue(
|
||||
customPosition.right,
|
||||
parentEl.getBoundingClientRect().right - container.clientWidth
|
||||
parentEl.getBoundingClientRect().right -
|
||||
container.getBoundingClientRect().width
|
||||
);
|
||||
|
||||
RTL
|
||||
|
@ -465,30 +541,38 @@ export class FeatureCallout {
|
|||
if (customPosition.bottom) {
|
||||
container.style.top = subtractPixelValueFromValue(
|
||||
customPosition.bottom,
|
||||
parentEl.getBoundingClientRect().bottom - container.clientHeight
|
||||
parentEl.getBoundingClientRect().bottom -
|
||||
container.getBoundingClientRect().height
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Remember not to use HTML-only properties/methods like offsetHeight. Try
|
||||
// to use getBoundingClientRect() instead, which is available on XUL
|
||||
// elements. This is necessary to support feature callout in chrome, which
|
||||
// is still largely XUL-based.
|
||||
const positioners = {
|
||||
// availableSpace should be the space between the edge of the page in the assumed direction
|
||||
// and the edge of the parent (with the callout being intended to fit between those two edges)
|
||||
// while needed space should be the space necessary to fit the callout container
|
||||
// availableSpace should be the space between the edge of the page in the
|
||||
// assumed direction and the edge of the parent (with the callout being
|
||||
// intended to fit between those two edges) while needed space should be
|
||||
// the space necessary to fit the callout container.
|
||||
top: {
|
||||
availableSpace() {
|
||||
return (
|
||||
doc.documentElement.clientHeight -
|
||||
getOffset(parentEl).top -
|
||||
parentEl.clientHeight
|
||||
parentEl.getBoundingClientRect().height
|
||||
);
|
||||
},
|
||||
neededSpace: container.clientHeight - overlap,
|
||||
neededSpace: container.getBoundingClientRect().height - overlap,
|
||||
position() {
|
||||
// Point to an element above the callout
|
||||
let containerTop =
|
||||
getOffset(parentEl).top + parentEl.clientHeight - overlap;
|
||||
getOffset(parentEl).top +
|
||||
parentEl.getBoundingClientRect().height -
|
||||
overlap;
|
||||
container.style.top = `${Math.max(0, containerTop)}px`;
|
||||
alignHorizontally("center");
|
||||
},
|
||||
|
@ -497,11 +581,13 @@ export class FeatureCallout {
|
|||
availableSpace() {
|
||||
return getOffset(parentEl).top;
|
||||
},
|
||||
neededSpace: container.clientHeight - overlap,
|
||||
neededSpace: container.getBoundingClientRect().height - overlap,
|
||||
position() {
|
||||
// Point to an element below the callout
|
||||
let containerTop =
|
||||
getOffset(parentEl).top - container.clientHeight + overlap;
|
||||
getOffset(parentEl).top -
|
||||
container.getBoundingClientRect().height +
|
||||
overlap;
|
||||
container.style.top = `${Math.max(0, containerTop)}px`;
|
||||
alignHorizontally("center");
|
||||
},
|
||||
|
@ -510,13 +596,18 @@ export class FeatureCallout {
|
|||
availableSpace() {
|
||||
return getOffset(parentEl).left;
|
||||
},
|
||||
neededSpace: container.clientWidth - overlap,
|
||||
neededSpace: container.getBoundingClientRect().width - overlap,
|
||||
position() {
|
||||
// Point to an element to the right of the callout
|
||||
let containerLeft =
|
||||
getOffset(parentEl).left - container.clientWidth + overlap;
|
||||
getOffset(parentEl).left -
|
||||
container.getBoundingClientRect().width +
|
||||
overlap;
|
||||
container.style.left = `${Math.max(0, containerLeft)}px`;
|
||||
if (container.offsetHeight <= parentEl.offsetHeight) {
|
||||
if (
|
||||
container.getBoundingClientRect().height <=
|
||||
parentEl.getBoundingClientRect().height
|
||||
) {
|
||||
container.style.top = `${getOffset(parentEl).top}px`;
|
||||
} else {
|
||||
centerVertically();
|
||||
|
@ -527,13 +618,18 @@ export class FeatureCallout {
|
|||
availableSpace() {
|
||||
return doc.documentElement.clientWidth - getOffset(parentEl).right;
|
||||
},
|
||||
neededSpace: container.clientWidth - overlap,
|
||||
neededSpace: container.getBoundingClientRect().width - overlap,
|
||||
position() {
|
||||
// Point to an element to the left of the callout
|
||||
let containerLeft =
|
||||
getOffset(parentEl).left + parentEl.clientWidth - overlap;
|
||||
getOffset(parentEl).left +
|
||||
parentEl.getBoundingClientRect().width -
|
||||
overlap;
|
||||
container.style.left = `${Math.max(0, containerLeft)}px`;
|
||||
if (container.offsetHeight <= parentEl.offsetHeight) {
|
||||
if (
|
||||
container.getBoundingClientRect().height <=
|
||||
parentEl.getBoundingClientRect().height
|
||||
) {
|
||||
container.style.top = `${getOffset(parentEl).top}px`;
|
||||
} else {
|
||||
centerVertically();
|
||||
|
@ -542,40 +638,80 @@ export class FeatureCallout {
|
|||
},
|
||||
"top-start": {
|
||||
availableSpace() {
|
||||
doc.documentElement.clientHeight -
|
||||
return (
|
||||
doc.documentElement.clientHeight -
|
||||
getOffset(parentEl).top -
|
||||
parentEl.clientHeight;
|
||||
parentEl.getBoundingClientRect().height
|
||||
);
|
||||
},
|
||||
neededSpace: container.clientHeight - overlap,
|
||||
neededSpace: container.getBoundingClientRect().height - overlap,
|
||||
position() {
|
||||
// Point to an element above and at the start of the callout
|
||||
let containerTop =
|
||||
getOffset(parentEl).top + parentEl.clientHeight - overlap;
|
||||
container.style.top = `${Math.max(
|
||||
container.clientHeight - overlap,
|
||||
containerTop
|
||||
)}px`;
|
||||
getOffset(parentEl).top +
|
||||
parentEl.getBoundingClientRect().height -
|
||||
overlap;
|
||||
container.style.top = `${Math.max(0, containerTop)}px`;
|
||||
alignHorizontally("start");
|
||||
},
|
||||
},
|
||||
"top-end": {
|
||||
availableSpace() {
|
||||
doc.documentElement.clientHeight -
|
||||
return (
|
||||
doc.documentElement.clientHeight -
|
||||
getOffset(parentEl).top -
|
||||
parentEl.clientHeight;
|
||||
parentEl.getBoundingClientRect().height
|
||||
);
|
||||
},
|
||||
neededSpace: container.clientHeight - overlap,
|
||||
neededSpace: container.getBoundingClientRect().height - overlap,
|
||||
position() {
|
||||
// Point to an element above and at the end of the callout
|
||||
let containerTop =
|
||||
getOffset(parentEl).top + parentEl.clientHeight - overlap;
|
||||
container.style.top = `${Math.max(
|
||||
container.clientHeight - overlap,
|
||||
containerTop
|
||||
)}px`;
|
||||
getOffset(parentEl).top +
|
||||
parentEl.getBoundingClientRect().height -
|
||||
overlap;
|
||||
container.style.top = `${Math.max(0, containerTop)}px`;
|
||||
alignHorizontally("end");
|
||||
},
|
||||
},
|
||||
"top-center-arrow-start": {
|
||||
availableSpace() {
|
||||
return (
|
||||
doc.documentElement.clientHeight -
|
||||
getOffset(parentEl).top -
|
||||
parentEl.getBoundingClientRect().height
|
||||
);
|
||||
},
|
||||
neededSpace: container.getBoundingClientRect().height - overlap,
|
||||
position() {
|
||||
// Point to an element above and at the start of the callout
|
||||
let containerTop =
|
||||
getOffset(parentEl).top +
|
||||
parentEl.getBoundingClientRect().height -
|
||||
overlap;
|
||||
container.style.top = `${Math.max(0, containerTop)}px`;
|
||||
alignHorizontally("center-arrow-start");
|
||||
},
|
||||
},
|
||||
"top-center-arrow-end": {
|
||||
availableSpace() {
|
||||
return (
|
||||
doc.documentElement.clientHeight -
|
||||
getOffset(parentEl).top -
|
||||
parentEl.getBoundingClientRect().height
|
||||
);
|
||||
},
|
||||
neededSpace: container.getBoundingClientRect().height - overlap,
|
||||
position() {
|
||||
// Point to an element above and at the end of the callout
|
||||
let containerTop =
|
||||
getOffset(parentEl).top +
|
||||
parentEl.getBoundingClientRect().height -
|
||||
overlap;
|
||||
container.style.top = `${Math.max(0, containerTop)}px`;
|
||||
alignHorizontally("center-arrow-end");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const calloutFits = position => {
|
||||
|
@ -595,20 +731,21 @@ export class FeatureCallout {
|
|||
let position = arrowPosition;
|
||||
if (!arrowPositions.includes(position)) {
|
||||
// Configured arrow position is not valid
|
||||
return false;
|
||||
position = null;
|
||||
}
|
||||
if (["start", "end"].includes(position)) {
|
||||
// position here is referencing the direction that the callout container
|
||||
// is pointing to, and therefore should be the _opposite_ side of the arrow
|
||||
// eg. if arrow is at the "end" in LTR layouts, the container is pointing
|
||||
// at an element to the right of itself, while in RTL layouts it is pointing to the left of itself
|
||||
// is pointing to, and therefore should be the _opposite_ side of the
|
||||
// arrow eg. if arrow is at the "end" in LTR layouts, the container is
|
||||
// pointing at an element to the right of itself, while in RTL layouts
|
||||
// it is pointing to the left of itself
|
||||
position = RTL ^ (position === "start") ? "left" : "right";
|
||||
}
|
||||
// If we're overriding the position, we don't need to sort for available space
|
||||
if (customPosition || calloutFits(position)) {
|
||||
if (customPosition || (position && calloutFits(position))) {
|
||||
return position;
|
||||
}
|
||||
let sortedPositions = Object.keys(positioners)
|
||||
let sortedPositions = ["top", "bottom", "left", "right"]
|
||||
.filter(p => p !== position)
|
||||
.filter(calloutFits)
|
||||
.sort((a, b) => {
|
||||
|
@ -624,20 +761,36 @@ export class FeatureCallout {
|
|||
};
|
||||
|
||||
const centerVertically = () => {
|
||||
let topOffset = (container.offsetHeight - parentEl.offsetHeight) / 2;
|
||||
let topOffset =
|
||||
(container.getBoundingClientRect().height -
|
||||
parentEl.getBoundingClientRect().height) /
|
||||
2;
|
||||
container.style.top = `${getOffset(parentEl).top - topOffset}px`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontally align a top/bottom-positioned callout according to the
|
||||
* passed position.
|
||||
* @param {String} [position = "start"] <"start"|"end"|"center">
|
||||
* @param {String} position one of...
|
||||
* - "center": for use with top/bottom. arrow is in the center, and the
|
||||
* center of the callout aligns with the parent center.
|
||||
* - "center-arrow-start": for use with center-arrow-top-start. arrow is
|
||||
* on the start (left) side of the callout, and the callout is aligned
|
||||
* so that the arrow points to the center of the parent element.
|
||||
* - "center-arrow-end": for use with center-arrow-top-end. arrow is on
|
||||
* the end, and the arrow points to the center of the parent.
|
||||
* - "start": currently unused. align the callout's starting edge with the
|
||||
* parent's starting edge.
|
||||
* - "end": currently unused. same as start but for the ending edge.
|
||||
*/
|
||||
const alignHorizontally = position => {
|
||||
switch (position) {
|
||||
case "center": {
|
||||
let sideOffset = (parentEl.clientWidth - container.clientWidth) / 2;
|
||||
let containerSide = RTL
|
||||
const sideOffset =
|
||||
(parentEl.getBoundingClientRect().width -
|
||||
container.getBoundingClientRect().width) /
|
||||
2;
|
||||
const containerSide = RTL
|
||||
? doc.documentElement.clientWidth -
|
||||
getOffset(parentEl).right +
|
||||
sideOffset
|
||||
|
@ -648,16 +801,35 @@ export class FeatureCallout {
|
|||
)}px`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
let containerSide =
|
||||
case "end":
|
||||
case "start": {
|
||||
const containerSide =
|
||||
RTL ^ (position === "end")
|
||||
? parentEl.getBoundingClientRect().left +
|
||||
parentEl.clientWidth -
|
||||
container.clientWidth
|
||||
parentEl.getBoundingClientRect().width -
|
||||
container.getBoundingClientRect().width
|
||||
: parentEl.getBoundingClientRect().left;
|
||||
container.style.left = `${Math.max(containerSide, 0)}px`;
|
||||
break;
|
||||
}
|
||||
case "center-arrow-end":
|
||||
case "center-arrow-start": {
|
||||
const parentRect = parentEl.getBoundingClientRect();
|
||||
const containerWidth = container.getBoundingClientRect().width;
|
||||
const containerSide =
|
||||
RTL ^ position.endsWith("end")
|
||||
? parentRect.left +
|
||||
parentRect.width / 2 +
|
||||
arrowWidth * 2 -
|
||||
containerWidth
|
||||
: parentRect.left + parentRect.width / 2 - arrowWidth * 2;
|
||||
const maxContainerSide =
|
||||
doc.documentElement.clientWidth - containerWidth;
|
||||
container.style.left = `${Math.min(
|
||||
maxContainerSide,
|
||||
Math.max(containerSide, 0)
|
||||
)}px`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -681,40 +853,48 @@ export class FeatureCallout {
|
|||
if (this.AWSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const AWParent = new lazy.AboutWelcomeParent();
|
||||
this.win.addEventListener("unload", () => {
|
||||
AWParent.didDestroy();
|
||||
});
|
||||
this.win.addEventListener("unload", () => AWParent.didDestroy());
|
||||
const receive = name => data =>
|
||||
AWParent.onContentMessage(`AWPage:${name}`, data, this.doc);
|
||||
this.win.AWGetFeatureConfig = () => this.config;
|
||||
this.win.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
|
||||
// Do not send telemetry if message config sets metrics as 'block'.
|
||||
if (this.config?.metrics !== "block") {
|
||||
this.win.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
|
||||
}
|
||||
this.win.AWSendToDeviceEmailsSupported = receive(
|
||||
"SEND_TO_DEVICE_EMAILS_SUPPORTED"
|
||||
);
|
||||
this.win.AWSendToParent = (name, data) => receive(name)(data);
|
||||
this.win.AWFinish = () => {
|
||||
this.endTour();
|
||||
|
||||
this._windowFuncs = {
|
||||
AWGetFeatureConfig: () => this.config,
|
||||
AWGetSelectedTheme: receive("GET_SELECTED_THEME"),
|
||||
// Do not send telemetry if message config sets metrics as 'block'.
|
||||
AWSendEventTelemetry:
|
||||
this.config?.metrics !== "block" ? receive("TELEMETRY_EVENT") : null,
|
||||
AWSendToDeviceEmailsSupported: receive("SEND_TO_DEVICE_EMAILS_SUPPORTED"),
|
||||
AWSendToParent: (name, data) => receive(name)(data),
|
||||
AWFinish: () => this.endTour(),
|
||||
AWEvaluateScreenTargeting: receive("EVALUATE_SCREEN_TARGETING"),
|
||||
};
|
||||
this.win.AWEvaluateScreenTargeting = receive("EVALUATE_SCREEN_TARGETING");
|
||||
for (const [name, func] of Object.entries(this._windowFuncs)) {
|
||||
this.win[name] = func;
|
||||
}
|
||||
|
||||
this.AWSetup = true;
|
||||
}
|
||||
|
||||
/** Clean up the functions defined above. */
|
||||
_clearWindowFunctions() {
|
||||
const windowFuncs = [
|
||||
"AWGetFeatureConfig",
|
||||
"AWGetSelectedTheme",
|
||||
"AWSendEventTelemetry",
|
||||
"AWSendToDeviceEmailsSupported",
|
||||
"AWSendToParent",
|
||||
"AWFinish",
|
||||
];
|
||||
windowFuncs.forEach(func => delete this.win[func]);
|
||||
if (this.AWSetup) {
|
||||
this.AWSetup = false;
|
||||
|
||||
for (const name of Object.keys(this._windowFuncs)) {
|
||||
delete this.win[name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to the broker, if one is present.
|
||||
* @param {String} name
|
||||
* @param {any} data
|
||||
*/
|
||||
_emitEvent(name, data) {
|
||||
this.listener?.(this.win, name, data);
|
||||
}
|
||||
|
||||
endTour(skipFadeOut = false) {
|
||||
|
@ -727,12 +907,13 @@ export class FeatureCallout {
|
|||
this.win.removeEventListener("keypress", this, { capture: true });
|
||||
this._pageEventManager?.clear();
|
||||
|
||||
// We're deleting featureTourProgress here to ensure that the
|
||||
// reference is freed for garbage collection. This prevents errors
|
||||
// caused by lingering instances when instantiating and removing
|
||||
// multiple feature tour instances in succession.
|
||||
delete this.featureTourProgress;
|
||||
// Delete almost everything to get this ready to show a different message.
|
||||
this.teardownFeatureTourProgress();
|
||||
this.pref = null;
|
||||
this.ready = false;
|
||||
this.message = null;
|
||||
this.content = null;
|
||||
this.currentScreen = null;
|
||||
// wait for fade out transition
|
||||
this._container?.classList.add("hidden");
|
||||
this._clearWindowFunctions();
|
||||
|
@ -747,6 +928,7 @@ export class FeatureCallout {
|
|||
if (this.savedActiveElement) {
|
||||
this.savedActiveElement.focus({ focusVisible: true });
|
||||
}
|
||||
this._emitEvent("end");
|
||||
},
|
||||
skipFadeOut ? 0 : TRANSITION_MS
|
||||
);
|
||||
|
@ -756,9 +938,11 @@ export class FeatureCallout {
|
|||
let action = this.currentScreen?.content.dismiss_button?.action;
|
||||
if (action?.type) {
|
||||
this.win.AWSendToParent("SPECIAL_ACTION", action);
|
||||
} else {
|
||||
this.endTour();
|
||||
if (!action.dismiss) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.endTour();
|
||||
}
|
||||
|
||||
async _addScriptsAndRender() {
|
||||
|
@ -801,51 +985,90 @@ export class FeatureCallout {
|
|||
}
|
||||
|
||||
/**
|
||||
* Request a message from ASRouter, targeting the `browser` and `page` values
|
||||
* passed to the constructor. The message content is stored in this.config,
|
||||
* which is returned by AWGetFeatureConfig. The aboutwelcome bundle will use
|
||||
* that function to get the content. It will only be called when the bundle
|
||||
* loads, so the bundle must be reloaded for a new message to be rendered.
|
||||
* Update the internal config with a new message. If a message is not
|
||||
* provided, try requesting one from ASRouter. The message content is stored
|
||||
* in this.config, which is returned by AWGetFeatureConfig. The aboutwelcome
|
||||
* bundle will use that function to get the content when it executes.
|
||||
* @param {Object} [message] ASRouter message. Omit to request a new one.
|
||||
* @returns {Promise<boolean>} true if a message is loaded, false if not.
|
||||
*/
|
||||
async _loadConfig() {
|
||||
async _updateConfig(message) {
|
||||
if (this.loadingConfig) {
|
||||
return false;
|
||||
}
|
||||
this.loadingConfig = true;
|
||||
await lazy.ASRouter.waitForInitialized;
|
||||
let result = await lazy.ASRouter.sendTriggerMessage({
|
||||
browser: this.browser,
|
||||
// triggerId and triggerContext
|
||||
id: "featureCalloutCheck",
|
||||
context: { source: this.page },
|
||||
});
|
||||
this.message = result.message;
|
||||
this.loadingConfig = false;
|
||||
|
||||
if (result.message.template !== "feature_callout") {
|
||||
// If another message type, like a Spotlight modal, is included
|
||||
// in the tour, save the template name as the current screen.
|
||||
this.currentScreen = result.message.template;
|
||||
return false;
|
||||
this.message = message || (await this._loadConfig());
|
||||
|
||||
switch (this.message.template) {
|
||||
case "feature_callout":
|
||||
break;
|
||||
case "spotlight":
|
||||
// Special handling for spotlight messages, which can be configured as a
|
||||
// kind of introduction to a feature tour.
|
||||
this.currentScreen = "spotlight";
|
||||
// fall through
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
this.config = result.message.content;
|
||||
this.config = this.message.content;
|
||||
|
||||
// Set the default start screen.
|
||||
let newScreen = this.config?.screens?.[this.config?.startScreen || 0];
|
||||
// If we have a feature tour in progress, try to set the start screen to
|
||||
// whichever screen is configured in the feature tour pref.
|
||||
if (
|
||||
this.config.screens &&
|
||||
this.config?.tour_pref_name &&
|
||||
this.config.tour_pref_name === this.pref?.name &&
|
||||
this.featureTourProgress
|
||||
) {
|
||||
const newIndex = this.config.screens.findIndex(
|
||||
screen => screen.id === this.featureTourProgress.screen
|
||||
);
|
||||
if (newIndex !== -1) {
|
||||
newScreen = this.config.screens[newIndex];
|
||||
if (newScreen?.id !== this.currentScreen?.id) {
|
||||
// This is how we tell the bundle to render the correct screen.
|
||||
this.config.startScreen = newIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newScreen?.id === this.currentScreen?.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only add an impression if we actually have a message to impress
|
||||
if (Object.keys(result.message).length) {
|
||||
lazy.ASRouter.addImpression(result.message);
|
||||
if (Object.keys(this.message).length) {
|
||||
lazy.ASRouter.addImpression(this.message);
|
||||
}
|
||||
|
||||
this.currentScreen = newScreen;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a message from ASRouter, targeting the `browser` and `page` values
|
||||
* passed to the constructor.
|
||||
* @returns {Promise<Object>} the requested message.
|
||||
*/
|
||||
async _loadConfig() {
|
||||
this.loadingConfig = true;
|
||||
await lazy.ASRouter.waitForInitialized;
|
||||
let result = await lazy.ASRouter.sendTriggerMessage({
|
||||
browser: this.browser,
|
||||
// triggerId and triggerContext
|
||||
id: "featureCalloutCheck",
|
||||
context: { source: this.location },
|
||||
});
|
||||
this.loadingConfig = false;
|
||||
return result.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to render the callout in the current document.
|
||||
* @returns {Promise<Boolean>} whether the callout was rendered.
|
||||
*/
|
||||
async _renderCallout() {
|
||||
let container = this._createContainer();
|
||||
if (container) {
|
||||
|
@ -853,7 +1076,9 @@ export class FeatureCallout {
|
|||
await this._addScriptsAndRender();
|
||||
this._observeRender(container);
|
||||
this._addPositionListeners();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -899,7 +1124,7 @@ export class FeatureCallout {
|
|||
* @param {Event} event Triggering event
|
||||
*/
|
||||
_handlePageEventAction(action, event) {
|
||||
const page = this.page;
|
||||
const page = this.location;
|
||||
const message_id = this.config?.id.toUpperCase();
|
||||
const source = this._getUniqueElementIdentifier(event.target);
|
||||
this.win.AWSendEventTelemetry?.({
|
||||
|
@ -958,11 +1183,17 @@ export class FeatureCallout {
|
|||
return source;
|
||||
}
|
||||
|
||||
async showFeatureCallout() {
|
||||
let updated = await this._loadConfig();
|
||||
/**
|
||||
* Show a feature callout message, either by requesting one from ASRouter or
|
||||
* by showing a message passed as an argument.
|
||||
* @param {Object} [message] optional message to show instead of requesting one
|
||||
* @returns {Promise<Boolean>} true if a message was shown
|
||||
*/
|
||||
async showFeatureCallout(message) {
|
||||
let updated = await this._updateConfig(message);
|
||||
|
||||
if (!updated || !this.config?.screens?.length) {
|
||||
return;
|
||||
return !!this.currentScreen;
|
||||
}
|
||||
|
||||
this.renderObserver = new this.win.MutationObserver(() => {
|
||||
|
@ -994,19 +1225,15 @@ export class FeatureCallout {
|
|||
this.ready = false;
|
||||
this._container?.remove();
|
||||
|
||||
// If user has disabled CFR, don't show any callouts. But make sure we load
|
||||
// the necessary stylesheets first, since re-enabling CFR should allow
|
||||
// callouts to be shown without needing to reload. In the future this could
|
||||
// allow adding a CTA to disable recommendations with a label like "Don't show
|
||||
// these again" (or potentially a toggle to re-enable them).
|
||||
if (!this.cfrFeaturesUserPref) {
|
||||
this.currentScreen = null;
|
||||
return;
|
||||
this.endTour();
|
||||
return false;
|
||||
}
|
||||
|
||||
this._addCalloutLinkElements();
|
||||
this._setupWindowFunctions();
|
||||
await this._renderCallout();
|
||||
let rendering = await this._renderCallout();
|
||||
return rendering && !!this.currentScreen;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1062,7 +1289,7 @@ export class FeatureCallout {
|
|||
// part of the content of a page in a browser tab (like PDF.js).
|
||||
this._container.classList.toggle(
|
||||
"simulateContent",
|
||||
!!(this.page === "chrome" && this.theme.simulateContent)
|
||||
!!this.theme.simulateContent
|
||||
);
|
||||
for (const type of ["light", "dark", "hcm"]) {
|
||||
const scheme = this.theme[type];
|
||||
|
|
|
@ -205,7 +205,10 @@ export const SpecialMessageActions = {
|
|||
"cookiebanners.service.detectOnly",
|
||||
];
|
||||
|
||||
if (!allowedPrefs.includes(pref.name)) {
|
||||
if (
|
||||
!allowedPrefs.includes(pref.name) &&
|
||||
!pref.name.startsWith("messaging-system-action.")
|
||||
) {
|
||||
pref.name = `messaging-system-action.${pref.name}`;
|
||||
}
|
||||
// If pref has no value, reset it, otherwise set it to desired value
|
||||
|
|
|
@ -71,19 +71,21 @@ add_setup(async () => {
|
|||
});
|
||||
|
||||
add_task(async function test_CLICK_ELEMENT() {
|
||||
SpecialPowers.pushPrefEnv([
|
||||
"browser.firefox-view.feature-tour",
|
||||
JSON.stringify({
|
||||
screen: "",
|
||||
complete: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const sendTriggerStub = sandbox.stub(ASRouter, "sendTriggerMessage");
|
||||
sendTriggerStub.resolves(TEST_MESSAGE);
|
||||
|
||||
await withFirefoxView({ openNewWindow: true }, async browser => {
|
||||
const { document } = browser.contentWindow;
|
||||
const { FeatureCallout } = ChromeUtils.importESModule(
|
||||
"resource:///modules/FeatureCallout.sys.mjs"
|
||||
);
|
||||
let callout = new FeatureCallout({
|
||||
win: browser.contentWindow,
|
||||
location: "about:firefoxview",
|
||||
context: "content",
|
||||
theme: { preset: "themed-content" },
|
||||
});
|
||||
callout.showFeatureCallout();
|
||||
const calloutSelector = "#multi-stage-message-root.featureCallout";
|
||||
|
||||
await BrowserTestUtils.waitForCondition(() => {
|
||||
|
|
|
@ -201,22 +201,23 @@
|
|||
"id": {
|
||||
"type": "string",
|
||||
"enum": ["featureCalloutCheck"]
|
||||
},
|
||||
"context": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["firefoxview"],
|
||||
"description": "Which about page is the source of the trigger"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"description": "Happens when navigating to about:firefoxview or other about pages with Feature Callout tours enabled"
|
||||
"description": "Used to display Feature Callouts in Firefox View. Can only be used for Feature Callouts."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": ["pdfJsFeatureCalloutCheck"]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"description": "Used to display Feature Callouts on PDF.js pages. Can only be used for Feature Callouts."
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
|
|
|
@ -178,7 +178,11 @@ Watch for changes on any number of preferences. Runs when a pref is added, remov
|
|||
|
||||
### `featureCalloutCheck`
|
||||
|
||||
Happens when navigating to about:firefoxview or other about pages with Feature Callout tours enabled
|
||||
Used to display Feature Callouts in Firefox View. Can only be used for Feature Callouts.
|
||||
|
||||
### `pdfJsFeatureCalloutCheck`
|
||||
|
||||
Used to display Feature Callouts on PDF.js pages. Can only be used for Feature Callouts.
|
||||
|
||||
### `nthTabClosed`
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче