SyncedTabsController.sys.mjs (13907B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 ChromeUtils.defineESModuleGetters(lazy, { 7 ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", 8 SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", 9 SyncedTabsManagement: "resource://services-sync/SyncedTabs.sys.mjs", 10 COMMAND_CLOSETAB: "resource://gre/modules/FxAccountsCommon.sys.mjs", 11 }); 12 13 import { SyncedTabsErrorHandler } from "resource:///modules/firefox-view-synced-tabs-error-handler.sys.mjs"; 14 import { TabsSetupFlowManager } from "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs"; 15 import { searchTabList } from "chrome://browser/content/firefoxview/search-helpers.mjs"; 16 17 const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; 18 const TOPIC_SETUPSTATE_CHANGED = "firefox-view.setupstate.changed"; 19 20 /** 21 * The controller for synced tabs components. 22 * 23 * @implements {ReactiveController} 24 */ 25 export class SyncedTabsController { 26 /** 27 * @type {boolean} 28 */ 29 contextMenu; 30 currentSetupStateIndex = -1; 31 currentSyncedTabs = []; 32 devices = []; 33 /** 34 * The current error state as determined by `SyncedTabsErrorHandler`. 35 * 36 * @type {number} 37 */ 38 errorState = null; 39 /** 40 * Component associated with this controller. 41 * 42 * @type {ReactiveControllerHost} 43 */ 44 host; 45 /** 46 * @type {Function} 47 */ 48 pairDeviceCallback; 49 searchQuery = ""; 50 /** 51 * @type {Function} 52 */ 53 signupCallback; 54 55 /** 56 * Construct a new SyncedTabsController. 57 * 58 * @param {ReactiveControllerHost} host 59 * @param {object} options 60 * @param {boolean} [options.contextMenu] 61 * Whether synced tab items have a secondary context menu. 62 * @param {Function} [options.pairDeviceCallback] 63 * The function to call when the pair device window is opened. 64 * @param {Function} [options.signupCallback] 65 * The function to call when the signup window is opened. 66 */ 67 constructor(host, { contextMenu, pairDeviceCallback, signupCallback } = {}) { 68 this.contextMenu = contextMenu; 69 this.pairDeviceCallback = pairDeviceCallback; 70 this.signupCallback = signupCallback; 71 this.observe = this.observe.bind(this); 72 this.host = host; 73 this.host.addController(this); 74 // Track tabs requested close per device but not-yet-sent, 75 // it'll be in the form of {fxaDeviceId: Set(urls)} 76 this._pendingCloseTabs = new Map(); 77 // The last closed URL, for undo purposes 78 this.lastClosedURL = null; 79 } 80 81 hostConnected() { 82 this.host.addEventListener("click", this); 83 } 84 85 hostDisconnected() { 86 this.host.removeEventListener("click", this); 87 } 88 89 get isSyncedTabsLoaded() { 90 return this.currentSetupStateIndex === 4; 91 } 92 93 addSyncObservers() { 94 Services.obs.addObserver(this.observe, SYNCED_TABS_CHANGED); 95 Services.obs.addObserver(this.observe, TOPIC_SETUPSTATE_CHANGED); 96 } 97 98 removeSyncObservers() { 99 Services.obs.removeObserver(this.observe, SYNCED_TABS_CHANGED); 100 Services.obs.removeObserver(this.observe, TOPIC_SETUPSTATE_CHANGED); 101 } 102 103 handleEvent(event) { 104 if (event.type == "click" && event.target.dataset.action) { 105 const { ErrorType } = SyncedTabsErrorHandler; 106 switch (event.target.dataset.action) { 107 case `${ErrorType.SYNC_ERROR}`: 108 case `${ErrorType.NETWORK_OFFLINE}`: 109 case `${ErrorType.PASSWORD_LOCKED}`: { 110 TabsSetupFlowManager.tryToClearError(); 111 break; 112 } 113 case `${ErrorType.SIGNED_OUT}`: 114 case "sign-in": { 115 TabsSetupFlowManager.openFxASignup(event.target.ownerGlobal); 116 this.signupCallback?.(); 117 break; 118 } 119 case "add-device": { 120 TabsSetupFlowManager.openFxAPairDevice(event.target.ownerGlobal); 121 this.pairDeviceCallback?.(); 122 break; 123 } 124 case "sync-tabs-disabled": { 125 TabsSetupFlowManager.syncOpenTabs(event.target); 126 break; 127 } 128 case `${ErrorType.SYNC_DISCONNECTED}`: { 129 const win = event.target.ownerGlobal; 130 const { switchToTabHavingURI } = 131 win.docShell.chromeEventHandler.ownerGlobal; 132 switchToTabHavingURI( 133 "about:preferences?action=choose-what-to-sync#sync", 134 true, 135 {} 136 ); 137 break; 138 } 139 } 140 } else if (event.type == "click" && event.composedTarget.href) { 141 const { switchToTabHavingURI } = 142 event.view.browsingContext.topChromeWindow; 143 switchToTabHavingURI(event.composedTarget.href, true, { 144 ignoreFragment: "whenComparingAndReplace", 145 }); 146 } 147 } 148 149 async observe(_, topic, errorState) { 150 if (topic == TOPIC_SETUPSTATE_CHANGED) { 151 await this.updateStates(errorState); 152 } 153 if (topic == SYNCED_TABS_CHANGED) { 154 // Usually this means we performed a sync, so clear the 155 // "in-queue" things as those most likely got flushed 156 this._pendingCloseTabs = new Map(); 157 this.lastClosedURL = null; 158 await this.getSyncedTabData(); 159 } 160 } 161 162 async updateStates(errorState) { 163 let stateIndex = TabsSetupFlowManager.uiStateIndex; 164 errorState = errorState || SyncedTabsErrorHandler.getErrorType(); 165 166 if (stateIndex == 4 && this.currentSetupStateIndex !== stateIndex) { 167 // trigger an initial request for the synced tabs list 168 await this.getSyncedTabData(); 169 } 170 171 this.currentSetupStateIndex = stateIndex; 172 this.errorState = errorState; 173 this.host.requestUpdate(); 174 } 175 176 actionMappings = { 177 "sign-in": { 178 header: "firefoxview-syncedtabs-signin-header-2", 179 description: "firefoxview-syncedtabs-signin-description-2", 180 buttonLabel: "firefoxview-syncedtabs-signin-primarybutton-2", 181 }, 182 "add-device": { 183 header: "firefoxview-syncedtabs-adddevice-header-2", 184 description: "firefoxview-syncedtabs-adddevice-description-2", 185 buttonLabel: "firefoxview-syncedtabs-adddevice-primarybutton", 186 descriptionLink: { 187 name: "url", 188 url: "https://support.mozilla.org/kb/how-do-i-set-sync-my-computer#w_connect-additional-devices-to-sync", 189 }, 190 }, 191 "sync-tabs-disabled": { 192 header: "firefoxview-syncedtabs-synctabs-header", 193 description: "firefoxview-syncedtabs-synctabs-description", 194 buttonLabel: "firefoxview-tabpickup-synctabs-primarybutton", 195 }, 196 loading: { 197 header: "firefoxview-syncedtabs-loading-header", 198 description: "firefoxview-syncedtabs-loading-description", 199 }, 200 }; 201 202 #getMessageCardForState({ error = false, action, errorState }) { 203 errorState = errorState || this.errorState; 204 let header, description, descriptionLink, buttonLabel, mainImageUrl; 205 let descriptionArray; 206 if (error) { 207 let link; 208 ({ header, description, link, buttonLabel } = 209 SyncedTabsErrorHandler.getFluentStringsForErrorType(errorState)); 210 action = `${errorState}`; 211 mainImageUrl = 212 "chrome://browser/content/firefoxview/synced-tabs-error.svg"; 213 descriptionArray = [description]; 214 if (errorState == "password-locked") { 215 descriptionLink = {}; 216 // This is ugly, but we need to special case this link so we can 217 // coexist with the old view. 218 descriptionArray.push("firefoxview-syncedtab-password-locked-link"); 219 descriptionLink.name = "syncedtab-password-locked-link"; 220 descriptionLink.url = link.href; 221 } 222 } else { 223 header = this.actionMappings[action].header; 224 description = this.actionMappings[action].description; 225 buttonLabel = this.actionMappings[action].buttonLabel; 226 descriptionLink = this.actionMappings[action].descriptionLink; 227 mainImageUrl = 228 "chrome://browser/content/firefoxview/synced-tabs-empty.svg"; 229 descriptionArray = [description]; 230 } 231 return { 232 action, 233 buttonLabel, 234 descriptionArray, 235 descriptionLink, 236 error, 237 header, 238 mainImageUrl, 239 }; 240 } 241 242 getRenderInfo() { 243 let renderInfo = {}; 244 for (let tab of this.currentSyncedTabs) { 245 if (!(tab.client in renderInfo)) { 246 renderInfo[tab.client] = { 247 name: tab.device, 248 deviceType: tab.deviceType, 249 canClose: !!tab.availableCommands[lazy.COMMAND_CLOSETAB], 250 tabs: [], 251 }; 252 } 253 renderInfo[tab.client].tabs.push(tab); 254 } 255 256 // Add devices without tabs 257 for (let device of this.devices) { 258 if (!(device.id in renderInfo)) { 259 renderInfo[device.id] = { 260 name: device.name, 261 deviceType: device.clientType, 262 tabs: [], 263 }; 264 } 265 } 266 267 for (let id in renderInfo) { 268 renderInfo[id].tabItems = this.searchQuery 269 ? searchTabList(this.searchQuery, this.getTabItems(renderInfo[id])) 270 : this.getTabItems(renderInfo[id]); 271 } 272 return renderInfo; 273 } 274 275 getMessageCard() { 276 switch (this.currentSetupStateIndex) { 277 case 0 /* error-state */: 278 if (this.errorState) { 279 return this.#getMessageCardForState({ error: true }); 280 } 281 return this.#getMessageCardForState({ action: "loading" }); 282 case 1 /* not-signed-in */: 283 if (Services.prefs.prefHasUserValue("services.sync.lastversion")) { 284 // If this pref is set, the user has signed out of sync. 285 // This path is also taken if we are disconnected from sync. See bug 1784055 286 return this.#getMessageCardForState({ 287 error: true, 288 errorState: "signed-out", 289 }); 290 } 291 return this.#getMessageCardForState({ action: "sign-in" }); 292 case 2 /* connect-secondary-device*/: 293 return this.#getMessageCardForState({ action: "add-device" }); 294 case 3 /* disabled-tab-sync */: 295 return this.#getMessageCardForState({ action: "sync-tabs-disabled" }); 296 case 4 /* synced-tabs-loaded*/: 297 // There seems to be an edge case where sync says everything worked 298 // fine but we have no devices. 299 if (!this.devices.length) { 300 return this.#getMessageCardForState({ action: "add-device" }); 301 } 302 } 303 return null; 304 } 305 306 /** 307 * Turn renderInfo into a list of tabs for syncedtabs-tab-list 308 * 309 * @param {object} renderInfo 310 * @param {Array<object>} [renderInfo.tabs] 311 * tabs to display to the user 312 * @param {string} [renderInfo.name] 313 * The name of the device for use when the user hovers over 314 * the close button for context 315 * @param {boolean} [renderInfo.canClose] 316 * Whether the list should support remotely closing tabs 317 */ 318 getTabItems({ tabs, name, canClose }) { 319 return tabs 320 ?.map(tab => { 321 let tabItem = { 322 icon: tab.icon, 323 title: tab.title, 324 time: tab.lastUsed * 1000, 325 url: tab.url, 326 fxaDeviceId: tab.fxaDeviceId, 327 primaryL10nId: "firefoxview-tabs-list-tab-button", 328 primaryL10nArgs: JSON.stringify({ targetURI: tab.url }), 329 secondaryL10nId: this.contextMenu 330 ? "fxviewtabrow-options-menu-button" 331 : undefined, 332 secondaryL10nArgs: this.contextMenu 333 ? JSON.stringify({ tabTitle: tab.title }) 334 : undefined, 335 }; 336 // We don't want to show the option to close remotely if the 337 // device doesn't support it 338 if (!canClose) { 339 return tabItem; 340 } 341 342 // If this item has been requested to be closed, show 343 // the undo instead until removed from the list 344 if (tabItem.url === this.lastClosedURL) { 345 tabItem.tertiaryL10nId = "text-action-undo"; 346 tabItem.tertiaryActionClass = "undo-button"; 347 tabItem.tertiaryL10nArgs = null; 348 tabItem.closeRequested = true; 349 } else { 350 // Otherwise default to showing the close/dismiss button 351 tabItem.tertiaryL10nId = "synced-tabs-context-close-tab-title"; 352 tabItem.tertiaryL10nArgs = JSON.stringify({ deviceName: name }); 353 tabItem.tertiaryActionClass = "dismiss-button"; 354 tabItem.closeRequested = false; 355 } 356 return tabItem; 357 }) 358 .filter( 359 item => 360 !this.isURLQueuedToClose(item.fxaDeviceId, item.url) || 361 item.url === this.lastClosedURL 362 ); 363 } 364 365 updateTabsList(syncedTabs) { 366 if (!syncedTabs.length) { 367 this.currentSyncedTabs = syncedTabs; 368 } 369 370 const tabsToRender = syncedTabs; 371 372 // Return early if new tabs are the same as previous ones 373 if (lazy.ObjectUtils.deepEqual(tabsToRender, this.currentSyncedTabs)) { 374 return; 375 } 376 377 this.currentSyncedTabs = tabsToRender; 378 this.host.requestUpdate(); 379 } 380 381 async getSyncedTabData() { 382 this.devices = await lazy.SyncedTabs.getTabClients(); 383 let tabs = await lazy.SyncedTabs.createRecentTabsList(this.devices, 5000, { 384 removeAllDupes: false, 385 removeDeviceDupes: true, 386 }); 387 388 this.updateTabsList(tabs); 389 } 390 391 // Wrappers and helpful methods for SyncedTabManagement 392 // so FxView and Sidebar don't need to import 393 requestCloseRemoteTab(fxaDeviceId, url) { 394 if (!this._pendingCloseTabs.has(fxaDeviceId)) { 395 this._pendingCloseTabs.set(fxaDeviceId, new Set()); 396 } 397 this._pendingCloseTabs.get(fxaDeviceId).add(url); 398 this.lastClosedURL = url; 399 lazy.SyncedTabsManagement.enqueueTabToClose(fxaDeviceId, url); 400 } 401 402 removePendingTabToClose(fxaDeviceId, url) { 403 const urls = this._pendingCloseTabs.get(fxaDeviceId); 404 if (urls) { 405 urls.delete(url); 406 if (!urls.size) { 407 this._pendingCloseTabs.delete(fxaDeviceId); 408 } 409 } 410 this.lastClosedURL = null; 411 lazy.SyncedTabsManagement.removePendingTabToClose(fxaDeviceId, url); 412 } 413 414 isURLQueuedToClose(fxaDeviceId, url) { 415 const urls = this._pendingCloseTabs.get(fxaDeviceId); 416 return urls && urls.has(url); 417 } 418 }