Fix Bug 1470170 - Implement priority system for AS Router messages
This commit is contained in:
Родитель
0287617d8a
Коммит
1a74d11862
|
@ -187,7 +187,13 @@ export class ASRouterUISurface extends React.PureComponent {
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
const endpoint = ASRouterUtils.getEndpoint();
|
const endpoint = ASRouterUtils.getEndpoint();
|
||||||
ASRouterUtils.addListener(this.onMessageFromParent);
|
ASRouterUtils.addListener(this.onMessageFromParent);
|
||||||
ASRouterUtils.sendMessage({type: "CONNECT_UI_REQUEST", data: {endpoint}});
|
|
||||||
|
// If we are loading about:welcome we want to trigger the onboarding messages
|
||||||
|
if (this.props.document.location.href === "about:welcome") {
|
||||||
|
ASRouterUtils.sendMessage({type: "TRIGGER", data: {trigger: "firstRun"}});
|
||||||
|
} else {
|
||||||
|
ASRouterUtils.sendMessage({type: "CONNECT_UI_REQUEST", data: {endpoint}});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
|
|
@ -27,6 +27,10 @@
|
||||||
"targeting": {
|
"targeting": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "a JEXL expression representing targeting information"
|
"description": "a JEXL expression representing targeting information"
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A string representing what the trigger to show this message is."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["id", "template", "content"]
|
"required": ["id", "template", "content"]
|
||||||
|
|
|
@ -232,7 +232,22 @@ class _ASRouter {
|
||||||
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "ADMIN_SET_STATE", data: state});
|
this.messageChannel.sendAsyncMessage(OUTGOING_MESSAGE_NAME, {type: "ADMIN_SET_STATE", data: state});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _getBundledMessages(originalMessage, target, force = false) {
|
async _findMessage(msgs, target, data = {}) {
|
||||||
|
let message;
|
||||||
|
let {trigger} = data;
|
||||||
|
if (trigger) {
|
||||||
|
// Find a message that matches the targeting context as well as the trigger context
|
||||||
|
message = await ASRouterTargeting.findMatchingMessageWithTrigger(msgs, target, trigger);
|
||||||
|
}
|
||||||
|
if (!message) {
|
||||||
|
// If there was no messages with this trigger, try finding a regular targeted message
|
||||||
|
message = await ASRouterTargeting.findMatchingMessage(msgs, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getBundledMessages(originalMessage, target, data, force = false) {
|
||||||
let result = [{content: originalMessage.content, id: originalMessage.id}];
|
let result = [{content: originalMessage.content, id: originalMessage.id}];
|
||||||
|
|
||||||
// First, find all messages of same template. These are potential matching targeting candidates
|
// First, find all messages of same template. These are potential matching targeting candidates
|
||||||
|
@ -251,7 +266,7 @@ class _ASRouter {
|
||||||
} else {
|
} else {
|
||||||
while (bundledMessagesOfSameTemplate.length) {
|
while (bundledMessagesOfSameTemplate.length) {
|
||||||
// Find a message that matches the targeting context - or break if there are no matching messages
|
// Find a message that matches the targeting context - or break if there are no matching messages
|
||||||
const message = await ASRouterTargeting.findMatchingMessage(bundledMessagesOfSameTemplate, target);
|
const message = await this._findMessage(bundledMessagesOfSameTemplate, target, data);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
/* istanbul ignore next */ // Code coverage in mochitests
|
/* istanbul ignore next */ // Code coverage in mochitests
|
||||||
break;
|
break;
|
||||||
|
@ -279,11 +294,11 @@ class _ASRouter {
|
||||||
return state.messages.filter(item => !state.blockList.includes(item.id));
|
return state.messages.filter(item => !state.blockList.includes(item.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async _sendMessageToTarget(message, target, force = false) {
|
async _sendMessageToTarget(message, target, data, force = false) {
|
||||||
let bundledMessages;
|
let bundledMessages;
|
||||||
// If this message needs to be bundled with other messages of the same template, find them and bundle them together
|
// If this message needs to be bundled with other messages of the same template, find them and bundle them together
|
||||||
if (message && message.bundled) {
|
if (message && message.bundled) {
|
||||||
bundledMessages = await this._getBundledMessages(message, target, force);
|
bundledMessages = await this._getBundledMessages(message, target, data, force);
|
||||||
}
|
}
|
||||||
if (message && !message.bundled) {
|
if (message && !message.bundled) {
|
||||||
// If we only need to send 1 message, send the message
|
// If we only need to send 1 message, send the message
|
||||||
|
@ -296,7 +311,8 @@ class _ASRouter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendNextMessage(target) {
|
async sendNextMessage(target, action = {}) {
|
||||||
|
let {data} = action;
|
||||||
const msgs = this._getUnblockedMessages();
|
const msgs = this._getUnblockedMessages();
|
||||||
let message = null;
|
let message = null;
|
||||||
const previewMsgs = this.state.messages.filter(item => item.provider === "preview");
|
const previewMsgs = this.state.messages.filter(item => item.provider === "preview");
|
||||||
|
@ -304,18 +320,18 @@ class _ASRouter {
|
||||||
if (previewMsgs.length) {
|
if (previewMsgs.length) {
|
||||||
[message] = previewMsgs;
|
[message] = previewMsgs;
|
||||||
} else {
|
} else {
|
||||||
message = await ASRouterTargeting.findMatchingMessage(msgs, target);
|
message = await this._findMessage(msgs, target, data);
|
||||||
}
|
}
|
||||||
await this.setState({lastMessageId: message ? message.id : null});
|
await this.setState({lastMessageId: message ? message.id : null});
|
||||||
|
|
||||||
await this._sendMessageToTarget(message, target);
|
await this._sendMessageToTarget(message, target, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMessageById(id, target, force = true) {
|
async setMessageById(id, target, force = true, action = {}) {
|
||||||
await this.setState({lastMessageId: id});
|
await this.setState({lastMessageId: id});
|
||||||
const newMessage = this.getMessageById(id);
|
const newMessage = this.getMessageById(id);
|
||||||
|
|
||||||
await this._sendMessageToTarget(newMessage, target, force);
|
await this._sendMessageToTarget(newMessage, target, force, action.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async blockById(idOrIds) {
|
async blockById(idOrIds) {
|
||||||
|
@ -368,6 +384,7 @@ class _ASRouter {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "CONNECT_UI_REQUEST":
|
case "CONNECT_UI_REQUEST":
|
||||||
case "GET_NEXT_MESSAGE":
|
case "GET_NEXT_MESSAGE":
|
||||||
|
case "TRIGGER":
|
||||||
// Wait for our initial message loading to be done before responding to any UI requests
|
// Wait for our initial message loading to be done before responding to any UI requests
|
||||||
await this.waitForInitialized;
|
await this.waitForInitialized;
|
||||||
if (action.data && action.data.endpoint) {
|
if (action.data && action.data.endpoint) {
|
||||||
|
@ -375,7 +392,7 @@ class _ASRouter {
|
||||||
}
|
}
|
||||||
// Check if any updates are needed first
|
// Check if any updates are needed first
|
||||||
await this.loadMessagesFromAllProviders();
|
await this.loadMessagesFromAllProviders();
|
||||||
await this.sendNextMessage(target);
|
await this.sendNextMessage(target, action);
|
||||||
break;
|
break;
|
||||||
case ra.OPEN_PRIVATE_BROWSER_WINDOW:
|
case ra.OPEN_PRIVATE_BROWSER_WINDOW:
|
||||||
// Forcefully open about:privatebrowsing
|
// Forcefully open about:privatebrowsing
|
||||||
|
@ -414,7 +431,7 @@ class _ASRouter {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "OVERRIDE_MESSAGE":
|
case "OVERRIDE_MESSAGE":
|
||||||
await this.setMessageById(action.data.id, target);
|
await this.setMessageById(action.data.id, target, true, action);
|
||||||
break;
|
break;
|
||||||
case "ADMIN_CONNECT_STATE":
|
case "ADMIN_CONNECT_STATE":
|
||||||
if (action.data && action.data.endpoint) {
|
if (action.data && action.data.endpoint) {
|
||||||
|
|
|
@ -33,15 +33,11 @@ const TargetingGetters = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function EnvironmentTargeting(target) {
|
|
||||||
return {isFirstRun: target.url === "about:welcome"};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ASRouterTargeting = {
|
this.ASRouterTargeting = {
|
||||||
Environment: TargetingGetters,
|
Environment: TargetingGetters,
|
||||||
|
|
||||||
isMatch(filterExpression, target, context = this.Environment) {
|
isMatch(filterExpression, target, context = this.Environment) {
|
||||||
return FilterExpressions.eval(filterExpression, {...context, ...EnvironmentTargeting(target)});
|
return FilterExpressions.eval(filterExpression, context);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -58,7 +54,20 @@ this.ASRouterTargeting = {
|
||||||
let candidate;
|
let candidate;
|
||||||
while (!match && arrayOfItems.length) {
|
while (!match && arrayOfItems.length) {
|
||||||
candidate = removeRandomItemFromArray(arrayOfItems);
|
candidate = removeRandomItemFromArray(arrayOfItems);
|
||||||
if (candidate && (!candidate.targeting || await this.isMatch(candidate.targeting, target, context))) {
|
if (candidate && !candidate.trigger && (!candidate.targeting || await this.isMatch(candidate.targeting, target, context))) {
|
||||||
|
match = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
},
|
||||||
|
|
||||||
|
async findMatchingMessageWithTrigger(messages, target, trigger, context) {
|
||||||
|
const arrayOfItems = [...messages];
|
||||||
|
let match;
|
||||||
|
let candidate;
|
||||||
|
while (!match && arrayOfItems.length) {
|
||||||
|
candidate = removeRandomItemFromArray(arrayOfItems);
|
||||||
|
if (candidate && candidate.trigger === trigger && (!candidate.targeting || await this.isMatch(candidate.targeting, target, context))) {
|
||||||
match = candidate;
|
match = candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ const ONBOARDING_MESSAGES = [
|
||||||
button_label: "Try It Now",
|
button_label: "Try It Now",
|
||||||
button_action: "OPEN_PRIVATE_BROWSER_WINDOW"
|
button_action: "OPEN_PRIVATE_BROWSER_WINDOW"
|
||||||
},
|
},
|
||||||
targeting: "isFirstRun"
|
trigger: "firstRun"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ONBOARDING_2",
|
id: "ONBOARDING_2",
|
||||||
|
@ -29,7 +29,7 @@ const ONBOARDING_MESSAGES = [
|
||||||
button_action: "OPEN_URL",
|
button_action: "OPEN_URL",
|
||||||
button_action_params: "https://screenshots.firefox.com/#tour"
|
button_action_params: "https://screenshots.firefox.com/#tour"
|
||||||
},
|
},
|
||||||
targeting: "isFirstRun"
|
trigger: "firstRun"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ONBOARDING_3",
|
id: "ONBOARDING_3",
|
||||||
|
@ -43,7 +43,8 @@ const ONBOARDING_MESSAGES = [
|
||||||
button_action: "OPEN_ABOUT_PAGE",
|
button_action: "OPEN_ABOUT_PAGE",
|
||||||
button_action_params: "addons"
|
button_action_params: "addons"
|
||||||
},
|
},
|
||||||
targeting: "isFirstRun && isInExperimentCohort == 1"
|
targeting: "isInExperimentCohort == 1",
|
||||||
|
trigger: "firstRun"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ONBOARDING_4",
|
id: "ONBOARDING_4",
|
||||||
|
@ -57,7 +58,8 @@ const ONBOARDING_MESSAGES = [
|
||||||
button_action: "OPEN_URL",
|
button_action: "OPEN_URL",
|
||||||
button_action_params: "https://addons.mozilla.org/en-US/firefox/addon/ghostery/"
|
button_action_params: "https://addons.mozilla.org/en-US/firefox/addon/ghostery/"
|
||||||
},
|
},
|
||||||
targeting: "isFirstRun && isInExperimentCohort == 2"
|
targeting: "isInExperimentCohort == 2",
|
||||||
|
trigger: "firstRun"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -145,7 +145,7 @@ describe("ASRouter", () => {
|
||||||
await Router.setState(() => ({blockList: ALL_MESSAGE_IDS.slice(1)}));
|
await Router.setState(() => ({blockList: ALL_MESSAGE_IDS.slice(1)}));
|
||||||
const targetStub = {sendAsyncMessage: sandbox.stub()};
|
const targetStub = {sendAsyncMessage: sandbox.stub()};
|
||||||
|
|
||||||
await Router.sendNextMessage(targetStub, null);
|
await Router.sendNextMessage(targetStub);
|
||||||
|
|
||||||
assert.calledOnce(targetStub.sendAsyncMessage);
|
assert.calledOnce(targetStub.sendAsyncMessage);
|
||||||
assert.equal(Router.state.lastMessageId, ALL_MESSAGE_IDS[0]);
|
assert.equal(Router.state.lastMessageId, ALL_MESSAGE_IDS[0]);
|
||||||
|
@ -154,7 +154,7 @@ describe("ASRouter", () => {
|
||||||
await Router.setState(() => ({blockList: ALL_MESSAGE_IDS}));
|
await Router.setState(() => ({blockList: ALL_MESSAGE_IDS}));
|
||||||
const targetStub = {sendAsyncMessage: sandbox.stub()};
|
const targetStub = {sendAsyncMessage: sandbox.stub()};
|
||||||
|
|
||||||
await Router.sendNextMessage(targetStub, null);
|
await Router.sendNextMessage(targetStub);
|
||||||
|
|
||||||
assert.calledOnce(targetStub.sendAsyncMessage);
|
assert.calledOnce(targetStub.sendAsyncMessage);
|
||||||
assert.equal(Router.state.lastMessageId, null);
|
assert.equal(Router.state.lastMessageId, null);
|
||||||
|
@ -331,7 +331,7 @@ describe("ASRouter", () => {
|
||||||
await Router.onMessage(msg);
|
await Router.onMessage(msg);
|
||||||
|
|
||||||
assert.calledOnce(Router.sendNextMessage);
|
assert.calledOnce(Router.sendNextMessage);
|
||||||
assert.calledWithExactly(Router.sendNextMessage, sinon.match.instanceOf(FakeRemotePageManager));
|
assert.calledWithExactly(Router.sendNextMessage, sinon.match.instanceOf(FakeRemotePageManager), {type: "CONNECT_UI_REQUEST"});
|
||||||
});
|
});
|
||||||
it("should call sendNextMessage on GET_NEXT_MESSAGE", async () => {
|
it("should call sendNextMessage on GET_NEXT_MESSAGE", async () => {
|
||||||
sandbox.stub(Router, "sendNextMessage").resolves();
|
sandbox.stub(Router, "sendNextMessage").resolves();
|
||||||
|
@ -340,7 +340,7 @@ describe("ASRouter", () => {
|
||||||
await Router.onMessage(msg);
|
await Router.onMessage(msg);
|
||||||
|
|
||||||
assert.calledOnce(Router.sendNextMessage);
|
assert.calledOnce(Router.sendNextMessage);
|
||||||
assert.calledWithExactly(Router.sendNextMessage, sinon.match.instanceOf(FakeRemotePageManager));
|
assert.calledWithExactly(Router.sendNextMessage, sinon.match.instanceOf(FakeRemotePageManager), {type: "GET_NEXT_MESSAGE"});
|
||||||
});
|
});
|
||||||
it("should return the preview message if that's available", async () => {
|
it("should return the preview message if that's available", async () => {
|
||||||
const expectedObj = {provider: "preview"};
|
const expectedObj = {provider: "preview"};
|
||||||
|
@ -398,6 +398,39 @@ describe("ASRouter", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("#onMessage: TRIGGER", () => {
|
||||||
|
it("should pass the trigger to ASRouterTargeting on TRIGGER message", async () => {
|
||||||
|
sandbox.stub(Router, "_findMessage").resolves();
|
||||||
|
const msg = fakeAsyncMessage({type: "TRIGGER", data: {trigger: "firstRun"}});
|
||||||
|
await Router.onMessage(msg);
|
||||||
|
|
||||||
|
assert.calledOnce(Router._findMessage);
|
||||||
|
assert.deepEqual(Router._findMessage.firstCall.args[2], {trigger: "firstRun"});
|
||||||
|
});
|
||||||
|
it("consider the trigger when picking a message", async () => {
|
||||||
|
let messages = [
|
||||||
|
{id: "foo1", template: "simple_template", bundled: 1, trigger: "foo", content: {title: "Foo1", body: "Foo123-1"}}
|
||||||
|
];
|
||||||
|
|
||||||
|
const {target, data} = fakeAsyncMessage({type: "TRIGGER", data: {trigger: "foo"}});
|
||||||
|
let message = await Router._findMessage(messages, target, data.data);
|
||||||
|
assert.equal(message, messages[0]);
|
||||||
|
});
|
||||||
|
it("should pick a message with the right targeting and trigger", async () => {
|
||||||
|
let messages = [
|
||||||
|
{id: "foo1", template: "simple_template", bundled: 2, trigger: "foo", content: {title: "Foo1", body: "Foo123-1"}},
|
||||||
|
{id: "foo2", template: "simple_template", bundled: 2, trigger: "bar", content: {title: "Foo2", body: "Foo123-2"}},
|
||||||
|
{id: "foo3", template: "simple_template", bundled: 2, trigger: "foo", content: {title: "Foo3", body: "Foo123-3"}}
|
||||||
|
];
|
||||||
|
await Router.setState({messages});
|
||||||
|
const {target, data} = fakeAsyncMessage({type: "TRIGGER", data: {trigger: "foo"}});
|
||||||
|
let {bundle} = await Router._getBundledMessages(messages[0], target, data.data);
|
||||||
|
assert.equal(bundle.length, 2);
|
||||||
|
// it should have picked foo1 and foo3 only
|
||||||
|
assert.isTrue(bundle.every(elem => elem.id === "foo1" || elem.id === "foo3"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("#onMessage: OVERRIDE_MESSAGE", () => {
|
describe("#onMessage: OVERRIDE_MESSAGE", () => {
|
||||||
it("should broadcast a SET_MESSAGE message to all clients with a particular id", async () => {
|
it("should broadcast a SET_MESSAGE message to all clients with a particular id", async () => {
|
||||||
const [testMessage] = Router.state.messages;
|
const [testMessage] = Router.state.messages;
|
||||||
|
|
|
@ -43,6 +43,7 @@ describe("ASRouterUISurface", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sandbox = sinon.sandbox.create();
|
sandbox = sinon.sandbox.create();
|
||||||
fakeDocument = {
|
fakeDocument = {
|
||||||
|
location: {href: ""},
|
||||||
_listeners: new Set(),
|
_listeners: new Set(),
|
||||||
_visibilityState: "hidden",
|
_visibilityState: "hidden",
|
||||||
get visibilityState() {
|
get visibilityState() {
|
||||||
|
|
Загрузка…
Ссылка в новой задаче