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:
Shane Hughes 2023-07-11 16:00:40 +00:00
Родитель d981109bac
Коммит 9c7a1ae2e1
22 изменённых файлов: 1449 добавлений и 642 удалений

Просмотреть файл

@ -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`