Bug 1561536 - Add new message schema and template type for feature callouts (#5133)

This commit is contained in:
Andrei Oprea 2019-07-04 11:23:45 +00:00
Родитель 39b6db8257
Коммит 27b795648d
14 изменённых файлов: 531 добавлений и 14 удалений

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

@ -34,6 +34,7 @@ Please note that some targeting attributes require stricter controls on the tele
* [isFxAEnabled](#isFxAEnabled)
* [xpinstallEnabled](#xpinstallEnabled)
* [hasPinnedTabs](#haspinnedtabs)
* [hasAccessedFxAPanel](#hasaccessedfxapanel)
## Detailed usage
@ -474,3 +475,13 @@ Does the user have any pinned tabs in any windows.
```ts
declare const hasPinnedTabs: boolean;
```
### `hasAccessedFxAPanel`
Boolean pref that gets set the first time the user opens the FxA toolbar panel
#### Definition
```ts
declare const hasAccessedFxAPanel: boolean;
```

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

@ -0,0 +1,13 @@
{
"title": "ToolbarBadgeMessage",
"description": "A template that specifies to which element in the browser toolbar to add a notification.",
"version": "1.0.0",
"type": "object",
"properties": {
"target": {
"type": "string"
}
},
"additionalProperties": false,
"required": ["target"]
}

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

@ -18,6 +18,7 @@ XPCOMUtils.defineLazyModuleGetters(this, {
SnippetsTestMessageProvider:
"resource://activity-stream/lib/SnippetsTestMessageProvider.jsm",
PanelTestProvider: "resource://activity-stream/lib/PanelTestProvider.jsm",
ToolbarBadgeHub: "resource://activity-stream/lib/ToolbarBadgeHub.jsm",
});
const {
ASRouterActions: ra,
@ -491,11 +492,13 @@ class _ASRouter {
};
this._triggerHandler = this._triggerHandler.bind(this);
this._localProviders = localProviders;
this.blockMessageById = this.blockMessageById.bind(this);
this.onMessage = this.onMessage.bind(this);
this.handleMessageRequest = this.handleMessageRequest.bind(this);
this.addImpression = this.addImpression.bind(this);
this._handleTargetingError = this._handleTargetingError.bind(this);
this.onPrefChange = this.onPrefChange.bind(this);
this.dispatch = this.dispatch.bind(this);
}
async onPrefChange(prefName) {
@ -712,7 +715,6 @@ class _ASRouter {
this._storage = storage;
this.WHITELIST_HOSTS = this._loadSnippetsWhitelistHosts();
this.dispatchToAS = dispatchToAS;
this.dispatch = this.dispatch.bind(this);
ASRouterPreferences.init();
ASRouterPreferences.addListener(this.onPrefChange);
@ -721,6 +723,11 @@ class _ASRouter {
this.addImpression,
this.dispatch
);
ToolbarBadgeHub.init(this.waitForInitialized, {
handleMessageRequest: this.handleMessageRequest,
addImpression: this.addImpression,
blockMessageById: this.blockMessageById,
});
this._loadLocalProviders();
@ -1253,6 +1260,9 @@ class _ASRouter {
BookmarkPanelHub._forceShowMessage(target, message);
}
break;
case "toolbar_badge":
ToolbarBadgeHub.registerBadgeNotificationListener(message);
break;
default:
target.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {
type: "SET_MESSAGE",
@ -1446,12 +1456,18 @@ class _ASRouter {
await this._sendMessageToTarget(message, target, trigger);
}
handleMessageRequest(trigger) {
const msgs = this._getUnblockedMessages();
return this._findMessage(
msgs.filter(m => m.trigger && m.trigger.id === trigger.id),
trigger
);
handleMessageRequest({ triggerId, template }) {
const msgs = this._getUnblockedMessages().filter(m => {
if (template && m.template !== template) {
return false;
}
if (m.trigger && m.trigger.id !== triggerId) {
return false;
}
return true;
});
return this._findMessage(msgs, { id: triggerId });
}
async setMessageById(id, target, force = true, action = {}) {

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

@ -359,6 +359,12 @@ const TargetingGetters = {
return false;
},
get hasAccessedFxAPanel() {
return Services.prefs.getBoolPref(
"identity.fxaccounts.toolbar.accessed",
true
);
},
};
this.ASRouterTargeting = {

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

@ -85,7 +85,9 @@ class _BookmarkPanelHub {
// If we didn't match on a previously cached request then make sure
// the container is empty
this._removeContainer(target);
const response = await this._handleMessageRequest(this._trigger);
const response = await this._handleMessageRequest({
triggerId: this._trigger.id,
});
return this.onResponse(response, target, win);
}

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

@ -465,6 +465,16 @@ const ONBOARDING_MESSAGES = async () => [
"attributionData.campaign == 'non-fx-button' && attributionData.source == 'addons.mozilla.org'",
trigger: { id: "firstRun" },
},
{
id: "FXA_ACCOUNTS_BADGE",
template: "toolbar_badge",
content: {
target: "fxa-toolbar-menu-button",
},
// Never accessed the FxA panel && doesn't use Firefox sync & has FxA enabled
targeting: `!hasAccessedFxAPanel && !usesFirefoxSync && isFxAEnabled == true`,
trigger: { id: "toolbarBadgeUpdate" },
},
];
const OnboardingMessageProvider = {

114
lib/ToolbarBadgeHub.jsm Normal file
Просмотреть файл

@ -0,0 +1,114 @@
/* 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/. */
"use strict";
ChromeUtils.defineModuleGetter(
this,
"EveryWindow",
"resource:///modules/EveryWindow.jsm"
);
const notificationsByWindow = new WeakMap();
class _ToolbarBadgeHub {
constructor() {
this.id = "toolbar-badge-hub";
this.template = "toolbar_badge";
this.state = null;
this.removeAllNotifications = this.removeAllNotifications.bind(this);
this.removeToolbarNotification = this.removeToolbarNotification.bind(this);
this.addToolbarNotification = this.addToolbarNotification.bind(this);
this._handleMessageRequest = null;
this._addImpression = null;
this._blockMessageById = null;
}
async init(
waitForInitialized,
{ handleMessageRequest, addImpression, blockMessageById }
) {
this._handleMessageRequest = handleMessageRequest;
this._blockMessageById = blockMessageById;
this._addImpression = addImpression;
// Need to wait for ASRouter to initialize before trying to fetch messages
await waitForInitialized;
this.messageRequest("toolbarBadgeUpdate");
}
removeAllNotifications() {
// Will call uninit on every window
EveryWindow.unregisterCallback(this.id);
this._blockMessageById(this.state.notification.id);
this.state = null;
}
removeToolbarNotification(toolbarButton) {
toolbarButton
.querySelector(".toolbarbutton-badge")
.removeAttribute("value");
toolbarButton.removeAttribute("badged");
}
addToolbarNotification(win, message) {
const document = win.browser.ownerDocument;
let toolbarbutton = document.getElementById(message.content.target);
if (toolbarbutton) {
toolbarbutton.setAttribute("badged", true);
toolbarbutton
.querySelector(".toolbarbutton-badge")
.setAttribute("value", "x");
toolbarbutton.addEventListener("click", this.removeAllNotifications, {
once: true,
});
this.state = { notification: { id: message.id } };
return toolbarbutton;
}
return null;
}
registerBadgeNotificationListener(message) {
this._addImpression(message);
EveryWindow.registerCallback(
this.id,
win => {
if (notificationsByWindow.has(win)) {
// nothing to do
return;
}
const el = this.addToolbarNotification(win, message);
notificationsByWindow.set(win, el);
},
win => {
const el = notificationsByWindow.get(win);
this.removeToolbarNotification(el);
notificationsByWindow.delete(win);
}
);
}
async messageRequest(triggerId) {
const message = await this._handleMessageRequest({
triggerId,
template: this.template,
});
if (message) {
this.registerBadgeNotificationListener(message);
}
}
}
this._ToolbarBadgeHub = _ToolbarBadgeHub;
/**
* ToolbarBadgeHub - singleton instance of _ToolbarBadgeHub that can initiate
* message requests and render messages.
*/
this.ToolbarBadgeHub = new _ToolbarBadgeHub();
const EXPORTED_SYMBOLS = ["ToolbarBadgeHub", "_ToolbarBadgeHub"];

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

@ -807,6 +807,22 @@ add_task(async function check_pinned_tabs() {
);
});
add_task(async function check_hasAccessedFxAPanel() {
is(
await ASRouterTargeting.Environment.hasAccessedFxAPanel,
false,
"Not accessed yet"
);
await pushPrefs(["identity.fxaccounts.toolbar.accessed", true]);
is(
await ASRouterTargeting.Environment.hasAccessedFxAPanel,
true,
"Should detect panel access"
);
});
add_task(async function checkCFRPinnedTabsTargetting() {
const now = Date.now();
const timeMinutesAgo = numMinutes => now - numMinutes * 60 * 1000;

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

@ -66,6 +66,7 @@ describe("ASRouter", () => {
let dispatchStub;
let fakeAttributionCode;
let FakeBookmarkPanelHub;
let FakeToolbarBadgeHub;
function createFakeStorage() {
const getStub = sandbox.stub();
@ -139,6 +140,10 @@ describe("ASRouter", () => {
uninit: sandbox.stub(),
_forceShowMessage: sandbox.stub(),
};
FakeToolbarBadgeHub = {
init: sandbox.stub(),
registerBadgeNotificationListener: sandbox.stub(),
};
globals.set({
AttributionCode: fakeAttributionCode,
// Testing framework doesn't know how to `defineLazyModuleGetter` so we're
@ -146,6 +151,7 @@ describe("ASRouter", () => {
SnippetsTestMessageProvider,
PanelTestProvider,
BookmarkPanelHub: FakeBookmarkPanelHub,
ToolbarBadgeHub: FakeToolbarBadgeHub,
});
await createRouterAndInit();
});
@ -412,6 +418,75 @@ describe("ASRouter", () => {
});
});
describe("#routeMessageToTarget", () => {
let target;
beforeEach(() => {
sandbox.stub(CFRPageActions, "forceRecommendation");
sandbox.stub(CFRPageActions, "addRecommendation");
target = { sendAsyncMessage: sandbox.stub() };
});
it("should route toolbar_badge message to the right hub", () => {
Router.routeMessageToTarget({ template: "toolbar_badge" }, target);
assert.calledOnce(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(target.sendAsyncMessage);
});
it("should route fxa_bookmark_panel message to the right hub force = true", () => {
Router.routeMessageToTarget(
{ template: "fxa_bookmark_panel" },
target,
{},
true
);
assert.calledOnce(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(target.sendAsyncMessage);
});
it("should route cfr_doorhanger message to the right hub force = false", () => {
Router.routeMessageToTarget(
{ template: "cfr_doorhanger" },
target,
{ param: {} },
false
);
assert.calledOnce(CFRPageActions.addRecommendation);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(target.sendAsyncMessage);
});
it("should route cfr_doorhanger message to the right hub force = true", () => {
Router.routeMessageToTarget(
{ template: "cfr_doorhanger" },
target,
{},
true
);
assert.calledOnce(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
assert.notCalled(target.sendAsyncMessage);
});
it("should route default to sending to content", () => {
Router.routeMessageToTarget({ template: "snippets" }, target, {}, true);
assert.calledOnce(target.sendAsyncMessage);
assert.notCalled(CFRPageActions.forceRecommendation);
assert.notCalled(CFRPageActions.addRecommendation);
assert.notCalled(FakeBookmarkPanelHub._forceShowMessage);
assert.notCalled(FakeToolbarBadgeHub.registerBadgeNotificationListener);
});
});
describe("#loadMessagesFromAllProviders", () => {
function assertRouterContainsMessages(messages) {
const messageIdsInRouter = Router.state.messages.map(m => m.id);
@ -691,18 +766,47 @@ describe("ASRouter", () => {
describe("#handleMessageRequest", () => {
it("should get unblocked messages that match the trigger", async () => {
const message = {
const message1 = {
id: "1",
campaign: "foocampaign",
trigger: { id: "foo" },
};
await Router.setState({ messages: [message] });
const message2 = {
id: "2",
campaign: "foocampaign",
trigger: { id: "bar" },
};
await Router.setState({ messages: [message2, message1] });
// Just return the first message provided as arg
sandbox.stub(Router, "_findMessage").callsFake(messages => messages[0]);
const result = Router.handleMessageRequest({ id: "foo" });
const result = Router.handleMessageRequest({ triggerId: "foo" });
assert.deepEqual(result, message);
assert.deepEqual(result, message1);
});
it("should get unblocked messages that match trigger and template", async () => {
const message1 = {
id: "1",
campaign: "foocampaign",
template: "badge",
trigger: { id: "foo" },
};
const message2 = {
id: "2",
campaign: "foocampaign",
template: "snippet",
trigger: { id: "foo" },
};
await Router.setState({ messages: [message2, message1] });
// Just return the first message provided as arg
sandbox.stub(Router, "_findMessage").callsFake(messages => messages[0]);
const result = Router.handleMessageRequest({
triggerId: "foo",
template: "badge",
});
assert.deepEqual(result, message1);
});
});

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

@ -12,6 +12,7 @@ describe("ASRouterFeed", () => {
let storage;
let globals;
let FakeBookmarkPanelHub;
let FakeToolbarBadgeHub;
beforeEach(() => {
sandbox = sinon.createSandbox();
globals = new GlobalOverrider();
@ -19,7 +20,11 @@ describe("ASRouterFeed", () => {
init: sandbox.stub(),
uninit: sandbox.stub(),
};
FakeToolbarBadgeHub = {
init: sandbox.stub(),
};
globals.set("BookmarkPanelHub", FakeBookmarkPanelHub);
globals.set("ToolbarBadgeHub", FakeToolbarBadgeHub);
Router = new _ASRouter({ providers: [FAKE_LOCAL_PROVIDER] });
storage = {

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

@ -1,6 +1,7 @@
import { GlobalOverrider } from "test/unit/utils";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
import schema from "content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json";
import badgeSchema from "content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json";
const DEFAULT_CONTENT = {
title: "A title",
@ -61,6 +62,13 @@ describe("OnboardingMessage", () => {
.filter(msg => msg.template in ["onboarding", "return_to_amo_overlay"])
.forEach(msg => assert.jsonSchema(msg.content, schema));
});
it("should validate all badge template messages", async () => {
const messages = await OnboardingMessageProvider.getUntranslatedMessages();
messages
.filter(msg => msg.template === "toolbar_badge")
.forEach(msg => assert.jsonSchema(msg.content, badgeSchema));
});
it("should decode the content field (double decoding)", async () => {
const fakeContent = "foo%2540bar.org";
globals.set("AttributionCode", {

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

@ -131,7 +131,9 @@ describe("BookmarkPanelHub", () => {
await instance.messageRequest(fakeTarget, {});
assert.calledOnce(fakeHandleMessageRequest);
assert.calledWithExactly(fakeHandleMessageRequest, instance._trigger);
assert.calledWithExactly(fakeHandleMessageRequest, {
triggerId: instance._trigger.id,
});
});
it("should call onResponse", async () => {
fakeHandleMessageRequest.resolves(fakeMessage);

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

@ -0,0 +1,206 @@
import { _ToolbarBadgeHub } from "lib/ToolbarBadgeHub.jsm";
import { GlobalOverrider } from "test/unit/utils";
import { OnboardingMessageProvider } from "lib/OnboardingMessageProvider.jsm";
describe("BookmarkPanelHub", () => {
let sandbox;
let instance;
let fakeAddImpression;
let fxaMessage;
let fakeElement;
let globals;
let everyWindowStub;
beforeEach(async () => {
globals = new GlobalOverrider();
sandbox = sinon.createSandbox();
instance = new _ToolbarBadgeHub();
fakeAddImpression = sandbox.stub();
[
,
,
,
,
,
,
fxaMessage,
] = await OnboardingMessageProvider.getUntranslatedMessages();
fakeElement = {
setAttribute: sandbox.stub(),
removeAttribute: sandbox.stub(),
querySelector: sandbox.stub(),
addEventListener: sandbox.stub(),
};
// Share the same element when selecting child nodes
fakeElement.querySelector.returns(fakeElement);
everyWindowStub = {
registerCallback: sandbox.stub(),
unregisterCallback: sandbox.stub(),
};
globals.set("EveryWindow", everyWindowStub);
});
afterEach(() => {
sandbox.restore();
});
it("should create an instance", () => {
assert.ok(instance);
});
describe("#init", () => {
it("should make a messageRequest on init", async () => {
sandbox.stub(instance, "messageRequest");
const waitForInitialized = sandbox.stub().resolves();
await instance.init(waitForInitialized, {});
assert.calledOnce(instance.messageRequest);
assert.calledWithExactly(instance.messageRequest, "toolbarBadgeUpdate");
});
});
describe("messageRequest", () => {
let handleMessageRequestStub;
beforeEach(() => {
handleMessageRequestStub = sandbox.stub().returns(fxaMessage);
sandbox
.stub(instance, "_handleMessageRequest")
.value(handleMessageRequestStub);
sandbox.stub(instance, "registerBadgeNotificationListener");
});
it("should fetch a message with the provided trigger and template", async () => {
await instance.messageRequest("trigger");
assert.calledOnce(handleMessageRequestStub);
assert.calledWithExactly(handleMessageRequestStub, {
triggerId: "trigger",
template: instance.template,
});
});
it("should call addToolbarNotification with browser window and message", async () => {
await instance.messageRequest("trigger");
assert.calledOnce(instance.registerBadgeNotificationListener);
assert.calledWithExactly(
instance.registerBadgeNotificationListener,
fxaMessage
);
});
it("shouldn't do anything if no message is provided", () => {
handleMessageRequestStub.returns(null);
instance.messageRequest("trigger");
assert.notCalled(instance.registerBadgeNotificationListener);
});
});
describe("addToolbarNotification", () => {
let target;
let fakeDocument;
beforeEach(() => {
fakeDocument = { getElementById: sandbox.stub().returns(fakeElement) };
target = { browser: { ownerDocument: fakeDocument } };
});
it("shouldn't do anything if target element is not found", () => {
fakeDocument.getElementById.returns(null);
instance.addToolbarNotification(target, fxaMessage);
assert.notCalled(fakeElement.setAttribute);
});
it("should target the element specified in the message", () => {
instance.addToolbarNotification(target, fxaMessage);
assert.calledOnce(fakeDocument.getElementById);
assert.calledWithExactly(
fakeDocument.getElementById,
fxaMessage.content.target
);
});
it("should show a notification", () => {
instance.addToolbarNotification(target, fxaMessage);
assert.calledTwice(fakeElement.setAttribute);
assert.calledWithExactly(fakeElement.setAttribute, "badged", true);
assert.calledWithExactly(fakeElement.setAttribute, "value", "x");
});
it("should attach a cb on the notification", () => {
instance.addToolbarNotification(target, fxaMessage);
assert.calledOnce(fakeElement.addEventListener);
assert.calledWithExactly(
fakeElement.addEventListener,
"click",
instance.removeAllNotifications,
{ once: true }
);
});
});
describe("registerBadgeNotificationListener", () => {
beforeEach(() => {
sandbox.stub(instance, "_addImpression").value(fakeAddImpression);
sandbox.stub(instance, "addToolbarNotification").returns(fakeElement);
sandbox.stub(instance, "removeToolbarNotification");
});
it("should add an impression for the message", () => {
instance.registerBadgeNotificationListener(fxaMessage);
assert.calledOnce(instance._addImpression);
assert.calledWithExactly(instance._addImpression, fxaMessage);
});
it("should register a callback that adds/removes the notification", () => {
instance.registerBadgeNotificationListener(fxaMessage);
assert.calledOnce(everyWindowStub.registerCallback);
assert.calledWithExactly(
everyWindowStub.registerCallback,
instance.id,
sinon.match.func,
sinon.match.func
);
const [
,
initFn,
uninitFn,
] = everyWindowStub.registerCallback.firstCall.args;
initFn(window);
// Test that it doesn't try to add a second notification
initFn(window);
assert.calledOnce(instance.addToolbarNotification);
assert.calledWithExactly(
instance.addToolbarNotification,
window,
fxaMessage
);
uninitFn(window);
assert.calledOnce(instance.removeToolbarNotification);
assert.calledWithExactly(instance.removeToolbarNotification, fakeElement);
});
});
describe("removeToolbarNotification", () => {
it("should remove the notification", () => {
instance.removeToolbarNotification(fakeElement);
assert.calledTwice(fakeElement.removeAttribute);
assert.calledWithExactly(fakeElement.removeAttribute, "badged");
});
});
describe("removeAllNotifications", () => {
let blockMessageByIdStub;
beforeEach(() => {
blockMessageByIdStub = sandbox.stub();
sandbox.stub(instance, "_blockMessageById").value(blockMessageByIdStub);
instance.state = { notification: { id: fxaMessage.id } };
});
it("should call to block the message", () => {
instance.removeAllNotifications();
assert.calledOnce(blockMessageByIdStub);
assert.calledWithExactly(blockMessageByIdStub, fxaMessage.id);
});
it("should remove the window listener", () => {
instance.removeAllNotifications();
assert.calledOnce(everyWindowStub.unregisterCallback);
assert.calledWithExactly(everyWindowStub.unregisterCallback, instance.id);
});
});
});

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

@ -282,7 +282,11 @@ const TEST_GLOBAL = {
createNullPrincipal() {},
getSystemPrincipal() {},
},
wm: { getMostRecentWindow: () => window, getEnumerator: () => [] },
wm: {
getMostRecentWindow: () => window,
getMostRecentBrowserWindow: () => window,
getEnumerator: () => [],
},
ww: { registerNotification() {}, unregisterNotification() {} },
appinfo: { appBuildID: "20180710100040" },
},