Bug 1401194 - Add newtab section webextension api draft
authork88hudson <k88hudson@gmail.com>
Thu, 04 Jan 2018 12:49:03 -0500
changeset 798324 dffb877de74dff007d1bc3c79865513e5c83cc7f
parent 798084 b75acf9652937ce79a9bf02de843c100db0e5ec7
push id110708
push userbmo:khudson@mozilla.com
push dateTue, 22 May 2018 16:18:39 +0000
bugs1401194
milestone62.0a1
Bug 1401194 - Add newtab section webextension api MozReview-Commit-ID: FSN4Sm73iMp
browser/components/extensions/ext-browser.json
browser/components/extensions/jar.mn
browser/components/extensions/parent/ext-newTabSection.js
browser/components/extensions/schemas/jar.mn
browser/components/extensions/schemas/newTabSection.json
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ext_newTabSection.js
browser/locales/en-US/chrome/browser/browser.properties
--- 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