Commit 4937ef32 authored by Zachary Carter's avatar Zachary Carter
Browse files

Bug 1210586 - Create a Synced tabs sidebar r=markh

parent 9ec51cfd
......@@ -17,6 +17,7 @@ const BUILTIN_SIDEBAR_MENUITEMS = exports.BUILTIN_SIDEBAR_MENUITEMS = [
'menu_socialSidebar',
'menu_historySidebar',
'menu_bookmarksSidebar',
'menu_tabsSidebar',
];
function isSidebarShowing(window) {
......
......@@ -242,7 +242,9 @@
key="key_gotoHistory"
observes="viewHistorySidebar"
label="&historyButton.label;"/>
<menuitem id="menu_tabsSidebar"
observes="viewTabsSidebar"
label="&syncedTabs.sidebar.label;"/>
<!-- Service providers with sidebars are inserted between these two menuseperators -->
<menuseparator hidden="true"/>
<menuseparator class="social-provider-menu" hidden="true"/>
......
......@@ -188,6 +188,10 @@
<broadcaster id="sync-setup-state"/>
<broadcaster id="sync-syncnow-state" hidden="true"/>
<broadcaster id="sync-reauth-state" hidden="true"/>
<broadcaster id="viewTabsSidebar" autoCheck="false" sidebartitle="&syncedTabs.sidebar.label;"
type="checkbox" group="sidebar"
sidebarurl="chrome://browser/content/syncedtabs/sidebar.xhtml"
oncommand="SidebarUI.toggle('viewTabsSidebar');"/>
<broadcaster id="workOfflineMenuitemState"/>
<broadcaster id="socialSidebarBroadcaster" hidden="true"/>
......
......@@ -191,7 +191,7 @@ var SidebarUI = {
return new Promise((resolve, reject) => {
let sidebarBroadcaster = document.getElementById(commandID);
if (!sidebarBroadcaster || sidebarBroadcaster.localName != "broadcaster") {
reject(new Error("Invalid sidebar broadcaster specified"));
reject(new Error("Invalid sidebar broadcaster specified: " + commandID));
return;
}
......
......@@ -474,6 +474,19 @@
acceskey="&emeNotificationsDontAskAgain.accesskey;"
oncommand="gEMEHandler.onDontAskAgain(this);"/>
</menupopup>
<menupopup id="SyncedTabsSidebarContext">
<menuitem label="&syncedTabs.context.openTab.label;"
accesskey="&syncedTabs.context.openTab.accesskey;"
id="syncedTabsOpenSelected"/>
<menuitem label="&syncedTabs.context.bookmarkSingleTab.label;"
accesskey="&syncedTabs.context.bookmarkSingleTab.accesskey;"
id="syncedTabsBookmarkSelected"/>
<menuseparator/>
<menuitem label="&syncedTabs.context.refreshList.label;"
accesskey="&syncedTabs.context.refreshList.accesskey;"
id="syncedTabsRefresh"/>
</menupopup>
</popupset>
#ifdef CAN_DRAW_IN_TITLEBAR
......
......@@ -112,6 +112,10 @@
<!-- this widget has 3 boxes in the body, but only 1 is ever visible -->
<!-- When Sync is ready to sync -->
<vbox id="PanelUI-remotetabs-main" observes="sync-syncnow-state">
<toolbarbutton id="PanelUI-remotetabs-view-sidebar"
class="subviewbutton"
observes="viewTabsSidebar"
label="&appMenuRemoteTabs.sidebar.label;"/>
<toolbarbutton id="PanelUI-remotetabs-syncnow"
observes="sync-status"
class="subviewbutton"
......
......@@ -21,6 +21,7 @@ DIRS += [
'sessionstore',
'shell',
'selfsupport',
'syncedtabs',
'uitour',
'translation',
]
......
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
this.EXPORTED_SYMBOLS = [
"EventEmitter"
];
// Simple event emitter abstraction for storage objects to use.
function EventEmitter () {
this._events = new Map();
}
EventEmitter.prototype = {
on(event, listener) {
if (this._events.has(event)) {
this._events.get(event).add(listener);
} else {
this._events.set(event, new Set([listener]));
}
},
off(event, listener) {
if (!this._events.has(event)) {
return;
}
this._events.get(event).delete(listener);
},
emit(event, ...args) {
if (!this._events.has(event)) {
return;
}
for (let listener of this._events.get(event).values()) {
try {
listener.apply(this, args);
} catch (e) {
Cu.reportError(e);
}
}
},
};
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckStore.js");
Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckView.js");
Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js");
Cu.import("resource:///modules/syncedtabs/TabListComponent.js");
Cu.import("resource:///modules/syncedtabs/TabListView.js");
let { getChromeWindow } = Cu.import("resource:///modules/syncedtabs/util.js", {});
XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () {
return Components.utils.import("resource://gre/modules/FxAccountsCommon.js", {});
});
let log = Cu.import("resource://gre/modules/Log.jsm", {})
.Log.repository.getLogger("Sync.RemoteTabs");
this.EXPORTED_SYMBOLS = [
"SyncedTabsDeckComponent"
];
/* SyncedTabsDeckComponent
* This component instantiates views and storage objects as well as defines
* behaviors that will be passed down to the views. This helps keep the views
* isolated and easier to test.
*/
function SyncedTabsDeckComponent({
window, SyncedTabs, fxAccounts, deckStore, listStore, listComponent, DeckView, getChromeWindowMock,
}) {
this._window = window;
this._SyncedTabs = SyncedTabs;
this._fxAccounts = fxAccounts;
this._DeckView = DeckView || SyncedTabsDeckView;
// used to stub during tests
this._getChromeWindow = getChromeWindowMock || getChromeWindow;
this._deckStore = deckStore || new SyncedTabsDeckStore();
this._syncedTabsListStore = listStore || new SyncedTabsListStore(SyncedTabs);
this.tabListComponent = listComponent || new TabListComponent({
window: this._window,
store: this._syncedTabsListStore,
View: TabListView,
SyncedTabs: SyncedTabs
});
};
SyncedTabsDeckComponent.prototype = {
PANELS: {
TABS_CONTAINER: "tabs-container",
TABS_FETCHING: "tabs-fetching",
NOT_AUTHED_INFO: "notAuthedInfo",
SINGLE_DEVICE_INFO: "singleDeviceInfo",
TABS_DISABLED: "tabs-disabled",
},
get container() {
return this._deckView ? this._deckView.container : null;
},
init() {
Services.obs.addObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED, false);
Services.obs.addObserver(this, FxAccountsCommon.ONLOGIN_NOTIFICATION, false);
// Go ahead and trigger sync
this._SyncedTabs.syncTabs()
.catch(Cu.reportError);
this._deckView = new this._DeckView(this._window, this.tabListComponent, {
onAndroidClick: event => this.openAndroidLink(event),
oniOSClick: event => this.openiOSLink(event),
onSyncPrefClick: event => this.openSyncPrefs(event)
});
this._deckStore.on("change", state => this._deckView.render(state));
// Trigger the initial rendering of the deck view
this._deckStore.setPanels(Object.values(this.PANELS));
// Set the initial panel to display
this.updatePanel();
},
uninit() {
Services.obs.removeObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED);
Services.obs.removeObserver(this, FxAccountsCommon.ONLOGIN_NOTIFICATION);
this._deckView.destroy();
},
observe(subject, topic, data) {
switch (topic) {
case this._SyncedTabs.TOPIC_TABS_CHANGED:
this._syncedTabsListStore.getData();
this.updatePanel();
break;
case FxAccountsCommon.ONLOGIN_NOTIFICATION:
this.updatePanel();
break;
default:
break;
}
},
// There's no good way to mock fxAccounts in browser tests where it's already
// been instantiated, so we have this method for stubbing.
_accountStatus() {
return this._fxAccounts.accountStatus();
},
getPanelStatus() {
return this._accountStatus().then(exists => {
if (!exists) {
return this.PANELS.NOT_AUTHED_INFO;
}
if (!this._SyncedTabs.isConfiguredToSyncTabs) {
return this.PANELS.TABS_DISABLED;
}
if (!this._SyncedTabs.hasSyncedThisSession) {
return this.PANELS.TABS_FETCHING;
}
return this._SyncedTabs.getTabClients().then(clients => {
if (clients.length) {
return this.PANELS.TABS_CONTAINER;
}
return this.PANELS.SINGLE_DEVICE_INFO;
});
})
.catch(err => {
Cu.reportError(err);
return this.PANELS.NOT_AUTHED_INFO;
});
},
updatePanel() {
// return promise for tests
return this.getPanelStatus()
.then(panelId => this._deckStore.selectPanel(panelId))
.catch(Cu.reportError);
},
openAndroidLink(event) {
let href = Services.prefs.getCharPref("identity.mobilepromo.android") + "synced-tabs-sidebar";
this._openUrl(href, event);
},
openiOSLink(event) {
let href = Services.prefs.getCharPref("identity.mobilepromo.ios") + "synced-tabs-sidebar";
this._openUrl(href, event);
},
_openUrl(url, event) {
this._window.openUILink(url, event);
},
openSyncPrefs() {
this._getChromeWindow(this._window).gSyncUI.openSetup();
}
};
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
let { EventEmitter } = Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm", {});
this.EXPORTED_SYMBOLS = [
"SyncedTabsDeckStore"
];
/**
* SyncedTabsDeckStore
*
* This store keeps track of the deck view state, including the panels and which
* one is selected. The view listens for change events on the store, which are
* triggered whenever the state changes. If it's a small change, the state
* will have `isUpdatable` set to true so the view can skip rerendering the whole
* DOM.
*/
function SyncedTabsDeckStore() {
EventEmitter.call(this);
this._panels = [];
};
Object.assign(SyncedTabsDeckStore.prototype, EventEmitter.prototype, {
_change(isUpdatable = false) {
let panels = this._panels.map(panel => {
return {id: panel, selected: panel === this._selectedPanel};
});
this.emit("change", {panels, isUpdatable: isUpdatable});
},
/**
* Sets the selected panelId and triggers a change event.
* @param {String} panelId - ID of the panel to select.
*/
selectPanel(panelId) {
if (this._panels.indexOf(panelId) === -1 || this._selectedPanel === panelId) {
return;
}
this._selectedPanel = panelId;
this._change(true);
},
/**
* Update the set of panels in the deck and trigger a change event.
* @param {Array} panels - an array of IDs for each panel in the deck.
*/
setPanels(panels) {
if (panels === this._panels) {
return;
}
this._panels = panels || [];
this._change();
}
});
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
let { getChromeWindow } = Cu.import("resource:///modules/syncedtabs/util.js", {});
let log = Cu.import("resource://gre/modules/Log.jsm", {})
.Log.repository.getLogger("Sync.RemoteTabs");
this.EXPORTED_SYMBOLS = [
"SyncedTabsDeckView"
];
/**
* SyncedTabsDeckView
*
* Instances of SyncedTabsDeckView render DOM nodes from a given state.
* No state is kept internaly and the DOM will completely
* rerender unless the state flags `isUpdatable`, which helps
* make small changes without the overhead of a full rerender.
*/
const SyncedTabsDeckView = function (window, tabListComponent, props) {
this.props = props;
this._window = window;
this._doc = window.document;
this._tabListComponent = tabListComponent;
this._deckTemplate = this._doc.getElementById("deck-template");
this.container = this._doc.createElement("div");
};
SyncedTabsDeckView.prototype = {
render(state) {
if (state.isUpdatable) {
this.update(state);
} else {
this.create(state);
}
},
create(state) {
let deck = this._doc.importNode(this._deckTemplate.content, true).firstElementChild;
this._clearChilden();
let tabListWrapper = this._doc.createElement("div");
tabListWrapper.className = "tabs-container sync-state";
this._tabListComponent.init();
tabListWrapper.appendChild(this._tabListComponent.container);
deck.appendChild(tabListWrapper);
this.container.appendChild(deck);
this._generateDevicePromo();
this._attachListeners();
this.update(state);
},
_getBrowserBundle() {
return getChromeWindow(this._window).document.getElementById("bundle_browser");
},
_generateDevicePromo() {
let bundle = this._getBrowserBundle();
let formatArgs = ["android", "ios"].map(os => {
let link = this._doc.createElement("a");
link.textContent = bundle.getString(`appMenuRemoteTabs.mobilePromo.${os}`)
link.className = `${os}-link text-link`;
link.setAttribute("href", "#");
return link.outerHTML;
});
// Put it all together...
let contents = bundle.getFormattedString("appMenuRemoteTabs.mobilePromo", formatArgs);
this.container.querySelector(".device-promo").innerHTML = contents;
},
destroy() {
this._tabListComponent.uninit();
this.container.remove();
},
update(state) {
for (let panel of state.panels) {
if (panel.selected) {
this.container.getElementsByClassName(panel.id).item(0).classList.add("selected");
} else {
this.container.getElementsByClassName(panel.id).item(0).classList.remove("selected");
}
}
},
_clearChilden() {
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
},
_attachListeners() {
this.container.querySelector(".android-link").addEventListener("click", this.props.onAndroidClick);
this.container.querySelector(".ios-link").addEventListener("click", this.props.oniOSClick);
let syncPrefLinks = this.container.querySelectorAll(".sync-prefs");
for (let link of syncPrefLinks) {
link.addEventListener("click", this.props.onSyncPrefClick);
}
},
};
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
let { EventEmitter } = Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm", {});
this.EXPORTED_SYMBOLS = [
"SyncedTabsListStore"
];
/**
* SyncedTabsListStore
*
* Instances of this store encapsulate all of the state associated with a synced tabs list view.
* The state includes the clients, their tabs, the row that is currently selected,
* and the filtered query.
*/
function SyncedTabsListStore(SyncedTabs) {
EventEmitter.call(this);
this._SyncedTabs = SyncedTabs;
this.data = [];
this._closedClients = {};
this._selectedRow = [-1, -1];
this.filter = "";
this.inputFocused = false;
};
Object.assign(SyncedTabsListStore.prototype, EventEmitter.prototype, {
// This internal method triggers the "change" event that views
// listen for. It denormalizes the state so that it's easier for
// the view to deal with. updateType hints to the view what
// actually needs to be rerendered or just updated, and can be
// empty (to (re)render everything), "searchbox" (to rerender just the tab list),
// or "all" (to skip rendering and just update all attributes of existing nodes).
_change(updateType) {
let selectedParent = this._selectedRow[0];
let selectedChild = this._selectedRow[1];
let rowSelected = false;
// clone the data so that consumers can't mutate internal storage
let data = Cu.cloneInto(this.data, {});
let tabCount = 0;
data.forEach((client, index) => {
client.closed = !!this._closedClients[client.id];
if (rowSelected || selectedParent < 0) {
return;
}
if (this.filter) {
if (selectedParent < tabCount + client.tabs.length) {
client.tabs[selectedParent - tabCount].selected = true;
client.tabs[selectedParent - tabCount].focused = !this.inputFocused;
rowSelected = true;
} else {
tabCount += client.tabs.length;
}
return;
}
if (selectedParent === index && selectedChild === -1) {
client.selected = true;
client.focused = !this.inputFocused;
rowSelected = true;
} else if (selectedParent === index) {
client.tabs[selectedChild].selected = true;
client.tabs[selectedChild].focused = !this.inputFocused;
rowSelected = true;
}
});
// If this were React the view would be smart enough
// to not re-render the whole list unless necessary. But it's
// not, so updateType is a hint to the view of what actually
// needs to be rerendered.
this.emit("change", {
clients: data,
canUpdateAll: updateType === "all",
canUpdateInput: updateType === "searchbox",
filter: this.filter,
inputFocused: this.inputFocused
});
},
/**
* Moves the row selection from a child to its parent,
* which occurs when the parent of a selected row closes.
*/
_selectParentRow() {
this._selectedRow[1] = -1;
},
_toggleBranch(id, closed) {
this._closedClients[id] = closed;
if (this._closedClients[id]) {
this._selectParentRow();
}
this._change("all");
},
_isOpen(client) {
return !this._closedClients[client.id];
},
moveSelectionDown() {
let branchRow = this._selectedRow[0];
let childRow = this._selectedRow[1];
let branch = this.data[branchRow];
if (this.filter) {
this.selectRow(branchRow + 1);
return;
}
if (branchRow < 0) {
this.