Bug 1571763 - Message template for tracking protection report in what's new panel (#5267)

This commit is contained in:
Andrei Oprea 2019-08-23 17:35:33 +00:00 коммит произвёл Ed Lee
Родитель 84639b9959
Коммит 63a5f79e8a
11 изменённых файлов: 358 добавлений и 111 удалений

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

@ -38,6 +38,7 @@ Please note that some targeting attributes require stricter controls on the tele
* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled)
* [earliestFirefoxVersion](#earliestfirefoxversion)
* [isFxABadgeEnabled](#isfxabadgeenabled)
* [totalBlockedCount](#totalblockedcount)
## Detailed usage
@ -518,3 +519,13 @@ Boolean pref that controls if the FxA toolbar button is badged by Messaging Syst
```ts
declare const isFxABadgeEnabled: boolean;
```
### `totalBlockedCount`
Total number of events from the content blocking database
#### Definition
```ts
declare const totalBlockedCount: number;
```

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

@ -24,6 +24,10 @@
}
},
"properties": {
"layout": {
"description": "Different message layouts",
"enum": ["tracking-protections"]
},
"published_date": {
"type": "integer",
"description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
@ -34,6 +38,12 @@
{"description": "Id of localized string or message override of What's New message title"}
]
},
"subtitle": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
{"description": "Id of localized string or message override of What's New message subtitle"}
]
},
"body": {
"allOf": [
{"$ref": "#/definitions/localizableText"},
@ -51,6 +61,10 @@
"type": "string",
"format": "uri"
},
"cta_type": {
"description": "Type of url open action",
"enum": ["OPEN_URL", "OPEN_ABOUT_PAGE"]
},
"icon_url": {
"description": "(optional) URL for the What's New message icon.",
"type": "string",

Двоичные данные
data/content/assets/protection-report-icon.png Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 4.2 KiB

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

@ -742,6 +742,7 @@ class _ASRouter {
ToolbarPanelHub.init(this.waitForInitialized, {
getMessages: this.handleMessageRequest,
dispatch: this.dispatch,
handleUserAction: this.handleUserAction,
});
this._loadLocalProviders();

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

@ -56,6 +56,12 @@ XPCOMUtils.defineLazyServiceGetter(
"@mozilla.org/updates/update-manager;1",
"nsIUpdateManager"
);
XPCOMUtils.defineLazyServiceGetter(
this,
"TrackingDBService",
"@mozilla.org/tracking-db-service;1",
"nsITrackingDBService"
);
const FXA_USERNAME_PREF = "services.sync.username";
const FXA_ENABLED_PREF = "identity.fxaccounts.enabled";
@ -422,6 +428,9 @@ const TargetingGetters = {
false
);
},
get totalBlockedCount() {
return TrackingDBService.sumAllEvents();
},
};
this.ASRouterTargeting = {

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

@ -407,6 +407,7 @@ const ONBOARDING_MESSAGES = () => [
cta_url: `${Services.urlFormatter.formatURLPref(
"app.support.baseURL"
)}etp-promotions?as=u&utm_source=inproduct`,
cta_type: "OPEN_URL",
},
trigger: { id: "protectionsPanelOpen" },
},

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

@ -66,6 +66,7 @@ const MESSAGES = () => [
{
id: "WHATS_NEW_70_1",
template: "whatsnew_panel_message",
order: 3,
content: {
published_date: 1560969794394,
title: "Protection Is Our Focus",
@ -75,6 +76,7 @@ const MESSAGES = () => [
body:
"The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
cta_url: "https://blog.mozilla.org/",
cta_type: "OPEN_URL",
},
targeting: `firefoxVersion > 69`,
trigger: { id: "whatsNewPanelOpened" },
@ -82,6 +84,7 @@ const MESSAGES = () => [
{
id: "WHATS_NEW_70_2",
template: "whatsnew_panel_message",
order: 1,
content: {
published_date: 1560969794394,
title: "Another thing new in Firefox 70",
@ -89,6 +92,7 @@ const MESSAGES = () => [
"The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
link_text: "Learn more on our blog",
cta_url: "https://blog.mozilla.org/",
cta_type: "OPEN_URL",
},
targeting: `firefoxVersion > 69`,
trigger: { id: "whatsNewPanelOpened" },
@ -96,6 +100,7 @@ const MESSAGES = () => [
{
id: "WHATS_NEW_69_1",
template: "whatsnew_panel_message",
order: 1,
content: {
published_date: 1557346235089,
title: "Something new in Firefox 69",
@ -103,10 +108,31 @@ const MESSAGES = () => [
"The New Enhanced Tracking Protection, gives you the best level of protection and performance. Discover how this version is the safest version of firefox ever made.",
link_text: "Learn more on our blog",
cta_url: "https://blog.mozilla.org/",
cta_type: "OPEN_URL",
},
targeting: `firefoxVersion > 68`,
trigger: { id: "whatsNewPanelOpened" },
},
{
id: "WHATS_NEW_70_3",
template: "whatsnew_panel_message",
order: 2,
content: {
published_date: 1560969794394,
layout: "tracking-protections",
title: { string_id: "cfr-whatsnew-tracking-blocked-title" },
subtitle: { string_id: "cfr-whatsnew-tracking-blocked-subtitle" },
icon_url:
"resource://activity-stream/data/content/assets/protection-report-icon.png",
icon_alt: "Protection Report icon",
body: { string_id: "cfr-whatsnew-tracking-protect-body" },
link_text: { string_id: "cfr-whatsnew-tracking-blocked-link-text" },
cta_url: "protections",
cta_type: "OPEN_ABOUT_PAGE",
},
targeting: `firefoxVersion > 69 && totalBlockedCount > 0`,
trigger: { id: "whatsNewPanelOpened" },
},
];
const PanelTestProvider = {

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

@ -3,20 +3,19 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
ChromeUtils.defineModuleGetter(
this,
"Services",
"resource://gre/modules/Services.jsm"
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
ChromeUtils.defineModuleGetter(
XPCOMUtils.defineLazyModuleGetters(this, {
Services: "resource://gre/modules/Services.jsm",
EveryWindow: "resource:///modules/EveryWindow.jsm",
PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
});
XPCOMUtils.defineLazyServiceGetter(
this,
"EveryWindow",
"resource:///modules/EveryWindow.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm"
"TrackingDBService",
"@mozilla.org/tracking-db-service;1",
"nsITrackingDBService"
);
const WHATSNEW_ENABLED_PREF = "browser.messaging-system.whatsNewPanel.enabled";
@ -43,9 +42,10 @@ class _ToolbarPanelHub {
this.state = null;
}
async init(waitForInitialized, { getMessages, dispatch }) {
async init(waitForInitialized, { getMessages, dispatch, handleUserAction }) {
this._getMessages = getMessages;
this._dispatch = dispatch;
this._handleUserAction = handleUserAction;
// Wait for ASRouter messages to become available in order to know
// if we can show the What's New panel
await waitForInitialized;
@ -137,23 +137,45 @@ class _ToolbarPanelHub {
});
}
// Newer messages first and use `order` field to decide between messages
// with the same timestamp
_sortWhatsNewMessages(m1, m2) {
// Sort by published_date in descending order.
if (m1.content.published_date === m2.content.published_date) {
// Ascending order
return m1.order - m2.order;
}
if (m1.content.published_date > m2.content.published_date) {
return -1;
}
return 1;
}
// Render what's new messages into the panel.
async renderMessages(win, doc, containerId) {
const messages = (await this.messages).sort((m1, m2) => {
// Sort by published_date in descending order.
if (m1.content.published_date === m2.content.published_date) {
return 0;
}
if (m1.content.published_date > m2.content.published_date) {
return -1;
}
return 1;
});
const messages = (await this.messages).sort(this._sortWhatsNewMessages);
const container = doc.getElementById(containerId);
if (messages && !container.querySelector(".whatsNew-message")) {
let previousDate = 0;
// Get and store any variable part of the message content
this.state.contentArguments = await this._contentArguments();
for (let message of messages) {
// Only render date if it is different from the one rendered before.
if (message.content.published_date !== previousDate) {
container.appendChild(
this._createElement(doc, "p", {
classList: "whatsNew-message-date",
content: new Date(
message.content.published_date
).toLocaleDateString("default", {
month: "long",
day: "numeric",
year: "numeric",
}),
})
);
}
container.appendChild(
this._createMessageElements(win, doc, message, previousDate)
);
@ -181,34 +203,36 @@ class _ToolbarPanelHub {
});
}
/**
* Attach click event listener defined in message payload
*/
_attachClickListener(win, element, message) {
element.addEventListener("click", () => {
this._handleUserAction({
target: win,
data: {
type: message.content.cta_type,
data: {
args: message.content.cta_url,
where: "tabshifted",
},
},
});
this.sendUserEventTelemetry(win, "CLICK", message);
});
}
_createMessageElements(win, doc, message, previousDate) {
const { content } = message;
const messageEl = this._createElement(doc, "div");
messageEl.classList.add("whatsNew-message");
// Only render date if it is different from the one rendered before.
if (content.published_date !== previousDate) {
messageEl.appendChild(
this._createDateElement(doc, content.published_date)
);
}
const wrapperEl = this._createElement(doc, "button");
// istanbul ignore next
wrapperEl.doCommand = () => {};
wrapperEl.classList.add("whatsNew-message-body");
messageEl.appendChild(wrapperEl);
wrapperEl.addEventListener("click", () => {
win.ownerGlobal.openLinkIn(content.cta_url, "tabshifted", {
private: false,
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
{}
),
csp: null,
});
this.sendUserEventTelemetry(win, "CLICK", message);
});
if (content.icon_url) {
wrapperEl.classList.add("has-icon");
@ -219,81 +243,141 @@ class _ToolbarPanelHub {
wrapperEl.appendChild(iconEl);
}
const titleEl = this._createElement(doc, "h2");
titleEl.classList.add("whatsNew-message-title");
this._setString(doc, titleEl, content.title);
wrapperEl.appendChild(titleEl);
const bodyEl = this._createElement(doc, "p");
this._setString(doc, bodyEl, content.body);
wrapperEl.appendChild(bodyEl);
wrapperEl.appendChild(this._createMessageContent(win, doc, content));
if (content.link_text) {
const linkEl = this._createElement(doc, "a");
linkEl.classList.add("text-link");
this._setString(doc, linkEl, content.link_text);
wrapperEl.appendChild(linkEl);
wrapperEl.appendChild(
this._createElement(doc, "a", {
classList: "text-link",
content: content.link_text,
})
);
}
// Attach event listener on entire message container
this._attachClickListener(win, wrapperEl, message);
return messageEl;
}
_createHeroElement(win, doc, content) {
/**
* Return message title (optional subtitle) and body
*/
_createMessageContent(win, doc, content) {
const wrapperEl = new win.DocumentFragment();
wrapperEl.appendChild(
this._createElement(doc, "h2", {
classList: "whatsNew-message-title",
content: content.title,
})
);
switch (content.layout) {
case "tracking-protections":
wrapperEl.appendChild(
this._createElement(doc, "h4", {
classList: "whatsNew-message-subtitle",
content: content.subtitle,
})
);
wrapperEl.appendChild(
this._createElement(doc, "h2", {
classList: "whatsNew-message-title-large",
content: this.state.contentArguments.blockedCount,
})
);
break;
}
wrapperEl.appendChild(
this._createElement(doc, "p", { content: content.body })
);
return wrapperEl;
}
_createHeroElement(win, doc, message) {
const messageEl = this._createElement(doc, "div");
messageEl.setAttribute("id", "protections-popup-message");
messageEl.classList.add("whatsNew-hero-message");
const wrapperEl = this._createElement(doc, "div");
wrapperEl.classList.add("whatsNew-message-body");
messageEl.appendChild(wrapperEl);
wrapperEl.addEventListener("click", () => {
win.ownerGlobal.openLinkIn(content.cta_url, "tabshifted", {
private: false,
relatedToCurrent: true,
triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
{}
),
csp: null,
});
});
const titleEl = this._createElement(doc, "h2");
titleEl.classList.add("whatsNew-message-title");
this._setString(doc, titleEl, content.title);
wrapperEl.appendChild(titleEl);
const bodyEl = this._createElement(doc, "p");
this._setString(doc, bodyEl, content.body);
wrapperEl.appendChild(bodyEl);
this._attachClickListener(win, wrapperEl, message);
if (content.link_text) {
const linkEl = this._createElement(doc, "a");
linkEl.classList.add("text-link");
this._setString(doc, linkEl, content.link_text);
wrapperEl.appendChild(linkEl);
wrapperEl.appendChild(
this._createElement(doc, "h2", {
classList: "whatsNew-message-title",
content: message.content.title,
})
);
wrapperEl.appendChild(
this._createElement(doc, "p", { content: message.content.body })
);
if (message.content.link_text) {
wrapperEl.appendChild(
this._createElement(doc, "a", {
classList: "text-link",
content: message.content.link_text,
})
);
}
return messageEl;
}
_createElement(doc, elem) {
return doc.createElementNS("http://www.w3.org/1999/xhtml", elem);
_createElement(doc, elem, options = {}) {
const node = doc.createElementNS("http://www.w3.org/1999/xhtml", elem);
if (options.classList) {
node.classList.add(options.classList);
}
if (options.content) {
this._setString(doc, node, options.content);
}
return node;
}
_createDateElement(doc, date) {
const dateEl = this._createElement(doc, "p");
dateEl.classList.add("whatsNew-message-date");
dateEl.textContent = new Date(date).toLocaleDateString("default", {
month: "long",
day: "numeric",
year: "numeric",
});
return dateEl;
async _contentArguments() {
// Between now and 6 weeks ago
const dateTo = new Date();
const dateFrom = new Date(dateTo.getTime() - 42 * 24 * 60 * 60 * 1000);
const eventsByDate = await TrackingDBService.getEventsByDateRange(
dateFrom,
dateTo
);
// Count all events in the past 6 weeks
const totalEvents = eventsByDate.reduce(
(acc, day) => acc + day.getResultByName("count"),
0
);
return {
// Keys need to match variable names used in asrouter.ftl
// `earliestDate` will be either 6 weeks ago or when tracking recording
// started. Whichever is more recent.
earliestDate: new Date(
Math.max(
new Date(await TrackingDBService.getEarliestRecordedDate()),
dateFrom
)
).getTime(),
blockedCount: totalEvents.toLocaleString(),
};
}
// If `string_id` is present it means we are relying on fluent for translations.
// Otherwise, we have a vanilla string.
_setString(doc, el, stringObj) {
if (stringObj.string_id) {
doc.l10n.setAttributes(el, stringObj.string_id);
doc.l10n.setAttributes(
el,
stringObj.string_id,
// Pass all available arguments to Fluent
this.state.contentArguments
);
} else {
el.textContent = stringObj;
}
@ -396,7 +480,7 @@ class _ToolbarPanelHub {
triggerId: "protectionsPanelOpen",
});
if (message) {
const messageEl = this._createHeroElement(win, doc, message.content);
const messageEl = this._createHeroElement(win, doc, message);
container.appendChild(messageEl);
infoButton.addEventListener("click", toggleMessage);
this.sendUserEventTelemetry(win, "IMPRESSION", message.id);

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

@ -214,6 +214,7 @@ describe("ASRouter", () => {
{
getMessages: Router.handleMessageRequest,
dispatch: Router.dispatch,
handleUserAction: Router.handleUserAction,
}
);

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

@ -1,13 +1,14 @@
import { PanelTestProvider } from "lib/PanelTestProvider.jsm";
import schema from "content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json";
import update_schema from "content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json";
import whats_new_schema from "content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json";
const messages = PanelTestProvider.getMessages();
describe("PanelTestProvider", () => {
it("should have a message", () => {
// Careful: when changing this number make sure that new messages also go
// through schema verifications.
assert.lengthOf(messages, 6);
assert.lengthOf(messages, 7);
});
it("should be a valid message", () => {
const fxaMessages = messages.filter(
@ -25,4 +26,14 @@ describe("PanelTestProvider", () => {
assert.jsonSchema(message.content, update_schema);
}
});
it("should be a valid message", () => {
const whatsNewMessages = messages.filter(
({ template }) => template === "whatsnew_panel_message"
);
for (let message of whatsNewMessages) {
assert.jsonSchema(message.content, whats_new_schema);
// Not part of `message.content` so it can't be enforced through schema
assert.property(message, "order");
}
});
});

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

@ -20,6 +20,9 @@ describe("ToolbarPanelHub", () => {
let waitForInitializedStub;
let isBrowserPrivateStub;
let fakeDispatch;
let getEarliestRecordedDateStub;
let getEventsByDateRangeStub;
let handleUserActionStub;
beforeEach(async () => {
sandbox = sinon.createSandbox();
@ -58,6 +61,10 @@ describe("ToolbarPanelHub", () => {
},
};
fakeWindow = {
// eslint-disable-next-line object-shorthand
DocumentFragment: function() {
return fakeElementById;
},
document: fakeDocument,
browser: {
ownerDocument: fakeDocument,
@ -94,6 +101,16 @@ describe("ToolbarPanelHub", () => {
globals.set("PrivateBrowsingUtils", {
isBrowserPrivate: isBrowserPrivateStub,
});
getEarliestRecordedDateStub = sandbox.stub();
getEventsByDateRangeStub = sandbox.stub();
globals.set("TrackingDBService", {
getEarliestRecordedDate: getEarliestRecordedDateStub.returns(
// A random date that's not the current timestamp
new Date() - 500
),
getEventsByDateRange: getEventsByDateRangeStub.returns([]),
});
handleUserActionStub = sandbox.stub();
});
afterEach(() => {
instance.uninit();
@ -242,6 +259,7 @@ describe("ToolbarPanelHub", () => {
instance.init(waitForInitializedStub, {
getMessages: getMessagesStub,
dispatch: fakeDispatch,
handleUserAction: handleUserActionStub,
});
});
it("should render messages to the panel on renderMessages()", async () => {
@ -250,32 +268,50 @@ describe("ToolbarPanelHub", () => {
);
messages[0].content.link_text = { string_id: "link_text_id" };
getMessagesStub.returns([messages[0], messages[2], messages[1]]);
getMessagesStub.returns(messages);
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
for (let message of messages) {
assert.ok(
createdElements.find(
el =>
el.tagName === "h2" && el.textContent === message.content.title
)
);
assert.ok(
createdElements.find(
el => el.tagName === "p" && el.textContent === message.content.body
)
);
assert.ok(createdElements.find(el => el.tagName === "h2"));
if (message.content.layout === "tracking-protections") {
assert.ok(createdElements.find(el => el.tagName === "h4"));
}
assert.ok(createdElements.find(el => el.tagName === "p"));
}
// Call the click handler to make coverage happy.
eventListeners.click();
assert.calledOnce(fakeWindow.ownerGlobal.openLinkIn);
assert.calledOnce(handleUserActionStub);
});
it("should sort based on order field value", async () => {
const messages = (await PanelTestProvider.getMessages()).filter(
m =>
m.template === "whatsnew_panel_message" &&
m.content.published_date === 1560969794394
);
messages.forEach(m => (m.content.title = m.order));
getMessagesStub.returns(messages);
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
// Select the title elements that are supposed to be set to the same
// value as the `order` field of the message
const titleEls = createdElements
.filter(
el =>
el.classList.add.firstCall &&
el.classList.add.firstCall.args[0] === "whatsNew-message-title"
)
.map(el => el.textContent);
assert.deepEqual(titleEls, [1, 2, 3]);
});
it("should accept string for image attributes", async () => {
const messages = (await PanelTestProvider.getMessages()).filter(
m => m.template === "whatsnew_panel_message"
m => m.id === "WHATS_NEW_70_1"
);
getMessagesStub.returns([messages[0], messages[2], messages[1]]);
getMessagesStub.returns(messages);
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
@ -289,19 +325,55 @@ describe("ToolbarPanelHub", () => {
});
it("should accept fluent ids for image attributes", async () => {
const messages = (await PanelTestProvider.getMessages()).filter(
m => m.template === "whatsnew_panel_message"
m => m.id === "WHATS_NEW_70_1"
);
messages[0].content.icon_alt = { string_id: "foo" };
getMessagesStub.returns([messages[0], messages[2], messages[1]]);
getMessagesStub.returns(messages);
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
const imageEl = createdElements.find(el => el.tagName === "img");
assert.calledOnce(fakeDocument.l10n.setAttributes);
assert.calledWithExactly(fakeDocument.l10n.setAttributes, imageEl, "foo");
});
it("should accept fluent ids for elements attributes", async () => {
const [message] = (await PanelTestProvider.getMessages()).filter(
m =>
m.template === "whatsnew_panel_message" &&
m.content.layout === "tracking-protections"
);
getMessagesStub.returns([message]);
instance.state.contentArguments = { foo: "foo", bar: "bar" };
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
const subtitle = createdElements.find(el => el.tagName === "h4");
assert.calledWithExactly(
fakeDocument.l10n.setAttributes,
subtitle,
message.content.subtitle.string_id,
instance.state.contentArguments
);
});
it("should correctly compute blocker trackers and date", async () => {
const messages = (await PanelTestProvider.getMessages()).filter(
m => m.template === "whatsnew_panel_message"
);
getMessagesStub.returns(messages);
getEventsByDateRangeStub.returns([
{ getResultByName: sandbox.stub().returns(2) },
{ getResultByName: sandbox.stub().returns(3) },
]);
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
assert.calledWithExactly(
fakeDocument.l10n.setAttributes,
sinon.match.object,
sinon.match.string,
{ blockedCount: "5", earliestDate: getEarliestRecordedDateStub() }
);
});
it("should only render unique dates (no duplicates)", async () => {
instance._createDateElement = sandbox.stub();
const messages = (await PanelTestProvider.getMessages()).filter(
m => m.template === "whatsnew_panel_message"
);
@ -312,7 +384,13 @@ describe("ToolbarPanelHub", () => {
await instance.renderMessages(fakeWindow, fakeDocument, "container-id");
assert.callCount(instance._createDateElement, uniqueDates.length);
const dateElements = createdElements.filter(
el =>
el.tagName === "p" &&
el.classList.add.firstCall &&
el.classList.add.firstCall.args[0] === "whatsNew-message-date"
);
assert.lengthOf(dateElements, uniqueDates.length);
});
it("should listen for panelhidden and remove the toolbar button", async () => {
getMessagesStub.returns([]);
@ -492,6 +570,7 @@ describe("ToolbarPanelHub", () => {
dispatch: fakeDispatch,
getMessages: () =>
onboardingMsgs.find(msg => msg.template === "protections_panel"),
handleUserAction: handleUserActionStub,
});
});
it("should remember it showed", async () => {
@ -522,7 +601,17 @@ describe("ToolbarPanelHub", () => {
eventListeners.click();
assert.calledOnce(fakeWindow.ownerGlobal.openLinkIn);
assert.calledOnce(handleUserActionStub);
assert.calledWithExactly(handleUserActionStub, {
target: fakeWindow,
data: {
type: "OPEN_URL",
data: {
args: sinon.match.string,
where: "tabshifted",
},
},
});
});
});
});