summaryrefslogtreecommitdiffstats
path: root/toolkit/jetpack/sdk/ui
diff options
context:
space:
mode:
authorMatt A. Tobin <email@mattatobin.com>2018-02-09 06:46:43 -0500
committerMatt A. Tobin <email@mattatobin.com>2018-02-09 06:46:43 -0500
commitac46df8daea09899ce30dc8fd70986e258c746bf (patch)
tree2750d3125fc253fd5b0671e4bd268eff1fd97296 /toolkit/jetpack/sdk/ui
parent8cecf8d5208f3945b35f879bba3015bb1a11bec6 (diff)
downloadUXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.gz
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.lz
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.xz
UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.zip
Move Add-on SDK source to toolkit/jetpack
Diffstat (limited to 'toolkit/jetpack/sdk/ui')
-rw-r--r--toolkit/jetpack/sdk/ui/button/action.js114
-rw-r--r--toolkit/jetpack/sdk/ui/button/contract.js73
-rw-r--r--toolkit/jetpack/sdk/ui/button/toggle.js127
-rw-r--r--toolkit/jetpack/sdk/ui/button/view.js243
-rw-r--r--toolkit/jetpack/sdk/ui/button/view/events.js18
-rw-r--r--toolkit/jetpack/sdk/ui/component.js182
-rw-r--r--toolkit/jetpack/sdk/ui/frame.js16
-rw-r--r--toolkit/jetpack/sdk/ui/frame/model.js154
-rw-r--r--toolkit/jetpack/sdk/ui/frame/view.html18
-rw-r--r--toolkit/jetpack/sdk/ui/frame/view.js150
-rw-r--r--toolkit/jetpack/sdk/ui/id.js27
-rw-r--r--toolkit/jetpack/sdk/ui/sidebar.js311
-rw-r--r--toolkit/jetpack/sdk/ui/sidebar/actions.js10
-rw-r--r--toolkit/jetpack/sdk/ui/sidebar/contract.js27
-rw-r--r--toolkit/jetpack/sdk/ui/sidebar/namespace.js15
-rw-r--r--toolkit/jetpack/sdk/ui/sidebar/utils.js8
-rw-r--r--toolkit/jetpack/sdk/ui/sidebar/view.js214
-rw-r--r--toolkit/jetpack/sdk/ui/state.js239
-rw-r--r--toolkit/jetpack/sdk/ui/state/events.js18
-rw-r--r--toolkit/jetpack/sdk/ui/toolbar.js16
-rw-r--r--toolkit/jetpack/sdk/ui/toolbar/model.js151
-rw-r--r--toolkit/jetpack/sdk/ui/toolbar/view.js248
22 files changed, 2379 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/ui/button/action.js b/toolkit/jetpack/sdk/ui/button/action.js
new file mode 100644
index 000000000..dfb092d0c
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/button/action.js
@@ -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';
+
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '> 28'
+ }
+};
+
+const { Class } = require('../../core/heritage');
+const { merge } = require('../../util/object');
+const { Disposable } = require('../../core/disposable');
+const { on, off, emit, setListeners } = require('../../event/core');
+const { EventTarget } = require('../../event/target');
+const { getNodeView } = require('../../view/core');
+
+const view = require('./view');
+const { buttonContract, stateContract } = require('./contract');
+const { properties, render, state, register, unregister,
+ getDerivedStateFor } = require('../state');
+const { events: stateEvents } = require('../state/events');
+const { events: viewEvents } = require('./view/events');
+const events = require('../../event/utils');
+
+const { getActiveTab } = require('../../tabs/utils');
+
+const { id: addonID } = require('../../self');
+const { identify } = require('../id');
+
+const buttons = new Map();
+
+const toWidgetId = id =>
+ ('action-button--' + addonID.toLowerCase()+ '-' + id).
+ replace(/[^a-z0-9_-]/g, '');
+
+const ActionButton = Class({
+ extends: EventTarget,
+ implements: [
+ properties(stateContract),
+ state(stateContract),
+ Disposable
+ ],
+ setup: function setup(options) {
+ let state = merge({
+ disabled: false
+ }, buttonContract(options));
+
+ let id = toWidgetId(options.id);
+
+ register(this, state);
+
+ // Setup listeners.
+ setListeners(this, options);
+
+ buttons.set(id, this);
+
+ view.create(merge({}, state, { id: id }));
+ },
+
+ dispose: function dispose() {
+ let id = toWidgetId(this.id);
+ buttons.delete(id);
+
+ off(this);
+
+ view.dispose(id);
+
+ unregister(this);
+ },
+
+ get id() {
+ return this.state().id;
+ },
+
+ click: function click() { view.click(toWidgetId(this.id)) }
+});
+exports.ActionButton = ActionButton;
+
+identify.define(ActionButton, ({id}) => toWidgetId(id));
+
+getNodeView.define(ActionButton, button =>
+ view.nodeFor(toWidgetId(button.id))
+);
+
+var actionButtonStateEvents = events.filter(stateEvents,
+ e => e.target instanceof ActionButton);
+
+var actionButtonViewEvents = events.filter(viewEvents,
+ e => buttons.has(e.target));
+
+var clickEvents = events.filter(actionButtonViewEvents, e => e.type === 'click');
+var updateEvents = events.filter(actionButtonViewEvents, e => e.type === 'update');
+
+on(clickEvents, 'data', ({target: id, window}) => {
+ let button = buttons.get(id);
+ let state = getDerivedStateFor(button, getActiveTab(window));
+
+ emit(button, 'click', state);
+});
+
+on(updateEvents, 'data', ({target: id, window}) => {
+ render(buttons.get(id), window);
+});
+
+on(actionButtonStateEvents, 'data', ({target, window, state}) => {
+ let id = toWidgetId(target.id);
+ view.setIcon(id, window, state.icon);
+ view.setLabel(id, window, state.label);
+ view.setDisabled(id, window, state.disabled);
+ view.setBadge(id, window, state.badge, state.badgeColor);
+});
diff --git a/toolkit/jetpack/sdk/ui/button/contract.js b/toolkit/jetpack/sdk/ui/button/contract.js
new file mode 100644
index 000000000..ce6e33d95
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/button/contract.js
@@ -0,0 +1,73 @@
+/* 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';
+
+const { contract } = require('../../util/contract');
+const { isLocalURL } = require('../../url');
+const { isNil, isObject, isString } = require('../../lang/type');
+const { required, either, string, boolean, object, number } = require('../../deprecated/api-utils');
+const { merge } = require('../../util/object');
+const { freeze } = Object;
+
+const isIconSet = (icons) =>
+ Object.keys(icons).
+ every(size => String(size >>> 0) === size && isLocalURL(icons[size]));
+
+var iconSet = {
+ is: either(object, string),
+ map: v => isObject(v) ? freeze(merge({}, v)) : v,
+ ok: v => (isString(v) && isLocalURL(v)) || (isObject(v) && isIconSet(v)),
+ msg: 'The option "icon" must be a local URL or an object with ' +
+ 'numeric keys / local URL values pair.'
+}
+
+var id = {
+ is: string,
+ ok: v => /^[a-z-_][a-z0-9-_]*$/i.test(v),
+ msg: 'The option "id" must be a valid alphanumeric id (hyphens and ' +
+ 'underscores are allowed).'
+};
+
+var label = {
+ is: string,
+ ok: v => isNil(v) || v.trim().length > 0,
+ msg: 'The option "label" must be a non empty string'
+}
+
+var badge = {
+ is: either(string, number),
+ msg: 'The option "badge" must be a string or a number'
+}
+
+var badgeColor = {
+ is: string,
+ msg: 'The option "badgeColor" must be a string'
+}
+
+var stateContract = contract({
+ label: label,
+ icon: iconSet,
+ disabled: boolean,
+ badge: badge,
+ badgeColor: badgeColor
+});
+
+exports.stateContract = stateContract;
+
+var buttonContract = contract(merge({}, stateContract.rules, {
+ id: required(id),
+ label: required(label),
+ icon: required(iconSet)
+}));
+
+exports.buttonContract = buttonContract;
+
+exports.toggleStateContract = contract(merge({
+ checked: boolean
+}, stateContract.rules));
+
+exports.toggleButtonContract = contract(merge({
+ checked: boolean
+}, buttonContract.rules));
+
diff --git a/toolkit/jetpack/sdk/ui/button/toggle.js b/toolkit/jetpack/sdk/ui/button/toggle.js
new file mode 100644
index 000000000..a226b3212
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/button/toggle.js
@@ -0,0 +1,127 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '> 28'
+ }
+};
+
+const { Class } = require('../../core/heritage');
+const { merge } = require('../../util/object');
+const { Disposable } = require('../../core/disposable');
+const { on, off, emit, setListeners } = require('../../event/core');
+const { EventTarget } = require('../../event/target');
+const { getNodeView } = require('../../view/core');
+
+const view = require('./view');
+const { toggleButtonContract, toggleStateContract } = require('./contract');
+const { properties, render, state, register, unregister,
+ setStateFor, getStateFor, getDerivedStateFor } = require('../state');
+const { events: stateEvents } = require('../state/events');
+const { events: viewEvents } = require('./view/events');
+const events = require('../../event/utils');
+
+const { getActiveTab } = require('../../tabs/utils');
+
+const { id: addonID } = require('../../self');
+const { identify } = require('../id');
+
+const buttons = new Map();
+
+const toWidgetId = id =>
+ ('toggle-button--' + addonID.toLowerCase()+ '-' + id).
+ replace(/[^a-z0-9_-]/g, '');
+
+const ToggleButton = Class({
+ extends: EventTarget,
+ implements: [
+ properties(toggleStateContract),
+ state(toggleStateContract),
+ Disposable
+ ],
+ setup: function setup(options) {
+ let state = merge({
+ disabled: false,
+ checked: false
+ }, toggleButtonContract(options));
+
+ let id = toWidgetId(options.id);
+
+ register(this, state);
+
+ // Setup listeners.
+ setListeners(this, options);
+
+ buttons.set(id, this);
+
+ view.create(merge({ type: 'checkbox' }, state, { id: id }));
+ },
+
+ dispose: function dispose() {
+ let id = toWidgetId(this.id);
+ buttons.delete(id);
+
+ off(this);
+
+ view.dispose(id);
+
+ unregister(this);
+ },
+
+ get id() {
+ return this.state().id;
+ },
+
+ click: function click() {
+ return view.click(toWidgetId(this.id));
+ }
+});
+exports.ToggleButton = ToggleButton;
+
+identify.define(ToggleButton, ({id}) => toWidgetId(id));
+
+getNodeView.define(ToggleButton, button =>
+ view.nodeFor(toWidgetId(button.id))
+);
+
+var toggleButtonStateEvents = events.filter(stateEvents,
+ e => e.target instanceof ToggleButton);
+
+var toggleButtonViewEvents = events.filter(viewEvents,
+ e => buttons.has(e.target));
+
+var clickEvents = events.filter(toggleButtonViewEvents, e => e.type === 'click');
+var updateEvents = events.filter(toggleButtonViewEvents, e => e.type === 'update');
+
+on(toggleButtonStateEvents, 'data', ({target, window, state}) => {
+ let id = toWidgetId(target.id);
+
+ view.setIcon(id, window, state.icon);
+ view.setLabel(id, window, state.label);
+ view.setDisabled(id, window, state.disabled);
+ view.setChecked(id, window, state.checked);
+ view.setBadge(id, window, state.badge, state.badgeColor);
+});
+
+on(clickEvents, 'data', ({target: id, window, checked }) => {
+ let button = buttons.get(id);
+ let windowState = getStateFor(button, window);
+
+ let newWindowState = merge({}, windowState, { checked: checked });
+
+ setStateFor(button, window, newWindowState);
+
+ let state = getDerivedStateFor(button, getActiveTab(window));
+
+ emit(button, 'click', state);
+
+ emit(button, 'change', state);
+});
+
+on(updateEvents, 'data', ({target: id, window}) => {
+ render(buttons.get(id), window);
+});
diff --git a/toolkit/jetpack/sdk/ui/button/view.js b/toolkit/jetpack/sdk/ui/button/view.js
new file mode 100644
index 000000000..63b7aea31
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/button/view.js
@@ -0,0 +1,243 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '> 28'
+ }
+};
+
+const { Cu } = require('chrome');
+const { on, off, emit } = require('../../event/core');
+
+const { data } = require('sdk/self');
+
+const { isObject, isNil } = require('../../lang/type');
+
+const { getMostRecentBrowserWindow } = require('../../window/utils');
+const { ignoreWindow } = require('../../private-browsing/utils');
+const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {});
+const { AREA_PANEL, AREA_NAVBAR } = CustomizableUI;
+
+const { events: viewEvents } = require('./view/events');
+
+const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul';
+
+const views = new Map();
+const customizedWindows = new WeakMap();
+
+const buttonListener = {
+ onCustomizeStart: window => {
+ for (let [id, view] of views) {
+ setIcon(id, window, view.icon);
+ setLabel(id, window, view.label);
+ }
+
+ customizedWindows.set(window, true);
+ },
+ onCustomizeEnd: window => {
+ customizedWindows.delete(window);
+
+ for (let [id, ] of views) {
+ let placement = CustomizableUI.getPlacementOfWidget(id);
+
+ if (placement)
+ emit(viewEvents, 'data', { type: 'update', target: id, window: window });
+ }
+ },
+ onWidgetAfterDOMChange: (node, nextNode, container) => {
+ let { id } = node;
+ let view = views.get(id);
+ let window = node.ownerDocument.defaultView;
+
+ if (view) {
+ emit(viewEvents, 'data', { type: 'update', target: id, window: window });
+ }
+ }
+};
+
+CustomizableUI.addListener(buttonListener);
+
+require('../../system/unload').when( _ =>
+ CustomizableUI.removeListener(buttonListener)
+);
+
+function getNode(id, window) {
+ return !views.has(id) || ignoreWindow(window)
+ ? null
+ : CustomizableUI.getWidget(id).forWindow(window).node
+};
+
+function isInToolbar(id) {
+ let placement = CustomizableUI.getPlacementOfWidget(id);
+
+ return placement && CustomizableUI.getAreaType(placement.area) === 'toolbar';
+}
+
+
+function getImage(icon, isInToolbar, pixelRatio) {
+ let targetSize = (isInToolbar ? 18 : 32) * pixelRatio;
+ let bestSize = 0;
+ let image = icon;
+
+ if (isObject(icon)) {
+ for (let size of Object.keys(icon)) {
+ size = +size;
+ let offset = targetSize - size;
+
+ if (offset === 0) {
+ bestSize = size;
+ break;
+ }
+
+ let delta = Math.abs(offset) - Math.abs(targetSize - bestSize);
+
+ if (delta < 0)
+ bestSize = size;
+ }
+
+ image = icon[bestSize];
+ }
+
+ if (image.indexOf('./') === 0)
+ return data.url(image.substr(2));
+
+ return image;
+}
+
+function nodeFor(id, window=getMostRecentBrowserWindow()) {
+ return customizedWindows.has(window) ? null : getNode(id, window);
+};
+exports.nodeFor = nodeFor;
+
+function create(options) {
+ let { id, label, icon, type, badge } = options;
+
+ if (views.has(id))
+ throw new Error('The ID "' + id + '" seems already used.');
+
+ CustomizableUI.createWidget({
+ id: id,
+ type: 'custom',
+ removable: true,
+ defaultArea: AREA_NAVBAR,
+ allowedAreas: [ AREA_PANEL, AREA_NAVBAR ],
+
+ onBuild: function(document) {
+ let window = document.defaultView;
+
+ let node = document.createElementNS(XUL_NS, 'toolbarbutton');
+
+ let image = getImage(icon, true, window.devicePixelRatio);
+
+ if (ignoreWindow(window))
+ node.style.display = 'none';
+
+ node.setAttribute('id', this.id);
+ node.setAttribute('class', 'toolbarbutton-1 chromeclass-toolbar-additional badged-button');
+ node.setAttribute('type', type);
+ node.setAttribute('label', label);
+ node.setAttribute('tooltiptext', label);
+ node.setAttribute('image', image);
+ node.setAttribute('constrain-size', 'true');
+
+ views.set(id, {
+ area: this.currentArea,
+ icon: icon,
+ label: label
+ });
+
+ node.addEventListener('command', function(event) {
+ if (views.has(id)) {
+ emit(viewEvents, 'data', {
+ type: 'click',
+ target: id,
+ window: event.view,
+ checked: node.checked
+ });
+ }
+ });
+
+ return node;
+ }
+ });
+};
+exports.create = create;
+
+function dispose(id) {
+ if (!views.has(id)) return;
+
+ views.delete(id);
+ CustomizableUI.destroyWidget(id);
+}
+exports.dispose = dispose;
+
+function setIcon(id, window, icon) {
+ let node = getNode(id, window);
+
+ if (node) {
+ icon = customizedWindows.has(window) ? views.get(id).icon : icon;
+ let image = getImage(icon, isInToolbar(id), window.devicePixelRatio);
+
+ node.setAttribute('image', image);
+ }
+}
+exports.setIcon = setIcon;
+
+function setLabel(id, window, label) {
+ let node = nodeFor(id, window);
+
+ if (node) {
+ node.setAttribute('label', label);
+ node.setAttribute('tooltiptext', label);
+ }
+}
+exports.setLabel = setLabel;
+
+function setDisabled(id, window, disabled) {
+ let node = nodeFor(id, window);
+
+ if (node)
+ node.disabled = disabled;
+}
+exports.setDisabled = setDisabled;
+
+function setChecked(id, window, checked) {
+ let node = nodeFor(id, window);
+
+ if (node)
+ node.checked = checked;
+}
+exports.setChecked = setChecked;
+
+function setBadge(id, window, badge, color) {
+ let node = nodeFor(id, window);
+
+ if (node) {
+ // `Array.from` is needed to handle unicode symbol properly:
+ // '𝐀𝐁'.length is 4 where Array.from('𝐀𝐁').length is 2
+ let text = isNil(badge)
+ ? ''
+ : Array.from(String(badge)).slice(0, 4).join('');
+
+ node.setAttribute('badge', text);
+
+ let badgeNode = node.ownerDocument.getAnonymousElementByAttribute(node,
+ 'class', 'toolbarbutton-badge');
+
+ if (badgeNode)
+ badgeNode.style.backgroundColor = isNil(color) ? '' : color;
+ }
+}
+exports.setBadge = setBadge;
+
+function click(id) {
+ let node = nodeFor(id);
+
+ if (node)
+ node.click();
+}
+exports.click = click;
diff --git a/toolkit/jetpack/sdk/ui/button/view/events.js b/toolkit/jetpack/sdk/ui/button/view/events.js
new file mode 100644
index 000000000..98909656a
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/button/view/events.js
@@ -0,0 +1,18 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '*',
+ 'SeaMonkey': '*',
+ 'Thunderbird': '*'
+ }
+};
+
+var channel = {};
+
+exports.events = channel;
diff --git a/toolkit/jetpack/sdk/ui/component.js b/toolkit/jetpack/sdk/ui/component.js
new file mode 100644
index 000000000..d1f12c95e
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/component.js
@@ -0,0 +1,182 @@
+/* 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";
+
+// Internal properties not exposed to the public.
+const cache = Symbol("component/cache");
+const writer = Symbol("component/writer");
+const isFirstWrite = Symbol("component/writer/first-write?");
+const currentState = Symbol("component/state/current");
+const pendingState = Symbol("component/state/pending");
+const isWriting = Symbol("component/writing?");
+
+const isntNull = x => x !== null;
+
+const Component = function(options, children) {
+ this[currentState] = null;
+ this[pendingState] = null;
+ this[writer] = null;
+ this[cache] = null;
+ this[isFirstWrite] = true;
+
+ this[Component.construct](options, children);
+}
+Component.Component = Component;
+// Constructs component.
+Component.construct = Symbol("component/construct");
+// Called with `options` and `children` and must return
+// initial state back.
+Component.initial = Symbol("component/initial");
+
+// Function patches current `state` with a given update.
+Component.patch = Symbol("component/patch");
+// Function that replaces current `state` with a passed state.
+Component.reset = Symbol("component/reset");
+
+// Function that must return render tree from passed state.
+Component.render = Symbol("component/render");
+
+// Path of the component with in the mount point.
+Component.path = Symbol("component/path");
+
+Component.isMounted = component => !!component[writer];
+Component.isWriting = component => !!component[isWriting];
+
+// Internal method that mounts component to a writer.
+// Mounts component to a writer.
+Component.mount = (component, write) => {
+ if (Component.isMounted(component)) {
+ throw Error("Can not mount already mounted component");
+ }
+
+ component[writer] = write;
+ Component.write(component);
+
+ if (component[Component.mounted]) {
+ component[Component.mounted]();
+ }
+}
+
+// Unmounts component from a writer.
+Component.unmount = (component) => {
+ if (Component.isMounted(component)) {
+ component[writer] = null;
+ if (component[Component.unmounted]) {
+ component[Component.unmounted]();
+ }
+ } else {
+ console.warn("Unmounting component that is not mounted is redundant");
+ }
+};
+ // Method invoked once after inital write occurs.
+Component.mounted = Symbol("component/mounted");
+// Internal method that unmounts component from the writer.
+Component.unmounted = Symbol("component/unmounted");
+// Function that must return true if component is changed
+Component.isUpdated = Symbol("component/updated?");
+Component.update = Symbol("component/update");
+Component.updated = Symbol("component/updated");
+
+const writeChild = base => (child, index) => Component.write(child, base, index)
+Component.write = (component, base, index) => {
+ if (component === null) {
+ return component;
+ }
+
+ if (!(component instanceof Component)) {
+ const path = base ? `${base}${component.key || index}/` : `/`;
+ return Object.assign({}, component, {
+ [Component.path]: path,
+ children: component.children && component.children.
+ map(writeChild(path)).
+ filter(isntNull)
+ });
+ }
+
+ component[isWriting] = true;
+
+ try {
+
+ const current = component[currentState];
+ const pending = component[pendingState] || current;
+ const isUpdated = component[Component.isUpdated];
+ const isInitial = component[isFirstWrite];
+
+ if (isUpdated(current, pending) || isInitial) {
+ if (!isInitial && component[Component.update]) {
+ component[Component.update](pending, current)
+ }
+
+ // Note: [Component.update] could have caused more updates so can't use
+ // `pending` as `component[pendingState]` may have changed.
+ component[currentState] = component[pendingState] || current;
+ component[pendingState] = null;
+
+ const tree = component[Component.render](component[currentState]);
+ component[cache] = Component.write(tree, base, index);
+ if (component[writer]) {
+ component[writer].call(null, component[cache]);
+ }
+
+ if (!isInitial && component[Component.updated]) {
+ component[Component.updated](current, pending);
+ }
+ }
+
+ component[isFirstWrite] = false;
+
+ return component[cache];
+ } finally {
+ component[isWriting] = false;
+ }
+};
+
+Component.prototype = Object.freeze({
+ constructor: Component,
+
+ [Component.mounted]: null,
+ [Component.unmounted]: null,
+ [Component.update]: null,
+ [Component.updated]: null,
+
+ get state() {
+ return this[pendingState] || this[currentState];
+ },
+
+
+ [Component.construct](settings, items) {
+ const initial = this[Component.initial];
+ const base = initial(settings, items);
+ const options = Object.assign(Object.create(null), base.options, settings);
+ const children = base.children || items || null;
+ const state = Object.assign(Object.create(null), base, {options, children});
+ this[currentState] = state;
+
+ if (this.setup) {
+ this.setup(state);
+ }
+ },
+ [Component.initial](options, children) {
+ return Object.create(null);
+ },
+ [Component.patch](update) {
+ this[Component.reset](Object.assign({}, this.state, update));
+ },
+ [Component.reset](state) {
+ this[pendingState] = state;
+ if (Component.isMounted(this) && !Component.isWriting(this)) {
+ Component.write(this);
+ }
+ },
+
+ [Component.isUpdated](before, after) {
+ return before != after
+ },
+
+ [Component.render](state) {
+ throw Error("Component must implement [Component.render] member");
+ }
+});
+
+module.exports = Component;
diff --git a/toolkit/jetpack/sdk/ui/frame.js b/toolkit/jetpack/sdk/ui/frame.js
new file mode 100644
index 000000000..566353cdf
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/frame.js
@@ -0,0 +1,16 @@
+/* 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";
+
+module.metadata = {
+ "stability": "experimental",
+ "engines": {
+ "Firefox": "> 28"
+ }
+};
+
+require("./frame/view");
+const { Frame } = require("./frame/model");
+
+exports.Frame = Frame;
diff --git a/toolkit/jetpack/sdk/ui/frame/model.js b/toolkit/jetpack/sdk/ui/frame/model.js
new file mode 100644
index 000000000..627310874
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/frame/model.js
@@ -0,0 +1,154 @@
+/* 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";
+
+module.metadata = {
+ "stability": "experimental",
+ "engines": {
+ "Firefox": "> 28"
+ }
+};
+
+const { Class } = require("../../core/heritage");
+const { EventTarget } = require("../../event/target");
+const { emit, off, setListeners } = require("../../event/core");
+const { Reactor, foldp, send, merges } = require("../../event/utils");
+const { Disposable } = require("../../core/disposable");
+const { OutputPort } = require("../../output/system");
+const { InputPort } = require("../../input/system");
+const { identify } = require("../id");
+const { pairs, object, map, each } = require("../../util/sequence");
+const { patch, diff } = require("diffpatcher/index");
+const { isLocalURL } = require("../../url");
+const { compose } = require("../../lang/functional");
+const { contract } = require("../../util/contract");
+const { id: addonID, data: { url: resolve }} = require("../../self");
+const { Frames } = require("../../input/frame");
+
+
+const output = new OutputPort({ id: "frame-change" });
+const mailbox = new OutputPort({ id: "frame-mailbox" });
+const input = Frames;
+
+
+const makeID = url =>
+ ("frame-" + addonID + "-" + url).
+ split("/").join("-").
+ split(".").join("-").
+ replace(/[^A-Za-z0-9_\-]/g, "");
+
+const validate = contract({
+ name: {
+ is: ["string", "undefined"],
+ ok: x => /^[a-z][a-z0-9-_]+$/i.test(x),
+ msg: "The `option.name` must be a valid alphanumeric string (hyphens and " +
+ "underscores are allowed) starting with letter."
+ },
+ url: {
+ map: x => x.toString(),
+ is: ["string"],
+ ok: x => isLocalURL(x),
+ msg: "The `options.url` must be a valid local URI."
+ }
+});
+
+const Source = function({id, ownerID}) {
+ this.id = id;
+ this.ownerID = ownerID;
+};
+Source.postMessage = ({id, ownerID}, data, origin) => {
+ send(mailbox, object([id, {
+ inbox: {
+ target: {id: id, ownerID: ownerID},
+ timeStamp: Date.now(),
+ data: data,
+ origin: origin
+ }
+ }]));
+};
+Source.prototype.postMessage = function(data, origin) {
+ Source.postMessage(this, data, origin);
+};
+
+const Message = function({type, data, source, origin, timeStamp}) {
+ this.type = type;
+ this.data = data;
+ this.origin = origin;
+ this.timeStamp = timeStamp;
+ this.source = new Source(source);
+};
+
+
+const frames = new Map();
+const sources = new Map();
+
+const Frame = Class({
+ extends: EventTarget,
+ implements: [Disposable, Source],
+ initialize: function(params={}) {
+ const options = validate(params);
+ const id = makeID(options.name || options.url);
+
+ if (frames.has(id))
+ throw Error("Frame with this id already exists: " + id);
+
+ const initial = { id: id, url: resolve(options.url) };
+ this.id = id;
+
+ setListeners(this, params);
+
+ frames.set(this.id, this);
+
+ send(output, object([id, initial]));
+ },
+ get url() {
+ const state = reactor.value[this.id];
+ return state && state.url;
+ },
+ destroy: function() {
+ send(output, object([this.id, null]));
+ frames.delete(this.id);
+ off(this);
+ },
+ // `JSON.stringify` serializes objects based of the return
+ // value of this method. For convinienc we provide this method
+ // to serialize actual state data.
+ toJSON: function() {
+ return { id: this.id, url: this.url };
+ }
+});
+identify.define(Frame, frame => frame.id);
+
+exports.Frame = Frame;
+
+const reactor = new Reactor({
+ onStep: (present, past) => {
+ const delta = diff(past, present);
+
+ each(([id, update]) => {
+ const frame = frames.get(id);
+ if (update) {
+ if (!past[id])
+ emit(frame, "register");
+
+ if (update.outbox)
+ emit(frame, "message", new Message(present[id].outbox));
+
+ each(([ownerID, state]) => {
+ const readyState = state ? state.readyState : "detach";
+ const type = readyState === "loading" ? "attach" :
+ readyState === "interactive" ? "ready" :
+ readyState === "complete" ? "load" :
+ readyState;
+
+ // TODO: Cache `Source` instances somewhere to preserve
+ // identity.
+ emit(frame, type, {type: type,
+ source: new Source({id: id, ownerID: ownerID})});
+ }, pairs(update.owners));
+ }
+ }, pairs(delta));
+ }
+});
+reactor.run(input);
diff --git a/toolkit/jetpack/sdk/ui/frame/view.html b/toolkit/jetpack/sdk/ui/frame/view.html
new file mode 100644
index 000000000..2a405b583
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/frame/view.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <script>
+ // HACK: This is not an ideal way to deliver chrome messages
+ // to an inner frame content but seems only way that would
+ // make `event.source` this (outer frame) window.
+ window.onmessage = function(event) {
+ var frame = document.querySelector("iframe");
+ var content = frame.contentWindow;
+ // If message is posted from chrome it has no `event.source`.
+ if (event.source === null)
+ content.postMessage(event.data, "*");
+ };
+ </script>
+ </head>
+ <body style="overflow: hidden"></body>
+</html>
diff --git a/toolkit/jetpack/sdk/ui/frame/view.js b/toolkit/jetpack/sdk/ui/frame/view.js
new file mode 100644
index 000000000..2eb4df2b7
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/frame/view.js
@@ -0,0 +1,150 @@
+/* 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";
+
+module.metadata = {
+ "stability": "experimental",
+ "engines": {
+ "Firefox": "> 28"
+ }
+};
+
+const { Cu, Ci } = require("chrome");
+const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {});
+const { subscribe, send, Reactor, foldp, lift, merges, keepIf } = require("../../event/utils");
+const { InputPort } = require("../../input/system");
+const { OutputPort } = require("../../output/system");
+const { LastClosed } = require("../../input/browser");
+const { pairs, keys, object, each } = require("../../util/sequence");
+const { curry, compose } = require("../../lang/functional");
+const { getFrameElement, getOuterId,
+ getByOuterId, getOwnerBrowserWindow } = require("../../window/utils");
+const { patch, diff } = require("diffpatcher/index");
+const { encode } = require("../../base64");
+const { Frames } = require("../../input/frame");
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+const OUTER_FRAME_URI = module.uri.replace(/\.js$/, ".html");
+
+const mailbox = new OutputPort({ id: "frame-mailbox" });
+
+const frameID = frame => frame.id.replace("outer-", "");
+const windowID = compose(getOuterId, getOwnerBrowserWindow);
+
+const getOuterFrame = (windowID, frameID) =>
+ getByOuterId(windowID).document.getElementById("outer-" + frameID);
+
+const listener = ({target, source, data, origin, timeStamp}) => {
+ // And sent received message to outbox so that frame API model
+ // will deal with it.
+ if (source && source !== target) {
+ const frame = getFrameElement(target);
+ const id = frameID(frame);
+ send(mailbox, object([id, {
+ outbox: {type: "message",
+ source: {id: id, ownerID: windowID(frame)},
+ data: data,
+ origin: origin,
+ timeStamp: timeStamp}}]));
+ }
+};
+
+// Utility function used to create frame with a given `state` and
+// inject it into given `window`.
+const registerFrame = ({id, url}) => {
+ CustomizableUI.createWidget({
+ id: id,
+ type: "custom",
+ removable: true,
+ onBuild: document => {
+ let view = document.createElementNS(XUL_NS, "toolbaritem");
+ view.setAttribute("id", id);
+ view.setAttribute("flex", 2);
+
+ let outerFrame = document.createElementNS(XUL_NS, "iframe");
+ outerFrame.setAttribute("src", OUTER_FRAME_URI);
+ outerFrame.setAttribute("id", "outer-" + id);
+ outerFrame.setAttribute("data-is-sdk-outer-frame", true);
+ outerFrame.setAttribute("type", "content");
+ outerFrame.setAttribute("transparent", true);
+ outerFrame.setAttribute("flex", 2);
+ outerFrame.setAttribute("style", "overflow: hidden;");
+ outerFrame.setAttribute("scrolling", "no");
+ outerFrame.setAttribute("disablehistory", true);
+ outerFrame.setAttribute("seamless", "seamless");
+ outerFrame.addEventListener("load", function onload() {
+ outerFrame.removeEventListener("load", onload, true);
+
+ let doc = outerFrame.contentDocument;
+
+ let innerFrame = doc.createElementNS(HTML_NS, "iframe");
+ innerFrame.setAttribute("id", id);
+ innerFrame.setAttribute("src", url);
+ innerFrame.setAttribute("seamless", "seamless");
+ innerFrame.setAttribute("sandbox", "allow-scripts");
+ innerFrame.setAttribute("scrolling", "no");
+ innerFrame.setAttribute("data-is-sdk-inner-frame", true);
+ innerFrame.setAttribute("style", [ "border:none",
+ "position:absolute", "width:100%", "top: 0",
+ "left: 0", "overflow: hidden"].join(";"));
+
+ doc.body.appendChild(innerFrame);
+ }, true);
+
+ view.appendChild(outerFrame);
+
+ return view;
+ }
+ });
+};
+
+const unregisterFrame = CustomizableUI.destroyWidget;
+
+const deliverMessage = curry((frameID, data, windowID) => {
+ const frame = getOuterFrame(windowID, frameID);
+ const content = frame && frame.contentWindow;
+
+ if (content)
+ content.postMessage(data, content.location.origin);
+});
+
+const updateFrame = (id, {inbox, owners}, present) => {
+ if (inbox) {
+ const { data, target:{ownerID}, source } = present[id].inbox;
+ if (ownerID)
+ deliverMessage(id, data, ownerID);
+ else
+ each(deliverMessage(id, data), keys(present[id].owners));
+ }
+
+ each(setupView(id), pairs(owners));
+};
+
+const setupView = curry((frameID, [windowID, state]) => {
+ if (state && state.readyState === "loading") {
+ const frame = getOuterFrame(windowID, frameID);
+ // Setup a message listener on contentWindow.
+ frame.contentWindow.addEventListener("message", listener);
+ }
+});
+
+
+const reactor = new Reactor({
+ onStep: (present, past) => {
+ const delta = diff(past, present);
+
+ // Apply frame changes
+ each(([id, update]) => {
+ if (update === null)
+ unregisterFrame(id);
+ else if (past[id])
+ updateFrame(id, update, present);
+ else
+ registerFrame(update);
+ }, pairs(delta));
+ },
+ onEnd: state => each(unregisterFrame, keys(state))
+});
+reactor.run(Frames);
diff --git a/toolkit/jetpack/sdk/ui/id.js b/toolkit/jetpack/sdk/ui/id.js
new file mode 100644
index 000000000..d17eb0a4e
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/id.js
@@ -0,0 +1,27 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'experimental'
+};
+
+const method = require('../../method/core');
+const { uuid } = require('../util/uuid');
+
+// NOTE: use lang/functional memoize when it is updated to use WeakMap
+function memoize(f) {
+ const memo = new WeakMap();
+
+ return function memoizer(o) {
+ let key = o;
+ if (!memo.has(key))
+ memo.set(key, f.apply(this, arguments));
+ return memo.get(key);
+ };
+}
+
+var identify = method('identify');
+identify.define(Object, memoize(function() { return uuid(); }));
+exports.identify = identify;
diff --git a/toolkit/jetpack/sdk/ui/sidebar.js b/toolkit/jetpack/sdk/ui/sidebar.js
new file mode 100644
index 000000000..59e35ea11
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/sidebar.js
@@ -0,0 +1,311 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '*'
+ }
+};
+
+const { Class } = require('../core/heritage');
+const { merge } = require('../util/object');
+const { Disposable } = require('../core/disposable');
+const { off, emit, setListeners } = require('../event/core');
+const { EventTarget } = require('../event/target');
+const { URL } = require('../url');
+const { add, remove, has, clear, iterator } = require('../lang/weak-set');
+const { id: addonID, data } = require('../self');
+const { WindowTracker } = require('../deprecated/window-utils');
+const { isShowing } = require('./sidebar/utils');
+const { isBrowser, getMostRecentBrowserWindow, windows, isWindowPrivate } = require('../window/utils');
+const { ns } = require('../core/namespace');
+const { remove: removeFromArray } = require('../util/array');
+const { show, hide, toggle } = require('./sidebar/actions');
+const { Worker } = require('../deprecated/sync-worker');
+const { contract: sidebarContract } = require('./sidebar/contract');
+const { create, dispose, updateTitle, updateURL, isSidebarShowing, showSidebar, hideSidebar } = require('./sidebar/view');
+const { defer } = require('../core/promise');
+const { models, views, viewsFor, modelFor } = require('./sidebar/namespace');
+const { isLocalURL } = require('../url');
+const { ensure } = require('../system/unload');
+const { identify } = require('./id');
+const { uuid } = require('../util/uuid');
+const { viewFor } = require('../view/core');
+
+const resolveURL = (url) => url ? data.url(url) : url;
+
+const sidebarNS = ns();
+
+const WEB_PANEL_BROWSER_ID = 'web-panels-browser';
+
+var sidebars = {};
+
+const Sidebar = Class({
+ implements: [ Disposable ],
+ extends: EventTarget,
+ setup: function(options) {
+ // inital validation for the model information
+ let model = sidebarContract(options);
+
+ // save the model information
+ models.set(this, model);
+
+ // generate an id if one was not provided
+ model.id = model.id || addonID + '-' + uuid();
+
+ // further validation for the title and url
+ validateTitleAndURLCombo({}, this.title, this.url);
+
+ const self = this;
+ const internals = sidebarNS(self);
+ const windowNS = internals.windowNS = ns();
+
+ // see bug https://bugzilla.mozilla.org/show_bug.cgi?id=886148
+ ensure(this, 'destroy');
+
+ setListeners(this, options);
+
+ let bars = [];
+ internals.tracker = WindowTracker({
+ onTrack: function(window) {
+ if (!isBrowser(window))
+ return;
+
+ let sidebar = window.document.getElementById('sidebar');
+ let sidebarBox = window.document.getElementById('sidebar-box');
+
+ let bar = create(window, {
+ id: self.id,
+ title: self.title,
+ sidebarurl: self.url
+ });
+ bars.push(bar);
+ windowNS(window).bar = bar;
+
+ bar.addEventListener('command', function() {
+ if (isSidebarShowing(window, self)) {
+ hideSidebar(window, self).catch(() => {});
+ return;
+ }
+
+ showSidebar(window, self);
+ }, false);
+
+ function onSidebarLoad() {
+ // check if the sidebar is ready
+ let isReady = sidebar.docShell && sidebar.contentDocument;
+ if (!isReady)
+ return;
+
+ // check if it is a web panel
+ let panelBrowser = sidebar.contentDocument.getElementById(WEB_PANEL_BROWSER_ID);
+ if (!panelBrowser) {
+ bar.removeAttribute('checked');
+ return;
+ }
+
+ let sbTitle = window.document.getElementById('sidebar-title');
+ function onWebPanelSidebarCreated() {
+ if (panelBrowser.contentWindow.location != resolveURL(model.url) ||
+ sbTitle.value != model.title) {
+ return;
+ }
+
+ let worker = windowNS(window).worker = Worker({
+ window: panelBrowser.contentWindow,
+ injectInDocument: true
+ });
+
+ function onWebPanelSidebarUnload() {
+ windowNS(window).onWebPanelSidebarUnload = null;
+
+ // uncheck the associated menuitem
+ bar.setAttribute('checked', 'false');
+
+ emit(self, 'hide', {});
+ emit(self, 'detach', worker);
+ windowNS(window).worker = null;
+ }
+ windowNS(window).onWebPanelSidebarUnload = onWebPanelSidebarUnload;
+ panelBrowser.contentWindow.addEventListener('unload', onWebPanelSidebarUnload, true);
+
+ // check the associated menuitem
+ bar.setAttribute('checked', 'true');
+
+ function onWebPanelSidebarReady() {
+ panelBrowser.contentWindow.removeEventListener('DOMContentLoaded', onWebPanelSidebarReady, false);
+ windowNS(window).onWebPanelSidebarReady = null;
+
+ emit(self, 'ready', worker);
+ }
+ windowNS(window).onWebPanelSidebarReady = onWebPanelSidebarReady;
+ panelBrowser.contentWindow.addEventListener('DOMContentLoaded', onWebPanelSidebarReady, false);
+
+ function onWebPanelSidebarLoad() {
+ panelBrowser.contentWindow.removeEventListener('load', onWebPanelSidebarLoad, true);
+ windowNS(window).onWebPanelSidebarLoad = null;
+
+ // TODO: decide if returning worker is acceptable..
+ //emit(self, 'show', { worker: worker });
+ emit(self, 'show', {});
+ }
+ windowNS(window).onWebPanelSidebarLoad = onWebPanelSidebarLoad;
+ panelBrowser.contentWindow.addEventListener('load', onWebPanelSidebarLoad, true);
+
+ emit(self, 'attach', worker);
+ }
+ windowNS(window).onWebPanelSidebarCreated = onWebPanelSidebarCreated;
+ panelBrowser.addEventListener('DOMWindowCreated', onWebPanelSidebarCreated, true);
+ }
+ windowNS(window).onSidebarLoad = onSidebarLoad;
+ sidebar.addEventListener('load', onSidebarLoad, true); // removed properly
+ },
+ onUntrack: function(window) {
+ if (!isBrowser(window))
+ return;
+
+ // hide the sidebar if it is showing
+ hideSidebar(window, self).catch(() => {});
+
+ // kill the menu item
+ let { bar } = windowNS(window);
+ if (bar) {
+ removeFromArray(viewsFor(self), bar);
+ dispose(bar);
+ }
+
+ // kill listeners
+ let sidebar = window.document.getElementById('sidebar');
+
+ if (windowNS(window).onSidebarLoad) {
+ sidebar && sidebar.removeEventListener('load', windowNS(window).onSidebarLoad, true)
+ windowNS(window).onSidebarLoad = null;
+ }
+
+ let panelBrowser = sidebar && sidebar.contentDocument.getElementById(WEB_PANEL_BROWSER_ID);
+ if (windowNS(window).onWebPanelSidebarCreated) {
+ panelBrowser && panelBrowser.removeEventListener('DOMWindowCreated', windowNS(window).onWebPanelSidebarCreated, true);
+ windowNS(window).onWebPanelSidebarCreated = null;
+ }
+
+ if (windowNS(window).onWebPanelSidebarReady) {
+ panelBrowser && panelBrowser.contentWindow.removeEventListener('DOMContentLoaded', windowNS(window).onWebPanelSidebarReady, false);
+ windowNS(window).onWebPanelSidebarReady = null;
+ }
+
+ if (windowNS(window).onWebPanelSidebarLoad) {
+ panelBrowser && panelBrowser.contentWindow.removeEventListener('load', windowNS(window).onWebPanelSidebarLoad, true);
+ windowNS(window).onWebPanelSidebarLoad = null;
+ }
+
+ if (windowNS(window).onWebPanelSidebarUnload) {
+ panelBrowser && panelBrowser.contentWindow.removeEventListener('unload', windowNS(window).onWebPanelSidebarUnload, true);
+ windowNS(window).onWebPanelSidebarUnload();
+ }
+ }
+ });
+
+ views.set(this, bars);
+
+ add(sidebars, this);
+ },
+ get id() {
+ return (modelFor(this) || {}).id;
+ },
+ get title() {
+ return (modelFor(this) || {}).title;
+ },
+ set title(v) {
+ // destroyed?
+ if (!modelFor(this))
+ return;
+ // validation
+ if (typeof v != 'string')
+ throw Error('title must be a string');
+ validateTitleAndURLCombo(this, v, this.url);
+ // do update
+ updateTitle(this, v);
+ return modelFor(this).title = v;
+ },
+ get url() {
+ return (modelFor(this) || {}).url;
+ },
+ set url(v) {
+ // destroyed?
+ if (!modelFor(this))
+ return;
+
+ // validation
+ if (!isLocalURL(v))
+ throw Error('the url must be a valid local url');
+
+ validateTitleAndURLCombo(this, this.title, v);
+
+ // do update
+ updateURL(this, v);
+ modelFor(this).url = v;
+ },
+ show: function(window) {
+ return showSidebar(viewFor(window), this);
+ },
+ hide: function(window) {
+ return hideSidebar(viewFor(window), this);
+ },
+ dispose: function() {
+ const internals = sidebarNS(this);
+
+ off(this);
+
+ remove(sidebars, this);
+
+ // stop tracking windows
+ if (internals.tracker) {
+ internals.tracker.unload();
+ }
+
+ internals.tracker = null;
+ internals.windowNS = null;
+
+ views.delete(this);
+ models.delete(this);
+ }
+});
+exports.Sidebar = Sidebar;
+
+function validateTitleAndURLCombo(sidebar, title, url) {
+ url = resolveURL(url);
+
+ if (sidebar.title == title && sidebar.url == url) {
+ return false;
+ }
+
+ for (let window of windows(null, { includePrivate: true })) {
+ let sidebar = window.document.querySelector('menuitem[sidebarurl="' + url + '"][label="' + title + '"]');
+ if (sidebar) {
+ throw Error('The provided title and url combination is invalid (already used).');
+ }
+ }
+
+ return false;
+}
+
+isShowing.define(Sidebar, isSidebarShowing.bind(null, null));
+show.define(Sidebar, showSidebar.bind(null, null));
+hide.define(Sidebar, hideSidebar.bind(null, null));
+
+identify.define(Sidebar, function(sidebar) {
+ return sidebar.id;
+});
+
+function toggleSidebar(window, sidebar) {
+ // TODO: make sure this is not private
+ window = window || getMostRecentBrowserWindow();
+ if (isSidebarShowing(window, sidebar)) {
+ return hideSidebar(window, sidebar);
+ }
+ return showSidebar(window, sidebar);
+}
+toggle.define(Sidebar, toggleSidebar.bind(null, null));
diff --git a/toolkit/jetpack/sdk/ui/sidebar/actions.js b/toolkit/jetpack/sdk/ui/sidebar/actions.js
new file mode 100644
index 000000000..4a52984c9
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/sidebar/actions.js
@@ -0,0 +1,10 @@
+/* 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';
+
+const method = require('../../../method/core');
+
+exports.show = method('show');
+exports.hide = method('hide');
+exports.toggle = method('toggle');
diff --git a/toolkit/jetpack/sdk/ui/sidebar/contract.js b/toolkit/jetpack/sdk/ui/sidebar/contract.js
new file mode 100644
index 000000000..b59c37c0b
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/sidebar/contract.js
@@ -0,0 +1,27 @@
+/* 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';
+
+const { contract } = require('../../util/contract');
+const { isValidURI, URL, isLocalURL } = require('../../url');
+const { isNil, isObject, isString } = require('../../lang/type');
+
+exports.contract = contract({
+ id: {
+ is: [ 'string', 'undefined' ],
+ ok: v => /^[a-z0-9-_]+$/i.test(v),
+ msg: 'The option "id" must be a valid alphanumeric id (hyphens and ' +
+ 'underscores are allowed).'
+ },
+ title: {
+ is: [ 'string' ],
+ ok: v => v.length
+ },
+ url: {
+ is: [ 'string' ],
+ ok: v => isLocalURL(v),
+ map: v => v.toString(),
+ msg: 'The option "url" must be a valid local URI.'
+ }
+});
diff --git a/toolkit/jetpack/sdk/ui/sidebar/namespace.js b/toolkit/jetpack/sdk/ui/sidebar/namespace.js
new file mode 100644
index 000000000..d79725d1a
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/sidebar/namespace.js
@@ -0,0 +1,15 @@
+/* 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';
+
+const models = exports.models = new WeakMap();
+const views = exports.views = new WeakMap();
+exports.buttons = new WeakMap();
+
+exports.viewsFor = function viewsFor(sidebar) {
+ return views.get(sidebar);
+};
+exports.modelFor = function modelFor(sidebar) {
+ return models.get(sidebar);
+};
diff --git a/toolkit/jetpack/sdk/ui/sidebar/utils.js b/toolkit/jetpack/sdk/ui/sidebar/utils.js
new file mode 100644
index 000000000..d6145c32e
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/sidebar/utils.js
@@ -0,0 +1,8 @@
+/* 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';
+
+const method = require('../../../method/core');
+
+exports.isShowing = method('isShowing');
diff --git a/toolkit/jetpack/sdk/ui/sidebar/view.js b/toolkit/jetpack/sdk/ui/sidebar/view.js
new file mode 100644
index 000000000..c91e69d3d
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/sidebar/view.js
@@ -0,0 +1,214 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'unstable',
+ 'engines': {
+ 'Firefox': '*'
+ }
+};
+
+const { models, buttons, views, viewsFor, modelFor } = require('./namespace');
+const { isBrowser, getMostRecentBrowserWindow, windows, isWindowPrivate } = require('../../window/utils');
+const { setStateFor } = require('../state');
+const { defer } = require('../../core/promise');
+const { isPrivateBrowsingSupported, data } = require('../../self');
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const WEB_PANEL_BROWSER_ID = 'web-panels-browser';
+
+const resolveURL = (url) => url ? data.url(url) : url;
+
+function create(window, details) {
+ let id = makeID(details.id);
+ let { document } = window;
+
+ if (document.getElementById(id))
+ throw new Error('The ID "' + details.id + '" seems already used.');
+
+ let menuitem = document.createElementNS(XUL_NS, 'menuitem');
+ menuitem.setAttribute('id', id);
+ menuitem.setAttribute('label', details.title);
+ menuitem.setAttribute('sidebarurl', resolveURL(details.sidebarurl));
+ menuitem.setAttribute('checked', 'false');
+ menuitem.setAttribute('type', 'checkbox');
+ menuitem.setAttribute('group', 'sidebar');
+ menuitem.setAttribute('autoCheck', 'false');
+
+ document.getElementById('viewSidebarMenu').appendChild(menuitem);
+
+ return menuitem;
+}
+exports.create = create;
+
+function dispose(menuitem) {
+ menuitem.parentNode.removeChild(menuitem);
+}
+exports.dispose = dispose;
+
+function updateTitle(sidebar, title) {
+ let button = buttons.get(sidebar);
+
+ for (let window of windows(null, { includePrivate: true })) {
+ let { document } = window;
+
+ // update the button
+ if (button) {
+ setStateFor(button, window, { label: title });
+ }
+
+ // update the menuitem
+ let mi = document.getElementById(makeID(sidebar.id));
+ if (mi) {
+ mi.setAttribute('label', title)
+ }
+
+ // update sidebar, if showing
+ if (isSidebarShowing(window, sidebar)) {
+ document.getElementById('sidebar-title').setAttribute('value', title);
+ }
+ }
+}
+exports.updateTitle = updateTitle;
+
+function updateURL(sidebar, url) {
+ let eleID = makeID(sidebar.id);
+
+ url = resolveURL(url);
+
+ for (let window of windows(null, { includePrivate: true })) {
+ // update the menuitem
+ let mi = window.document.getElementById(eleID);
+ if (mi) {
+ mi.setAttribute('sidebarurl', url)
+ }
+
+ // update sidebar, if showing
+ if (isSidebarShowing(window, sidebar)) {
+ showSidebar(window, sidebar, url);
+ }
+ }
+}
+exports.updateURL = updateURL;
+
+function isSidebarShowing(window, sidebar) {
+ let win = window || getMostRecentBrowserWindow();
+
+ // make sure there is a window
+ if (!win) {
+ return false;
+ }
+
+ // make sure there is a sidebar for the window
+ let sb = win.document.getElementById('sidebar');
+ let sidebarTitle = win.document.getElementById('sidebar-title');
+ if (!(sb && sidebarTitle)) {
+ return false;
+ }
+
+ // checks if the sidebar box is hidden
+ let sbb = win.document.getElementById('sidebar-box');
+ if (!sbb || sbb.hidden) {
+ return false;
+ }
+
+ if (sidebarTitle.value == modelFor(sidebar).title) {
+ let url = resolveURL(modelFor(sidebar).url);
+
+ // checks if the sidebar is loading
+ if (win.gWebPanelURI == url) {
+ return true;
+ }
+
+ // checks if the sidebar loaded already
+ let ele = sb.contentDocument && sb.contentDocument.getElementById(WEB_PANEL_BROWSER_ID);
+ if (!ele) {
+ return false;
+ }
+
+ if (ele.getAttribute('cachedurl') == url) {
+ return true;
+ }
+
+ if (ele && ele.contentWindow && ele.contentWindow.location == url) {
+ return true;
+ }
+ }
+
+ // default
+ return false;
+}
+exports.isSidebarShowing = isSidebarShowing;
+
+function showSidebar(window, sidebar, newURL) {
+ window = window || getMostRecentBrowserWindow();
+
+ let { promise, resolve, reject } = defer();
+ let model = modelFor(sidebar);
+
+ if (!newURL && isSidebarShowing(window, sidebar)) {
+ resolve({});
+ }
+ else if (!isPrivateBrowsingSupported && isWindowPrivate(window)) {
+ reject(Error('You cannot show a sidebar on private windows'));
+ }
+ else {
+ sidebar.once('show', resolve);
+
+ let menuitem = window.document.getElementById(makeID(model.id));
+ menuitem.setAttribute('checked', true);
+
+ window.openWebPanel(model.title, resolveURL(newURL || model.url));
+ }
+
+ return promise;
+}
+exports.showSidebar = showSidebar;
+
+
+function hideSidebar(window, sidebar) {
+ window = window || getMostRecentBrowserWindow();
+
+ let { promise, resolve, reject } = defer();
+
+ if (!isSidebarShowing(window, sidebar)) {
+ reject(Error('The sidebar is already hidden'));
+ }
+ else {
+ sidebar.once('hide', resolve);
+
+ // Below was taken from http://mxr.mozilla.org/mozilla-central/source/browser/base/content/browser.js#4775
+ // the code for window.todggleSideBar()..
+ let { document } = window;
+ let sidebarEle = document.getElementById('sidebar');
+ let sidebarTitle = document.getElementById('sidebar-title');
+ let sidebarBox = document.getElementById('sidebar-box');
+ let sidebarSplitter = document.getElementById('sidebar-splitter');
+ let commandID = sidebarBox.getAttribute('sidebarcommand');
+ let sidebarBroadcaster = document.getElementById(commandID);
+
+ sidebarBox.hidden = true;
+ sidebarSplitter.hidden = true;
+
+ sidebarEle.setAttribute('src', 'about:blank');
+ //sidebarEle.docShell.createAboutBlankContentViewer(null);
+
+ sidebarBroadcaster.removeAttribute('checked');
+ sidebarBox.setAttribute('sidebarcommand', '');
+ sidebarTitle.value = '';
+ sidebarBox.hidden = true;
+ sidebarSplitter.hidden = true;
+
+ // TODO: perhaps this isn't necessary if the window is not most recent?
+ window.gBrowser.selectedBrowser.focus();
+ }
+
+ return promise;
+}
+exports.hideSidebar = hideSidebar;
+
+function makeID(id) {
+ return 'jetpack-sidebar-' + id;
+}
diff --git a/toolkit/jetpack/sdk/ui/state.js b/toolkit/jetpack/sdk/ui/state.js
new file mode 100644
index 000000000..152ce696d
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/state.js
@@ -0,0 +1,239 @@
+/* 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';
+
+// The Button module currently supports only Firefox.
+// See: https://bugzilla.mozilla.org/show_bug.cgi?id=jetpack-panel-apps
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '*',
+ 'SeaMonkey': '*',
+ 'Thunderbird': '*'
+ }
+};
+
+const { Ci } = require('chrome');
+
+const events = require('../event/utils');
+const { events: browserEvents } = require('../browser/events');
+const { events: tabEvents } = require('../tab/events');
+const { events: stateEvents } = require('./state/events');
+
+const { windows, isInteractive, getFocusedBrowser } = require('../window/utils');
+const { getActiveTab, getOwnerWindow } = require('../tabs/utils');
+
+const { ignoreWindow } = require('../private-browsing/utils');
+
+const { freeze } = Object;
+const { merge } = require('../util/object');
+const { on, off, emit } = require('../event/core');
+
+const { add, remove, has, clear, iterator } = require('../lang/weak-set');
+const { isNil } = require('../lang/type');
+
+const { viewFor } = require('../view/core');
+
+const components = new WeakMap();
+
+const ERR_UNREGISTERED = 'The state cannot be set or get. ' +
+ 'The object may be not be registered, or may already have been unloaded.';
+
+const ERR_INVALID_TARGET = 'The state cannot be set or get for this target.' +
+ 'Only window, tab and registered component are valid targets.';
+
+const isWindow = thing => thing instanceof Ci.nsIDOMWindow;
+const isTab = thing => thing.tagName && thing.tagName.toLowerCase() === 'tab';
+const isActiveTab = thing => isTab(thing) && thing === getActiveTab(getOwnerWindow(thing));
+const isEnumerable = window => !ignoreWindow(window);
+const browsers = _ =>
+ windows('navigator:browser', { includePrivate: true }).filter(isInteractive);
+const getMostRecentTab = _ => getActiveTab(getFocusedBrowser());
+
+function getStateFor(component, target) {
+ if (!isRegistered(component))
+ throw new Error(ERR_UNREGISTERED);
+
+ if (!components.has(component))
+ return null;
+
+ let states = components.get(component);
+
+ if (target) {
+ if (isTab(target) || isWindow(target) || target === component)
+ return states.get(target) || null;
+ else
+ throw new Error(ERR_INVALID_TARGET);
+ }
+
+ return null;
+}
+exports.getStateFor = getStateFor;
+
+function getDerivedStateFor(component, target) {
+ if (!isRegistered(component))
+ throw new Error(ERR_UNREGISTERED);
+
+ if (!components.has(component))
+ return null;
+
+ let states = components.get(component);
+
+ let componentState = states.get(component);
+ let windowState = null;
+ let tabState = null;
+
+ if (target) {
+ // has a target
+ if (isTab(target)) {
+ windowState = states.get(getOwnerWindow(target), null);
+
+ if (states.has(target)) {
+ // we have a tab state
+ tabState = states.get(target);
+ }
+ }
+ else if (isWindow(target) && states.has(target)) {
+ // we have a window state
+ windowState = states.get(target);
+ }
+ }
+
+ return freeze(merge({}, componentState, windowState, tabState));
+}
+exports.getDerivedStateFor = getDerivedStateFor;
+
+function setStateFor(component, target, state) {
+ if (!isRegistered(component))
+ throw new Error(ERR_UNREGISTERED);
+
+ let isComponentState = target === component;
+ let targetWindows = isWindow(target) ? [target] :
+ isActiveTab(target) ? [getOwnerWindow(target)] :
+ isComponentState ? browsers() :
+ isTab(target) ? [] :
+ null;
+
+ if (!targetWindows)
+ throw new Error(ERR_INVALID_TARGET);
+
+ // initialize the state's map
+ if (!components.has(component))
+ components.set(component, new WeakMap());
+
+ let states = components.get(component);
+
+ if (state === null && !isComponentState) // component state can't be deleted
+ states.delete(target);
+ else {
+ let base = isComponentState ? states.get(target) : null;
+ states.set(target, freeze(merge({}, base, state)));
+ }
+
+ render(component, targetWindows);
+}
+exports.setStateFor = setStateFor;
+
+function render(component, targetWindows) {
+ targetWindows = targetWindows ? [].concat(targetWindows) : browsers();
+
+ for (let window of targetWindows.filter(isEnumerable)) {
+ let tabState = getDerivedStateFor(component, getActiveTab(window));
+
+ emit(stateEvents, 'data', {
+ type: 'render',
+ target: component,
+ window: window,
+ state: tabState
+ });
+
+ }
+}
+exports.render = render;
+
+function properties(contract) {
+ let { rules } = contract;
+ let descriptor = Object.keys(rules).reduce(function(descriptor, name) {
+ descriptor[name] = {
+ get: function() { return getDerivedStateFor(this)[name] },
+ set: function(value) {
+ let changed = {};
+ changed[name] = value;
+
+ setStateFor(this, this, contract(changed));
+ }
+ }
+ return descriptor;
+ }, {});
+
+ return Object.create(Object.prototype, descriptor);
+}
+exports.properties = properties;
+
+function state(contract) {
+ return {
+ state: function state(target, state) {
+ let nativeTarget = target === 'window' ? getFocusedBrowser()
+ : target === 'tab' ? getMostRecentTab()
+ : target === this ? null
+ : viewFor(target);
+
+ if (!nativeTarget && target !== this && !isNil(target))
+ throw new Error(ERR_INVALID_TARGET);
+
+ target = nativeTarget || target;
+
+ // jquery style
+ return arguments.length < 2
+ ? getDerivedStateFor(this, target)
+ : setStateFor(this, target, contract(state))
+ }
+ }
+}
+exports.state = state;
+
+const register = (component, state) => {
+ add(components, component);
+ setStateFor(component, component, state);
+}
+exports.register = register;
+
+const unregister = component => {
+ remove(components, component);
+}
+exports.unregister = unregister;
+
+const isRegistered = component => has(components, component);
+exports.isRegistered = isRegistered;
+
+var tabSelect = events.filter(tabEvents, e => e.type === 'TabSelect');
+var tabClose = events.filter(tabEvents, e => e.type === 'TabClose');
+var windowOpen = events.filter(browserEvents, e => e.type === 'load');
+var windowClose = events.filter(browserEvents, e => e.type === 'close');
+
+var close = events.merge([tabClose, windowClose]);
+var activate = events.merge([windowOpen, tabSelect]);
+
+on(activate, 'data', ({target}) => {
+ let [window, tab] = isWindow(target)
+ ? [target, getActiveTab(target)]
+ : [getOwnerWindow(target), target];
+
+ if (ignoreWindow(window)) return;
+
+ for (let component of iterator(components)) {
+ emit(stateEvents, 'data', {
+ type: 'render',
+ target: component,
+ window: window,
+ state: getDerivedStateFor(component, tab)
+ });
+ }
+});
+
+on(close, 'data', function({target}) {
+ for (let component of iterator(components)) {
+ components.get(component).delete(target);
+ }
+});
diff --git a/toolkit/jetpack/sdk/ui/state/events.js b/toolkit/jetpack/sdk/ui/state/events.js
new file mode 100644
index 000000000..98909656a
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/state/events.js
@@ -0,0 +1,18 @@
+/* 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';
+
+module.metadata = {
+ 'stability': 'experimental',
+ 'engines': {
+ 'Firefox': '*',
+ 'SeaMonkey': '*',
+ 'Thunderbird': '*'
+ }
+};
+
+var channel = {};
+
+exports.events = channel;
diff --git a/toolkit/jetpack/sdk/ui/toolbar.js b/toolkit/jetpack/sdk/ui/toolbar.js
new file mode 100644
index 000000000..c1becab2d
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/toolbar.js
@@ -0,0 +1,16 @@
+/* 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";
+
+module.metadata = {
+ "stability": "experimental",
+ "engines": {
+ "Firefox": "> 28"
+ }
+};
+
+const { Toolbar } = require("./toolbar/model");
+require("./toolbar/view");
+
+exports.Toolbar = Toolbar;
diff --git a/toolkit/jetpack/sdk/ui/toolbar/model.js b/toolkit/jetpack/sdk/ui/toolbar/model.js
new file mode 100644
index 000000000..5c5428606
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/toolbar/model.js
@@ -0,0 +1,151 @@
+/* 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";
+
+module.metadata = {
+ "stability": "experimental",
+ "engines": {
+ "Firefox": "> 28"
+ }
+};
+
+const { Class } = require("../../core/heritage");
+const { EventTarget } = require("../../event/target");
+const { off, setListeners, emit } = require("../../event/core");
+const { Reactor, foldp, merges, send } = require("../../event/utils");
+const { Disposable } = require("../../core/disposable");
+const { InputPort } = require("../../input/system");
+const { OutputPort } = require("../../output/system");
+const { identify } = require("../id");
+const { pairs, object, map, each } = require("../../util/sequence");
+const { patch, diff } = require("diffpatcher/index");
+const { contract } = require("../../util/contract");
+const { id: addonID } = require("../../self");
+
+// Input state is accumulated from the input received form the toolbar
+// view code & local output. Merging local output reflects local state
+// changes without complete roundloop.
+const input = foldp(patch, {}, new InputPort({ id: "toolbar-changed" }));
+const output = new OutputPort({ id: "toolbar-change" });
+
+// Takes toolbar title and normalizes is to an
+// identifier, also prefixes with add-on id.
+const titleToId = title =>
+ ("toolbar-" + addonID + "-" + title).
+ toLowerCase().
+ replace(/\s/g, "-").
+ replace(/[^A-Za-z0-9_\-]/g, "");
+
+const validate = contract({
+ title: {
+ is: ["string"],
+ ok: x => x.length > 0,
+ msg: "The `option.title` string must be provided"
+ },
+ items: {
+ is:["undefined", "object", "array"],
+ msg: "The `options.items` must be iterable sequence of items"
+ },
+ hidden: {
+ is: ["boolean", "undefined"],
+ msg: "The `options.hidden` must be boolean"
+ }
+});
+
+// Toolbars is a mapping between `toolbar.id` & `toolbar` instances,
+// which is used to find intstance for dispatching events.
+var toolbars = new Map();
+
+const Toolbar = Class({
+ extends: EventTarget,
+ implements: [Disposable],
+ initialize: function(params={}) {
+ const options = validate(params);
+ const id = titleToId(options.title);
+
+ if (toolbars.has(id))
+ throw Error("Toolbar with this id already exists: " + id);
+
+ // Set of the items in the toolbar isn't mutable, as a matter of fact
+ // it just defines desired set of items, actual set is under users
+ // control. Conver test to an array and freeze to make sure users won't
+ // try mess with it.
+ const items = Object.freeze(options.items ? [...options.items] : []);
+
+ const initial = {
+ id: id,
+ title: options.title,
+ // By default toolbars are visible when add-on is installed, unless
+ // add-on authors decides it should be hidden. From that point on
+ // user is in control.
+ collapsed: !!options.hidden,
+ // In terms of state only identifiers of items matter.
+ items: items.map(identify)
+ };
+
+ this.id = id;
+ this.items = items;
+
+ toolbars.set(id, this);
+ setListeners(this, params);
+
+ // Send initial state to the host so it can reflect it
+ // into a user interface.
+ send(output, object([id, initial]));
+ },
+
+ get title() {
+ const state = reactor.value[this.id];
+ return state && state.title;
+ },
+ get hidden() {
+ const state = reactor.value[this.id];
+ return state && state.collapsed;
+ },
+
+ destroy: function() {
+ send(output, object([this.id, null]));
+ },
+ // `JSON.stringify` serializes objects based of the return
+ // value of this method. For convinienc we provide this method
+ // to serialize actual state data. Note: items will also be
+ // serialized so they should probably implement `toJSON`.
+ toJSON: function() {
+ return {
+ id: this.id,
+ title: this.title,
+ hidden: this.hidden,
+ items: this.items
+ };
+ }
+});
+exports.Toolbar = Toolbar;
+identify.define(Toolbar, toolbar => toolbar.id);
+
+const dispose = toolbar => {
+ toolbars.delete(toolbar.id);
+ emit(toolbar, "detach");
+ off(toolbar);
+};
+
+const reactor = new Reactor({
+ onStep: (present, past) => {
+ const delta = diff(past, present);
+
+ each(([id, update]) => {
+ const toolbar = toolbars.get(id);
+
+ // Remove
+ if (!update)
+ dispose(toolbar);
+ // Add
+ else if (!past[id])
+ emit(toolbar, "attach");
+ // Update
+ else
+ emit(toolbar, update.collapsed ? "hide" : "show", toolbar);
+ }, pairs(delta));
+ }
+});
+reactor.run(input);
diff --git a/toolkit/jetpack/sdk/ui/toolbar/view.js b/toolkit/jetpack/sdk/ui/toolbar/view.js
new file mode 100644
index 000000000..4ef0c3d46
--- /dev/null
+++ b/toolkit/jetpack/sdk/ui/toolbar/view.js
@@ -0,0 +1,248 @@
+/* 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";
+
+module.metadata = {
+ "stability": "experimental",
+ "engines": {
+ "Firefox": "> 28"
+ }
+};
+
+const { Cu } = require("chrome");
+const { CustomizableUI } = Cu.import('resource:///modules/CustomizableUI.jsm', {});
+const { subscribe, send, Reactor, foldp, lift, merges } = require("../../event/utils");
+const { InputPort } = require("../../input/system");
+const { OutputPort } = require("../../output/system");
+const { Interactive } = require("../../input/browser");
+const { CustomizationInput } = require("../../input/customizable-ui");
+const { pairs, map, isEmpty, object,
+ each, keys, values } = require("../../util/sequence");
+const { curry, flip } = require("../../lang/functional");
+const { patch, diff } = require("diffpatcher/index");
+const prefs = require("../../preferences/service");
+const { getByOuterId } = require("../../window/utils");
+const { ignoreWindow } = require('../../private-browsing/utils');
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const PREF_ROOT = "extensions.sdk-toolbar-collapsed.";
+
+
+// There are two output ports one for publishing changes that occured
+// and the other for change requests. Later is synchronous and is only
+// consumed here. Note: it needs to be synchronous to avoid race conditions
+// when `collapsed` attribute changes are caused by user interaction and
+// toolbar is destroyed between the ticks.
+const output = new OutputPort({ id: "toolbar-changed" });
+const syncoutput = new OutputPort({ id: "toolbar-change", sync: true });
+
+// Merge disptached changes and recevied changes from models to keep state up to
+// date.
+const Toolbars = foldp(patch, {}, merges([new InputPort({ id: "toolbar-changed" }),
+ new InputPort({ id: "toolbar-change" })]));
+const State = lift((toolbars, windows, customizable) =>
+ ({windows: windows, toolbars: toolbars, customizable: customizable}),
+ Toolbars, Interactive, new CustomizationInput());
+
+// Shared event handler that makes `event.target.parent` collapsed.
+// Used as toolbar's close buttons click handler.
+const collapseToolbar = event => {
+ const toolbar = event.target.parentNode;
+ toolbar.collapsed = true;
+};
+
+const parseAttribute = x =>
+ x === "true" ? true :
+ x === "false" ? false :
+ x === "" ? null :
+ x;
+
+// Shared mutation observer that is used to observe `toolbar` node's
+// attribute mutations. Mutations are aggregated in the `delta` hash
+// and send to `ToolbarStateChanged` channel to let model know state
+// has changed.
+const attributesChanged = mutations => {
+ const delta = mutations.reduce((changes, {attributeName, target}) => {
+ const id = target.id;
+ const field = attributeName === "toolbarname" ? "title" : attributeName;
+ let change = changes[id] || (changes[id] = {});
+ change[field] = parseAttribute(target.getAttribute(attributeName));
+ return changes;
+ }, {});
+
+ // Calculate what are the updates from the current state and if there are
+ // any send them.
+ const updates = diff(reactor.value, patch(reactor.value, delta));
+
+ if (!isEmpty(pairs(updates))) {
+ // TODO: Consider sending sync to make sure that there won't be a new
+ // update doing a delete in the meantime.
+ send(syncoutput, updates);
+ }
+};
+
+
+// Utility function creates `toolbar` with a "close" button and returns
+// it back. In addition it set's up a listener and observer to communicate
+// state changes.
+const addView = curry((options, {document, window}) => {
+ if (ignoreWindow(window))
+ return;
+
+ let view = document.createElementNS(XUL_NS, "toolbar");
+ view.setAttribute("id", options.id);
+ view.setAttribute("collapsed", options.collapsed);
+ view.setAttribute("toolbarname", options.title);
+ view.setAttribute("pack", "end");
+ view.setAttribute("customizable", "false");
+ view.setAttribute("style", "padding: 2px 0; max-height: 40px;");
+ view.setAttribute("mode", "icons");
+ view.setAttribute("iconsize", "small");
+ view.setAttribute("context", "toolbar-context-menu");
+ view.setAttribute("class", "chromeclass-toolbar");
+
+ let label = document.createElementNS(XUL_NS, "label");
+ label.setAttribute("value", options.title);
+ label.setAttribute("collapsed", "true");
+ view.appendChild(label);
+
+ let closeButton = document.createElementNS(XUL_NS, "toolbarbutton");
+ closeButton.setAttribute("id", "close-" + options.id);
+ closeButton.setAttribute("class", "close-icon");
+ closeButton.setAttribute("customizable", false);
+ closeButton.addEventListener("command", collapseToolbar);
+
+ view.appendChild(closeButton);
+
+ // In order to have a close button not costumizable, aligned on the right,
+ // leaving the customizable capabilities of Australis, we need to create
+ // a toolbar inside a toolbar.
+ // This is should be a temporary hack, we should have a proper XBL for toolbar
+ // instead. See:
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=982005
+ let toolbar = document.createElementNS(XUL_NS, "toolbar");
+ toolbar.setAttribute("id", "inner-" + options.id);
+ toolbar.setAttribute("defaultset", options.items.join(","));
+ toolbar.setAttribute("customizable", "true");
+ toolbar.setAttribute("style", "-moz-appearance: none; overflow: hidden");
+ toolbar.setAttribute("mode", "icons");
+ toolbar.setAttribute("iconsize", "small");
+ toolbar.setAttribute("context", "toolbar-context-menu");
+ toolbar.setAttribute("flex", "1");
+
+ view.insertBefore(toolbar, closeButton);
+
+ const observer = new document.defaultView.MutationObserver(attributesChanged);
+ observer.observe(view, { attributes: true,
+ attributeFilter: ["collapsed", "toolbarname"] });
+
+ const toolbox = document.getElementById("navigator-toolbox");
+ toolbox.appendChild(view);
+});
+const viewAdd = curry(flip(addView));
+
+const removeView = curry((id, {document}) => {
+ const view = document.getElementById(id);
+ if (view) view.remove();
+});
+
+const updateView = curry((id, {title, collapsed, isCustomizing}, {document}) => {
+ const view = document.getElementById(id);
+
+ if (!view)
+ return;
+
+ if (title)
+ view.setAttribute("toolbarname", title);
+
+ if (collapsed !== void(0))
+ view.setAttribute("collapsed", Boolean(collapsed));
+
+ if (isCustomizing !== void(0)) {
+ view.querySelector("label").collapsed = !isCustomizing;
+ view.querySelector("toolbar").style.visibility = isCustomizing
+ ? "hidden" : "visible";
+ }
+});
+
+const viewUpdate = curry(flip(updateView));
+
+// Utility function used to register toolbar into CustomizableUI.
+const registerToolbar = state => {
+ // If it's first additon register toolbar as customizableUI component.
+ CustomizableUI.registerArea("inner-" + state.id, {
+ type: CustomizableUI.TYPE_TOOLBAR,
+ legacy: true,
+ defaultPlacements: [...state.items]
+ });
+};
+// Utility function used to unregister toolbar from the CustomizableUI.
+const unregisterToolbar = CustomizableUI.unregisterArea;
+
+const reactor = new Reactor({
+ onStep: (present, past) => {
+ const delta = diff(past, present);
+
+ each(([id, update]) => {
+ // If update is `null` toolbar is removed, in such case
+ // we unregister toolbar and remove it from each window
+ // it was added to.
+ if (update === null) {
+ unregisterToolbar("inner-" + id);
+ each(removeView(id), values(past.windows));
+
+ send(output, object([id, null]));
+ }
+ else if (past.toolbars[id]) {
+ // If `collapsed` state for toolbar was updated, persist
+ // it for a future sessions.
+ if (update.collapsed !== void(0))
+ prefs.set(PREF_ROOT + id, update.collapsed);
+
+ // Reflect update in each window it was added to.
+ each(updateView(id, update), values(past.windows));
+
+ send(output, object([id, update]));
+ }
+ // Hack: Mutation observers are invoked async, which means that if
+ // client does `hide(toolbar)` & then `toolbar.destroy()` by the
+ // time we'll get update for `collapsed` toolbar will be removed.
+ // For now we check if `update.id` is present which will be undefined
+ // in such cases.
+ else if (update.id) {
+ // If it is a new toolbar we create initial state by overriding
+ // `collapsed` filed with value persisted in previous sessions.
+ const state = patch(update, {
+ collapsed: prefs.get(PREF_ROOT + id, update.collapsed),
+ });
+
+ // Register toolbar and add it each window known in the past
+ // (note that new windows if any will be handled in loop below).
+ registerToolbar(state);
+ each(addView(state), values(past.windows));
+
+ send(output, object([state.id, state]));
+ }
+ }, pairs(delta.toolbars));
+
+ // Add views to every window that was added.
+ each(window => {
+ if (window)
+ each(viewAdd(window), values(past.toolbars));
+ }, values(delta.windows));
+
+ each(([id, isCustomizing]) => {
+ each(viewUpdate(getByOuterId(id), {isCustomizing: !!isCustomizing}),
+ keys(present.toolbars));
+
+ }, pairs(delta.customizable))
+ },
+ onEnd: state => {
+ each(id => {
+ unregisterToolbar("inner-" + id);
+ each(removeView(id), values(state.windows));
+ }, keys(state.toolbars));
+ }
+});
+reactor.run(State);