Source code
Revision control
Copy as Markdown
Other Tools
/* 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
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
COMMAND_CLOSETAB: "resource://gre/modules/FxAccountsCommon.sys.mjs",
});
import { SyncedTabsErrorHandler } from "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs";
import { searchTabList } from "chrome://browser/content/firefoxview/search-helpers.mjs";
const SYNCED_TABS_CHANGED = "services.sync.tabs.changed";
const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed";
/**
* The controller for synced tabs components.
*
* @implements {ReactiveController}
*/
export class SyncedTabsController {
/**
* @type {boolean}
*/
contextMenu;
currentSetupStateIndex = -1;
currentSyncedTabs = [];
devices = [];
/**
* The current error state as determined by `SyncedTabsErrorHandler`.
*
* @type {number}
*/
errorState = null;
/**
* Component associated with this controller.
*
* @type {ReactiveControllerHost}
*/
host;
/**
* @type {Function}
*/
pairDeviceCallback;
searchQuery = "";
/**
* @type {Function}
*/
signupCallback;
/**
* Construct a new SyncedTabsController.
*
* @param {ReactiveControllerHost} host
* @param {object} options
* @param {boolean} [options.contextMenu]
* Whether synced tab items have a secondary context menu.
* @param {Function} [options.pairDeviceCallback]
* The function to call when the pair device window is opened.
* @param {Function} [options.signupCallback]
* The function to call when the signup window is opened.
*/
constructor(host, { contextMenu, pairDeviceCallback, signupCallback } = {}) {
this.contextMenu = contextMenu;
this.pairDeviceCallback = pairDeviceCallback;
this.signupCallback = signupCallback;
this.observe = this.observe.bind(this);
this.host = host;
this.host.addController(this);
// Track tabs requested close per device but not-yet-sent,
// it'll be in the form of {fxaDeviceId: Set(urls)}
this._pendingCloseTabs = new Map();
// The last closed URL, for undo purposes
this.lastClosedURL = null;
}
hostConnected() {
this.host.addEventListener("click", this);
}
hostDisconnected() {
this.host.removeEventListener("click", this);
}
get isSyncedTabsLoaded() {
return this.currentSetupStateIndex === 4;
}
addSyncObservers() {
Services.obs.addObserver(this.observe, SYNCED_TABS_CHANGED);
Services.obs.addObserver(this.observe, TOPIC_SETUPSTATE_CHANGED);
}
removeSyncObservers() {
Services.obs.removeObserver(this.observe, SYNCED_TABS_CHANGED);
Services.obs.removeObserver(this.observe, TOPIC_SETUPSTATE_CHANGED);
}
handleEvent(event) {
if (event.type == "click" && event.target.dataset.action) {
const { ErrorType } = SyncedTabsErrorHandler;
switch (event.target.dataset.action) {
case `${ErrorType.SYNC_ERROR}`:
case `${ErrorType.NETWORK_OFFLINE}`:
case `${ErrorType.PASSWORD_LOCKED}`: {
TabsSetupFlowManager.tryToClearError();
break;
}
case `${ErrorType.SIGNED_OUT}`:
case "sign-in": {
TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal);
this.signupCallback?.();
break;
}
case "add-device": {
TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal);
this.pairDeviceCallback?.();
break;
}
case "sync-tabs-disabled": {
TabsSetupFlowManager.syncOpenTabs(event.target);
break;
}
case `${ErrorType.SYNC_DISCONNECTED}`: {
const win = event.target.ownerGlobal;
const { switchToTabHavingURI } =
win.docShell.chromeEventHandler.ownerGlobal;
switchToTabHavingURI(
"about:preferences?action=choose-what-to-sync#sync",
true,
{}
);
break;
}
}
}
}
async observe(_, topic, errorState) {
if (topic == TOPIC_SETUPSTATE_CHANGED) {
await this.updateStates(errorState);
}
if (topic == SYNCED_TABS_CHANGED) {
// Usually this means we performed a sync, so clear the
// "in-queue" things as those most likely got flushed
this._pendingCloseTabs = new Map();
this.lastClosedURL = null;
await this.getSyncedTabData();
}
}
async updateStates(errorState) {
let stateIndex = TabsSetupFlowManager.uiStateIndex;
errorState = errorState || SyncedTabsErrorHandler.getErrorType();
if (stateIndex == 4 && this.currentSetupStateIndex !== stateIndex) {
// trigger an initial request for the synced tabs list
await this.getSyncedTabData();
}
this.currentSetupStateIndex = stateIndex;
this.errorState = errorState;
this.host.requestUpdate();
}
actionMappings = {
"sign-in": {
header: "firefoxview-syncedtabs-signin-header-2",
description: "firefoxview-syncedtabs-signin-description-2",
buttonLabel: "firefoxview-syncedtabs-signin-primarybutton-2",
},
"add-device": {
header: "firefoxview-syncedtabs-adddevice-header-2",
description: "firefoxview-syncedtabs-adddevice-description-2",
buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton",
descriptionLink: {
name: "url",
},
},
"sync-tabs-disabled": {
header: "firefoxview-syncedtabs-synctabs-header",
description: "firefoxview-syncedtabs-synctabs-description",
buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton",
},
loading: {
header: "firefoxview-syncedtabs-loading-header",
description: "firefoxview-syncedtabs-loading-description",
},
};
#getMessageCardForState({ error = false, action, errorState }) {
errorState = errorState || this.errorState;
let header, description, descriptionLink, buttonLabel, mainImageUrl;
let descriptionArray;
if (error) {
let link;
({ header, description, link, buttonLabel } =
SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState));
action = `${errorState}`;
mainImageUrl =
"chrome://browser/content/firefoxview/synced-tabs-error.svg";
descriptionArray = [description];
if (errorState == "password-locked") {
descriptionLink = {};
// This is ugly, but we need to special case this link so we can
// coexist with the old view.
descriptionArray.push("firefoxview-syncedtab-password-locked-link");
descriptionLink.name = "syncedtab-password-locked-link";
descriptionLink.url = link.href;
}
} else {
header = this.actionMappings[action].header;
description = this.actionMappings[action].description;
buttonLabel = this.actionMappings[action].buttonLabel;
descriptionLink = this.actionMappings[action].descriptionLink;
mainImageUrl =
"chrome://browser/content/firefoxview/synced-tabs-empty.svg";
descriptionArray = [description];
}
return {
action,
buttonLabel,
descriptionArray,
descriptionLink,
error,
header,
mainImageUrl,
};
}
getRenderInfo() {
let renderInfo = {};
for (let tab of this.currentSyncedTabs) {
if (!(tab.client in renderInfo)) {
renderInfo[tab.client] = {
name: tab.device,
deviceType: tab.deviceType,
canClose: !!tab.availableCommands[lazy.COMMAND_CLOSETAB],
tabs: [],
};
}
renderInfo[tab.client].tabs.push(tab);
}
// Add devices without tabs
for (let device of this.devices) {
if (!(device.id in renderInfo)) {
renderInfo[device.id] = {
name: device.name,
deviceType: device.clientType,
tabs: [],
};
}
}
for (let id in renderInfo) {
renderInfo[id].tabItems = this.searchQuery
? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id]))
: this.getTabItems(renderInfo[id]);
}
return renderInfo;
}
getMessageCard() {
switch (this.currentSetupStateIndex) {
case 0 /* error-state */:
if (this.errorState) {
return this.#getMessageCardForState({ error: true });
}
return this.#getMessageCardForState({ action: "loading" });
case 1 /* not-signed-in */:
if (Services.prefs.prefHasUserValue("services.sync.lastversion")) {
// If this pref is set, the user has signed out of sync.
return this.#getMessageCardForState({
error: true,
errorState: "signed-out",
});
}
return this.#getMessageCardForState({ action: "sign-in" });
case 2 /* connect-secondary-device*/:
return this.#getMessageCardForState({ action: "add-device" });
case 3 /* disabled-tab-sync */:
return this.#getMessageCardForState({ action: "sync-tabs-disabled" });
case 4 /* synced-tabs-loaded*/:
// There seems to be an edge case where sync says everything worked
// fine but we have no devices.
if (!this.devices.length) {
return this.#getMessageCardForState({ action: "add-device" });
}
}
return null;
}
/**
* Turn renderInfo into a list of tabs for syncedtabs-tab-list
*
* @param {object} renderInfo
* @param {Array<object>} [renderInfo.tabs]
* tabs to display to the user
* @param {string} [renderInfo.name]
* The name of the device for use when the user hovers over
* the close button for context
* @param {boolean} [renderInfo.canClose]
* Whether the list should support remotely closing tabs
*/
getTabItems({ tabs, name, canClose }) {
return tabs
?.map(tab => {
let tabItem = {
icon: tab.icon,
title: tab.title,
time: tab.lastUsed * 1000,
url: tab.url,
fxaDeviceId: tab.fxaDeviceId,
primaryL10nId: "firefoxview-tabs-list-tab-button",
primaryL10nArgs: JSON.stringify({ targetURI: tab.url }),
secondaryL10nId: this.contextMenu
? "fxviewtabrow-options-menu-button"
: undefined,
secondaryL10nArgs: this.contextMenu
? JSON.stringify({ tabTitle: tab.title })
: undefined,
};
// We don't want to show the option to close remotely if the
// device doesn't support it
if (!canClose) {
return tabItem;
}
// If this item has been requested to be closed, show
// the undo instead until removed from the list
if (tabItem.url === this.lastClosedURL) {
tabItem.tertiaryL10nId = "text-action-undo";
tabItem.tertiaryActionClass = "undo-button";
tabItem.tertiaryL10nArgs = null;
tabItem.closeRequested = true;
} else {
// Otherwise default to showing the close/dismiss button
tabItem.tertiaryL10nId = "synced-tabs-context-close-tab-title";
tabItem.tertiaryL10nArgs = JSON.stringify({ deviceName: name });
tabItem.tertiaryActionClass = "dismiss-button";
tabItem.closeRequested = false;
}
return tabItem;
})
.filter(
item =>
!this.isURLQueuedToClose(item.fxaDeviceId, item.url) ||
item.url === this.lastClosedURL
);
}
updateTabsList(syncedTabs) {
if (!syncedTabs.length) {
this.currentSyncedTabs = syncedTabs;
}
const tabsToRender = syncedTabs;
// Return early if new tabs are the same as previous ones
if (lazy.ObjectUtils.deepEqual(tabsToRender, this.currentSyncedTabs)) {
return;
}
this.currentSyncedTabs = tabsToRender;
this.host.requestUpdate();
}
async getSyncedTabData() {
this.devices = await lazy.SyncedTabs.getTabClients();
let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 5000, {
removeAllDupes: false,
removeDeviceDupes: true,
});
this.updateTabsList(tabs);
}
// Wrappers and helpful methods for SyncedTabManagement
// so FxView and Sidebar don't need to import
requestCloseRemoteTab(fxaDeviceId, url) {
if (!this._pendingCloseTabs.has(fxaDeviceId)) {
this._pendingCloseTabs.set(fxaDeviceId, new Set());
}
this._pendingCloseTabs.get(fxaDeviceId).add(url);
this.lastClosedURL = url;
lazy.SyncedTabsManagement.enqueueTabToClose(fxaDeviceId, url);
}
removePendingTabToClose(fxaDeviceId, url) {
const urls = this._pendingCloseTabs.get(fxaDeviceId);
if (urls) {
urls.delete(url);
if (!urls.size) {
this._pendingCloseTabs.delete(fxaDeviceId);
}
}
this.lastClosedURL = null;
lazy.SyncedTabsManagement.removePendingTabToClose(fxaDeviceId, url);
}
isURLQueuedToClose(fxaDeviceId, url) {
const urls = this._pendingCloseTabs.get(fxaDeviceId);
return urls && urls.has(url);
}
}