--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -104,16 +104,24 @@
"schema": "chrome://browser/content/schemas/menus.json",
"scopes": ["addon_parent"],
"paths": [
["contextMenus"],
["menus"],
["menusInternal"]
]
},
+ "newTabSection": {
+ "url": "chrome://browser/content/parent/ext-newTabSection.js",
+ "schema": "chrome://browser/content/schemas/newTabSection.json",
+ "scopes": ["addon_parent"],
+ "paths": [
+ ["newTabSection"]
+ ]
+ },
"omnibox": {
"url": "chrome://browser/content/parent/ext-omnibox.js",
"schema": "chrome://browser/content/schemas/omnibox.json",
"scopes": ["addon_parent"],
"manifest": ["omnibox"],
"paths": [
["omnibox"]
]
--- a/browser/components/extensions/jar.mn
+++ b/browser/components/extensions/jar.mn
@@ -22,16 +22,17 @@ browser.jar:
content/browser/parent/ext-devtools.js (parent/ext-devtools.js)
content/browser/parent/ext-devtools-inspectedWindow.js (parent/ext-devtools-inspectedWindow.js)
content/browser/parent/ext-devtools-network.js (parent/ext-devtools-network.js)
content/browser/parent/ext-devtools-panels.js (parent/ext-devtools-panels.js)
content/browser/parent/ext-find.js (parent/ext-find.js)
content/browser/parent/ext-geckoProfiler.js (parent/ext-geckoProfiler.js)
content/browser/parent/ext-history.js (parent/ext-history.js)
content/browser/parent/ext-menus.js (parent/ext-menus.js)
+ content/browser/parent/ext-newTabSection.js (parent/ext-newTabSection.js)
content/browser/parent/ext-omnibox.js (parent/ext-omnibox.js)
content/browser/parent/ext-pageAction.js (parent/ext-pageAction.js)
content/browser/parent/ext-pkcs11.js (parent/ext-pkcs11.js)
content/browser/parent/ext-sessions.js (parent/ext-sessions.js)
content/browser/parent/ext-sidebarAction.js (parent/ext-sidebarAction.js)
content/browser/parent/ext-tabs.js (parent/ext-tabs.js)
content/browser/parent/ext-url-overrides.js (parent/ext-url-overrides.js)
content/browser/parent/ext-windows.js (parent/ext-windows.js)
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/parent/ext-newTabSection.js
@@ -0,0 +1,121 @@
+"use strict";
+/* import-globals-from ext-browser.js */
+
+ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm");
+
+const ACTIVITY_STREAM_ACTIONS = {
+ WEBEXT_CLICK: "Click",
+ WEBEXT_DISMISS: "Dismiss",
+};
+
+this.newTabSection = class extends ExtensionAPI { // eslint-disable-line no-unused-vars
+ constructor(extension) {
+ super(extension);
+
+ const manifestOptions = Object.assign({}, this.extension.manifest.new_tab_section);
+
+ for (const prop in manifestOptions) {
+ // Necessary to avoid overwriting default values with null if no option is
+ // provided in the manifest
+ if (manifestOptions[prop] === null) { delete manifestOptions[prop]; }
+ }
+
+ this.sectionOptions = Object.assign({
+ title: this.extension.manifest.name,
+ eventSource: this.extension.id,
+ maxRows: 1,
+ contextMenuOptions: [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "WebExtDismiss",
+ ],
+ emptyState: {message: "Loading"},
+ shouldSendImpressionStats: false,
+ isWebExtension: true,
+ // Don't show in about:preferences
+ shouldHidePref: true,
+ enabled: true,
+ }, manifestOptions);
+
+ this.wrapExtensionUrl(this.sectionOptions, "icon");
+
+ this.sectionReady = new Promise(resolve => {
+ // Add the section
+ SectionsManager.addSection(this.extension.id, this.sectionOptions);
+
+ // If the sections manager hasn't been initialized yet, we need to wait for it
+ if (SectionsManager.initialized) {
+ resolve();
+ } else {
+ SectionsManager.on(SectionsManager.INIT, function initListener() {
+ resolve();
+ SectionsManager.off(SectionsManager.INIT, initListener);
+ });
+ }
+ });
+ }
+
+ wrapExtensionUrl(object, prop) {
+ if (typeof object[prop] === "string" && !object[prop].startsWith("moz-extension://")) {
+ object[prop] = this.extension.getURL(object[prop]);
+ }
+ }
+
+ onShutdown() {
+ SectionsManager.removeSection(this.extension.id);
+ }
+
+ getAPI(context) {
+ const id = this.extension.id;
+ const options = this.sectionOptions;
+
+ const newTabSection = {
+ async update(newOptions) {
+ await this.sectionReady;
+
+ Object.assign(options, newOptions);
+ // If we dynamically update a section option and the section was installed,
+ // propagate the change to Activity Stream
+ if (SectionsManager.sections.has(id)) {
+ SectionsManager.updateSection(id, options, true);
+ }
+ },
+
+ async addCards(cards, shouldBroadcast = false) {
+ await this.sectionReady;
+
+ if (!SectionsManager.sections.has(id)) {
+ throw new Error(`No section with the id ${id} was found; it might be uninstalled.`);
+ }
+
+ SectionsManager.updateSection(id, {rows: cards}, shouldBroadcast);
+ },
+ };
+
+ // Constructs an EventManager for a single action
+ const ActionEventManagerFactory = action => {
+ const key = `newTabSection.on${ACTIVITY_STREAM_ACTIONS[action]}`;
+ return new EventManager(context, key, fire => {
+ const listener = (event, type, data) => {
+ if (type === action && data.source === this.extension.id) {
+ fire.async(data);
+ }
+ };
+
+ SectionsManager.on(SectionsManager.ACTION_DISPATCHED, listener);
+ return () =>
+ SectionsManager.off(SectionsManager.ACTION_DISPATCHED, listener);
+ }).api();
+ };
+
+ // Single action events
+ for (const action of Object.keys(ACTIVITY_STREAM_ACTIONS)) {
+ const key = `on${ACTIVITY_STREAM_ACTIONS[action]}`;
+ newTabSection[key] = ActionEventManagerFactory(action);
+ }
+
+ return {newTabSection};
+ }
+};
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -12,16 +12,17 @@ browser.jar:
content/browser/schemas/devtools_inspected_window.json
content/browser/schemas/devtools_network.json
content/browser/schemas/devtools_panels.json
content/browser/schemas/find.json
content/browser/schemas/geckoProfiler.json
content/browser/schemas/history.json
content/browser/schemas/menus.json
content/browser/schemas/menus_internal.json
+ content/browser/schemas/newTabSection.json
content/browser/schemas/omnibox.json
content/browser/schemas/page_action.json
content/browser/schemas/pkcs11.json
content/browser/schemas/sessions.json
content/browser/schemas/sidebar_action.json
content/browser/schemas/tabs.json
content/browser/schemas/url_overrides.json
content/browser/schemas/windows.json
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/schemas/newTabSection.json
@@ -0,0 +1,132 @@
+[
+ {
+ "namespace": "manifest",
+ "types": [
+ {
+ "$extend": "WebExtensionManifest",
+ "properties": {
+ "new_tab_section": {
+ "type": "object",
+ "additionalProperties": {"$ref": "UnrecognizedProperty"},
+ "optional": true,
+ "properties": {
+ "title": {"type": "string", "optional": true, "pattern": ".*\\S.*", "localize": true},
+ "icon": {"$ref": "IconPath", "optional": true},
+ "maxRows": {"type": "integer", "optional": true, "minimum": 1},
+ "emptyState": {
+ "type": "object",
+ "optional": true,
+ "properties": {
+ "message": {"type": "string", "localize": true}
+ }
+ }
+ }
+ }
+ }
+ }
+ ]
+ },
+ {
+ "namespace": "newTabSection",
+ "permissions": ["manifest:new_tab_section"],
+ "types": [
+ {
+ "id": "Card",
+ "type": "object",
+ "properties": {
+ "url": {"type": "string", "optional": true},
+ "title": {"type": "string"},
+ "description": {"type": "string", "optional": true},
+ "hostname": {"type": "string", "optional": true},
+ "type": {"type": "string", "optional": true},
+ "image": {"type": "string", "optional": true}
+ }
+ },
+ {
+ "id": "UpdateOptions",
+ "type": "object",
+ "properties": {
+ "title": {
+ "type": "string",
+ "pattern": ".*\\S.*",
+ "optional": true
+ },
+ "icon": {
+ "$ref": "IconPath",
+ "optional": true
+ },
+ "maxRows": {
+ "type": "integer",
+ "minimum": 1,
+ "optional": true
+ },
+ "emptyState": {
+ "type": "object",
+ "properties": {
+ "icon": {"$ref": "IconPath", "optional": true},
+ "message": {"type": "string", "optional": true}
+ },
+ "optional": true
+ }
+ }
+ }
+ ],
+ "functions": [
+ {
+ "name": "update",
+ "description": "Updates any of the available manifest options and returns a promise when updates have been applied. ",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "options",
+ "$ref": "UpdateOptions"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": []
+ }
+ ]
+ },
+ {
+ "name": "addCards",
+ "description": "Adds an array of cards to the section and returns a promise when all cards have been added.",
+ "type": "function",
+ "async": "callback",
+ "parameters": [
+ {
+ "name": "cards",
+ "type": "array",
+ "items": {"type": "object", "$ref": "Card"}
+ },
+ {
+ "name": "shouldBroadcast",
+ "description": "Should the updates be pushed immediately to all visible tabs? Defaults to false (cards will be updated in the background for the next tab only).",
+ "type": "boolean",
+ "optional": "true"
+ },
+ {
+ "type": "function",
+ "name": "callback",
+ "parameters": []
+ }
+ ]
+ }
+ ],
+ "events": [
+ {
+ "name": "onClick",
+ "description": "Fired when a user clicks on a card.",
+ "type": "function",
+ "parameters": []
+ },
+ {
+ "name": "onDismiss",
+ "description": "Fired when a user dismisses a card from the context menu and it is removed from the section.",
+ "type": "function",
+ "parameters": []
+ }
+ ]
+ }
+]
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -93,16 +93,17 @@ support-files =
../../../../../devtools/client/inspector/extensions/test/head_devtools_inspector_sidebar.js
[browser_ext_find.js]
[browser_ext_getViews.js]
[browser_ext_history_redirect.js]
[browser_ext_identity_indication.js]
[browser_ext_incognito_views.js]
[browser_ext_incognito_popup.js]
[browser_ext_lastError.js]
+[browser_ext_newTabSection.js]
[browser_ext_menus.js]
[browser_ext_menus_event_order.js]
[browser_ext_menus_events.js]
[browser_ext_menus_refresh.js]
[browser_ext_omnibox.js]
skip-if = debug && (os == 'linux' || os == 'mac') # Bug 1417052
[browser_ext_openPanel.js]
[browser_ext_optionsPage_browser_style.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_newTabSection.js
@@ -0,0 +1,258 @@
+"use strict";
+
+const TEST_SECTION_OPTIONS = {
+ "title": "Red Pandas",
+ "maxRows": 3,
+ "emptyState": {"message": "Empty 123"},
+};
+const TEST_MANIFEST = {
+ "background": {"scripts": ["background.js"]},
+ "new_tab_section": TEST_SECTION_OPTIONS,
+};
+
+function loadExtension(options = {}) {
+ return ExtensionTestUtils.loadExtension({
+ manifest: options.manifest || TEST_MANIFEST,
+ useAddonManager: "temporary",
+ files: {"background.js": options.js || function() {
+ browser.newTabSection.addCards([]);
+ }},
+ });
+}
+
+async function runInNewTab(args, contentTask) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:newtab", false);
+ let browser = tab.linkedBrowser;
+
+ // Wait for React to render something
+ await BrowserTestUtils.waitForCondition(() => ContentTask.spawn(browser, {}, () => {
+ return content.document.getElementById("root").children.length;
+ }), "Should render activity stream content");
+
+ try {
+ await ContentTask.spawn(browser, args, contentTask);
+ } finally {
+ await BrowserTestUtils.removeTab(tab);
+ }
+}
+
+/**
+ * The section should exist on the new tab page with the right title
+ */
+add_task(async function test_section_exists() {
+ const extension = loadExtension();
+ await extension.startup();
+
+ await runInNewTab({testdata: TEST_SECTION_OPTIONS, id: extension.id}, args => {
+ const {testdata, id} = args;
+
+ const sectionEl = content.document.querySelector(".section[data-section-id=\"" + id + "\"]");
+ const title = sectionEl.querySelector(".section-title").textContent;
+
+ ok(sectionEl, "should render a section where the data-section-id attribute matches the extension id");
+ is(title, testdata.title, "should render the title specified in the manifest");
+ });
+ await extension.unload();
+});
+
+/**
+ * The section should be able to update manifest options via newTabSection.update()
+ */
+add_task(async function test_newTabSection_update() {
+ const extension = loadExtension({
+ async js() {
+ await browser.newTabSection.update({title: "Foo Section"});
+ browser.test.sendMessage("updated");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("updated")]);
+
+ await runInNewTab({id: extension.id}, args => {
+ const {id} = args;
+ const sectionEl = content.document.querySelector(".section[data-section-id=\"" + id + "\"]");
+ ok(sectionEl, "should render the new title after it has been updated via browser.newTabSection.update");
+ });
+ await extension.unload();
+});
+
+/**
+ * The section should be able to add cards via newTabSection.addCards();
+ */
+add_task(async function test_newTabSection_addCards() {
+ const extension = loadExtension({
+ async js() {
+ await browser.newTabSection.addCards([
+ {url: "foo.com", title: "Foo", description: "This is a card"},
+ {url: "bar.com", title: "Bar", description: "This is a card"},
+ {url: "baz.com", title: "Baz", description: "This is a card"},
+ ]);
+ browser.test.sendMessage("updated");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("updated")]);
+
+ await runInNewTab({id: extension.id}, async args => {
+ const {id} = args;
+
+ // Find the matching section
+ const sectionEl = content.document.querySelector(".section[data-section-id=\"" + id + "\"]");
+ ok(sectionEl, "should render a section for the extension");
+
+ // Find the corresponding links
+ let links;
+ await ContentTaskUtils.waitForCondition(() => {
+ links = sectionEl.querySelectorAll(".card-outer > a");
+ return links.length === 3;
+ });
+ if (links) {
+ is(links[0].href, "foo.com", "correct url for foo.com");
+ is(links[1].href, "bar.com", "correct url for bar.com");
+ is(links[2].href, "baz.com", "correct url for baz.com");
+ }
+ });
+ await extension.unload();
+});
+
+/**
+ * The section should send events and remove elements when Dismiss context option is clicked
+ */
+add_task(async function test_newTabSection_dismiss() {
+ const extension = loadExtension({
+ async js() {
+ await browser.newTabSection.addCards([
+ {url: "about:blank", title: "Foo", description: "This is a card"},
+ ]);
+ browser.test.sendMessage("updated");
+ browser.newTabSection.onDismiss.addListener(data => browser.test.sendMessage("dismissed"));
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("updated")]);
+
+ const dismissMessageReceived = extension.awaitMessage("dismissed");
+ await runInNewTab({id: extension.id}, async args => {
+ const {id} = args;
+
+ // Find the matching section
+ const sectionEl = content.document.querySelector(".section[data-section-id=\"" + id + "\"]");
+ ok(sectionEl, "should render a section for the extension");
+
+ // Find the link and its dismiss button
+ let link;
+ await ContentTaskUtils.waitForCondition(() => {
+ link = sectionEl.querySelector(".card-outer:not(.placeholder)");
+ return link;
+ });
+
+ // Open context menu
+ link.querySelector(".context-menu-button").click();
+
+ const dismissBtn = link.querySelector(".context-menu-item:last-child a");
+
+ dismissBtn.click();
+
+ await ContentTaskUtils.waitForCondition(() => (
+ sectionEl.querySelector(".card-outer:not(.placeholder)") === null
+ ));
+ });
+
+ await dismissMessageReceived;
+ ok(true, "dismiss message was received");
+
+ await extension.unload();
+});
+
+/**
+ * The section should send an event with the coordesponding URL if a card is clicked
+ */
+add_task(async function test_newTabSection_click() {
+ const extension = loadExtension({
+ async js() {
+ browser.newTabSection.onClick.addListener(data => {
+ browser.test.sendMessage("onClick", data);
+ });
+ await browser.newTabSection.addCards([
+ {url: "#foo", title: "Foo123", description: "This is a card"},
+ ]);
+ browser.test.sendMessage("updated");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("updated")]);
+
+ let clickPromise = extension.awaitMessage("onClick");
+
+ await runInNewTab({id: extension.id}, async args => {
+ const {id} = args;
+
+ // Find the matching section
+ const sectionEl = content.document.querySelector(".section[data-section-id=\"" + id + "\"]");
+ ok(sectionEl, "should render a section for the extension");
+
+ // Find the link and its dismiss button
+ let link;
+ await ContentTaskUtils.waitForCondition(() => {
+ link = sectionEl.querySelector(".card-outer:not(.placeholder) a");
+ return link;
+ });
+
+ // Open context menu
+ link.click();
+ });
+
+ ok(await clickPromise, "#foo");
+
+ await extension.unload();
+});
+
+/**
+ * The emptyState message should appear when no items are left in the section
+ */
+add_task(async function test_newTabSection_empty_state() {
+ const extension = loadExtension({
+ async js() {
+ await browser.newTabSection.addCards([
+ {url: "about:blank", title: "Foo", description: "This is a card"},
+ ]);
+ browser.test.sendMessage("updated");
+ },
+ });
+
+ await Promise.all([extension.startup(), extension.awaitMessage("updated")]);
+
+ await runInNewTab({id: extension.id}, async args => {
+ const {id} = args;
+
+ // Find the matching section
+ const sectionEl = content.document.querySelector(".section[data-section-id=\"" + id + "\"]");
+ ok(sectionEl, "should render a section for the extension");
+ if (!sectionEl) {
+ return;
+ }
+
+ // Find the link and its dismiss button
+ let link;
+ await ContentTaskUtils.waitForCondition(() => {
+ link = sectionEl.querySelector(".card-outer:not(.placeholder)");
+ return link;
+ });
+
+ // Open context menu
+ link.querySelector(".context-menu-button").click();
+ const dismissBtn = link.querySelector(".context-menu-item:last-child a");
+
+ dismissBtn.click();
+
+ let emptyStateEl;
+ await ContentTaskUtils.waitForCondition(() => {
+ emptyStateEl = sectionEl.querySelector(".empty-state-message");
+ return emptyStateEl;
+ });
+
+ is(emptyStateEl.textContent, "Empty 123", "should have the empty state text defined in the manifest");
+ });
+
+ await extension.unload();
+});
--- a/browser/locales/en-US/chrome/browser/browser.properties
+++ b/browser/locales/en-US/chrome/browser/browser.properties
@@ -106,16 +106,17 @@ webextPerms.description.downloads=Download files and read and modify the browser’s download history
webextPerms.description.downloads.open=Open files downloaded to your computer
webextPerms.description.find=Read the text of all open tabs
webextPerms.description.geolocation=Access your location
webextPerms.description.history=Access browsing history
webextPerms.description.management=Monitor extension usage and manage themes
# LOCALIZATION NOTE (webextPerms.description.nativeMessaging)
# %S will be replaced with the name of the application
webextPerms.description.nativeMessaging=Exchange messages with programs other than %S
+webextPerms.description.newTabSection=Add a custom section to the newtab
webextPerms.description.notifications=Display notifications to you
webextPerms.description.pkcs11=Provide cryptographic authentication services
webextPerms.description.privacy=Read and modify privacy settings
webextPerms.description.proxy=Control browser proxy settings
webextPerms.description.sessions=Access recently closed tabs
webextPerms.description.tabs=Access browser tabs
webextPerms.description.tabHide=Hide and show browser tabs
webextPerms.description.topSites=Access browsing history