syncedtabs.mjs (13398B)
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 SyncedTabsController: "resource:///modules/SyncedTabsController.sys.mjs", 8 }); 9 10 const { TabsSetupFlowManager } = ChromeUtils.importESModule( 11 "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs" 12 ); 13 14 import { 15 html, 16 ifDefined, 17 when, 18 } from "chrome://global/content/vendor/lit.all.mjs"; 19 import { ViewPage } from "./viewpage.mjs"; 20 import { 21 escapeHtmlEntities, 22 MAX_TABS_FOR_RECENT_BROWSING, 23 navigateToLink, 24 } from "./helpers.mjs"; 25 // eslint-disable-next-line import/no-unassigned-import 26 import "chrome://browser/content/firefoxview/syncedtabs-tab-list.mjs"; 27 28 const UI_OPEN_STATE = "browser.tabs.firefox-view.ui-state.tab-pickup.open"; 29 30 class SyncedTabsInView extends ViewPage { 31 controller = new lazy.SyncedTabsController(this, { 32 contextMenu: true, 33 pairDeviceCallback: () => 34 Glean.firefoxviewNext.fxaMobileSync.record({ 35 has_devices: TabsSetupFlowManager.secondaryDeviceConnected, 36 }), 37 signupCallback: () => Glean.firefoxviewNext.fxaContinueSync.record(), 38 }); 39 40 constructor() { 41 super(); 42 this._started = false; 43 this._id = Math.floor(Math.random() * 10e6); 44 if (this.recentBrowsing) { 45 this.maxTabsLength = MAX_TABS_FOR_RECENT_BROWSING; 46 } else { 47 // Setting maxTabsLength to -1 for no max 48 this.maxTabsLength = -1; 49 } 50 this.fullyUpdated = false; 51 this.showAll = false; 52 this.cumulativeSearches = 0; 53 this.onSearchQuery = this.onSearchQuery.bind(this); 54 } 55 56 static properties = { 57 ...ViewPage.properties, 58 showAll: { type: Boolean }, 59 cumulativeSearches: { type: Number }, 60 }; 61 62 static queries = { 63 cardEls: { all: "card-container" }, 64 emptyState: "fxview-empty-state", 65 searchTextbox: "moz-input-search", 66 tabLists: { all: "syncedtabs-tab-list" }, 67 }; 68 69 start() { 70 if (this._started) { 71 return; 72 } 73 this._started = true; 74 this.controller.addSyncObservers(); 75 this.controller.updateStates(); 76 this.onVisibilityChange(); 77 78 if (this.recentBrowsing) { 79 this.recentBrowsingElement.addEventListener( 80 "MozInputSearch:search", 81 this.onSearchQuery 82 ); 83 } 84 } 85 86 stop() { 87 if (!this._started) { 88 return; 89 } 90 this._started = false; 91 TabsSetupFlowManager.updateViewVisibility(this._id, "unloaded"); 92 this.onVisibilityChange(); 93 this.controller.removeSyncObservers(); 94 95 if (this.recentBrowsing) { 96 this.recentBrowsingElement.removeEventListener( 97 "MozInputSearch:search", 98 this.onSearchQuery 99 ); 100 } 101 } 102 103 disconnectedCallback() { 104 super.disconnectedCallback(); 105 this.stop(); 106 } 107 108 viewVisibleCallback() { 109 this.start(); 110 } 111 112 viewHiddenCallback() { 113 this.stop(); 114 } 115 116 onVisibilityChange() { 117 const isOpen = this.open; 118 const isVisible = this.isVisible; 119 if (isVisible && isOpen) { 120 this.update(); 121 TabsSetupFlowManager.updateViewVisibility(this._id, "visible"); 122 } else { 123 TabsSetupFlowManager.updateViewVisibility( 124 this._id, 125 isVisible ? "closed" : "hidden" 126 ); 127 } 128 129 this.toggleVisibilityInCardContainer(); 130 } 131 132 generateMessageCard({ 133 action, 134 buttonLabel, 135 descriptionArray, 136 descriptionLink, 137 header, 138 mainImageUrl, 139 }) { 140 return html` 141 <fxview-empty-state 142 headerLabel=${header} 143 .descriptionLabels=${descriptionArray} 144 .descriptionLink=${ifDefined(descriptionLink)} 145 class="empty-state synced-tabs error" 146 ?isSelectedTab=${this.selectedTab} 147 ?isInnerCard=${this.recentBrowsing} 148 mainImageUrl=${ifDefined(mainImageUrl)} 149 id="empty-container" 150 > 151 <button 152 class="primary" 153 slot="primary-action" 154 ?hidden=${!buttonLabel} 155 data-l10n-id=${ifDefined(buttonLabel)} 156 data-action=${action} 157 @click=${e => this.controller.handleEvent(e)} 158 ></button> 159 </fxview-empty-state> 160 `; 161 } 162 163 onOpenLink(event) { 164 navigateToLink(event); 165 166 Glean.firefoxviewNext.syncedTabsTabs.record({ 167 page: this.recentBrowsing ? "recentbrowsing" : "syncedtabs", 168 }); 169 170 if (this.controller.searchQuery) { 171 Glean.firefoxview.cumulativeSearches[ 172 this.recentBrowsing ? "recentbrowsing" : "syncedtabs" 173 ].accumulateSingleSample(this.cumulativeSearches); 174 this.cumulativeSearches = 0; 175 } 176 } 177 178 onContextMenu(e) { 179 this.triggerNode = e.originalTarget; 180 e.target.querySelector("panel-list").toggle(e.detail.originalEvent); 181 } 182 183 onCloseTab(e) { 184 const { url, fxaDeviceId, tertiaryActionClass } = e.originalTarget; 185 if (tertiaryActionClass === "dismiss-button") { 186 // Set new pending close tab 187 this.controller.requestCloseRemoteTab(fxaDeviceId, url); 188 } else if (tertiaryActionClass === "undo-button") { 189 // User wants to undo 190 this.controller.removePendingTabToClose(fxaDeviceId, url); 191 } 192 this.requestUpdate(); 193 } 194 195 panelListTemplate() { 196 return html` 197 <panel-list slot="menu" data-tab-type="syncedtabs"> 198 <panel-item 199 @click=${this.openInNewWindow} 200 data-l10n-id="fxviewtabrow-open-in-window" 201 data-l10n-attrs="accesskey" 202 ></panel-item> 203 <panel-item 204 @click=${this.openInNewPrivateWindow} 205 data-l10n-id="fxviewtabrow-open-in-private-window" 206 data-l10n-attrs="accesskey" 207 ></panel-item> 208 <hr /> 209 <panel-item 210 @click=${this.copyLink} 211 data-l10n-id="fxviewtabrow-copy-link" 212 data-l10n-attrs="accesskey" 213 ></panel-item> 214 </panel-list> 215 `; 216 } 217 218 noDeviceTabsTemplate(deviceName, deviceType, isSearchResultsEmpty = false) { 219 const template = html`<h3 220 slot=${ifDefined(this.recentBrowsing ? null : "header")} 221 class="device-header" 222 > 223 <span class="icon ${deviceType}" role="presentation"></span> 224 ${deviceName} 225 </h3> 226 ${when( 227 isSearchResultsEmpty, 228 () => html` 229 <div 230 slot=${ifDefined(this.recentBrowsing ? null : "main")} 231 class="blackbox notabs search-results-empty" 232 data-l10n-id="firefoxview-search-results-empty" 233 data-l10n-args=${JSON.stringify({ 234 query: escapeHtmlEntities(this.controller.searchQuery), 235 })} 236 ></div> 237 `, 238 () => html` 239 <div 240 slot=${ifDefined(this.recentBrowsing ? null : "main")} 241 class="blackbox notabs" 242 data-l10n-id="firefoxview-syncedtabs-device-notabs" 243 ></div> 244 ` 245 )}`; 246 return this.recentBrowsing 247 ? template 248 : html`<card-container 249 shortPageName=${this.recentBrowsing ? "syncedtabs" : null} 250 >${template}</card-container 251 >`; 252 } 253 254 onSearchQuery(e) { 255 if (!this.recentBrowsing) { 256 Glean.firefoxviewNext.searchInitiatedSearch.record({ 257 page: "syncedtabs", 258 }); 259 } 260 this.controller.searchQuery = e.detail.query; 261 this.cumulativeSearches = e.detail.query ? this.cumulativeSearches + 1 : 0; 262 this.showAll = false; 263 } 264 265 deviceTemplate(deviceName, deviceType, tabItems) { 266 return html`<h3 267 slot=${!this.recentBrowsing ? "header" : null} 268 class="device-header" 269 > 270 <span class="icon ${deviceType}" role="presentation"></span> 271 ${deviceName} 272 </h3> 273 <syncedtabs-tab-list 274 slot="main" 275 .hasPopup=${"menu"} 276 .tabItems=${ifDefined(tabItems)} 277 .searchQuery=${this.controller.searchQuery} 278 .maxTabsLength=${this.showAll ? -1 : this.maxTabsLength} 279 @fxview-tab-list-primary-action=${this.onOpenLink} 280 @fxview-tab-list-secondary-action=${this.onContextMenu} 281 @fxview-tab-list-tertiary-action=${this.onCloseTab} 282 secondaryActionClass="options-button" 283 > 284 ${this.panelListTemplate()} 285 </syncedtabs-tab-list>`; 286 } 287 288 generateTabList() { 289 let renderArray = []; 290 let renderInfo = this.controller.getRenderInfo(); 291 for (let id in renderInfo) { 292 let tabItems = renderInfo[id].tabItems; 293 if (tabItems.length) { 294 const template = this.recentBrowsing 295 ? this.deviceTemplate( 296 renderInfo[id].name, 297 renderInfo[id].deviceType, 298 tabItems 299 ) 300 : html`<card-container 301 shortPageName=${this.recentBrowsing ? "syncedtabs" : null} 302 >${this.deviceTemplate( 303 renderInfo[id].name, 304 renderInfo[id].deviceType, 305 tabItems 306 )} 307 </card-container>`; 308 renderArray.push(template); 309 if (this.isShowAllLinkVisible(tabItems)) { 310 renderArray.push( 311 html` <div class="show-all-link-container"> 312 <div 313 class="show-all-link" 314 @click=${this.enableShowAll} 315 @keydown=${this.enableShowAll} 316 data-l10n-id="firefoxview-show-all" 317 tabindex="0" 318 role="link" 319 ></div> 320 </div>` 321 ); 322 } 323 } else { 324 // Check renderInfo[id].tabs.length to determine whether to display an 325 // empty tab list message or empty search results message. 326 // If there are no synced tabs, we always display the empty tab list 327 // message, even if there is an active search query. 328 renderArray.push( 329 this.noDeviceTabsTemplate( 330 renderInfo[id].name, 331 renderInfo[id].deviceType, 332 Boolean(renderInfo[id].tabs.length) 333 ) 334 ); 335 } 336 } 337 return renderArray; 338 } 339 340 isShowAllLinkVisible(tabItems) { 341 return ( 342 this.recentBrowsing && 343 this.controller.searchQuery && 344 tabItems.length > this.maxTabsLength && 345 !this.showAll 346 ); 347 } 348 349 enableShowAll(event) { 350 if ( 351 event.type == "click" || 352 (event.type == "keydown" && event.code == "Enter") || 353 (event.type == "keydown" && event.code == "Space") 354 ) { 355 event.preventDefault(); 356 this.showAll = true; 357 Glean.firefoxviewNext.searchShowAllShowallbutton.record({ 358 section: "syncedtabs", 359 }); 360 } 361 } 362 363 generateCardContent() { 364 const cardProperties = this.controller.getMessageCard(); 365 return cardProperties 366 ? this.generateMessageCard(cardProperties) 367 : this.generateTabList(); 368 } 369 370 render() { 371 this.open = 372 !TabsSetupFlowManager.isTabSyncSetupComplete || 373 Services.prefs.getBoolPref(UI_OPEN_STATE, true); 374 375 let renderArray = []; 376 renderArray.push( 377 html` <link 378 rel="stylesheet" 379 href="chrome://browser/content/firefoxview/view-syncedtabs.css" 380 />` 381 ); 382 renderArray.push( 383 html` <link 384 rel="stylesheet" 385 href="chrome://browser/content/firefoxview/firefoxview.css" 386 />` 387 ); 388 389 if (!this.recentBrowsing) { 390 renderArray.push( 391 html`<div class="sticky-container bottom-fade"> 392 <h2 393 class="page-header" 394 data-l10n-id="firefoxview-synced-tabs-header" 395 ></h2> 396 <div class="syncedtabs-header"> 397 <div> 398 <moz-input-search 399 data-l10n-id="firefoxview-search-text-box-tabs" 400 data-l10n-attrs="placeholder" 401 @MozInputSearch:search=${this.onSearchQuery} 402 ></moz-input-search> 403 </div> 404 ${when( 405 this.controller.currentSetupStateIndex === 4, 406 () => html` 407 <button 408 class="small-button" 409 data-action="add-device" 410 @click=${e => this.controller.handleEvent(e)} 411 > 412 <img 413 class="icon" 414 role="presentation" 415 src="chrome://global/skin/icons/plus.svg" 416 alt="plus sign" 417 /><span 418 data-l10n-id="firefoxview-syncedtabs-connect-another-device" 419 data-action="add-device" 420 ></span> 421 </button> 422 ` 423 )} 424 </div> 425 </div>` 426 ); 427 } 428 429 if (this.recentBrowsing) { 430 renderArray.push( 431 html`<card-container 432 preserveCollapseState 433 shortPageName="syncedtabs" 434 ?showViewAll=${this.controller.currentSetupStateIndex == 4 && 435 this.controller.currentSyncedTabs.length} 436 ?isEmptyState=${!this.controller.currentSyncedTabs.length} 437 > 438 > 439 <h3 440 slot="header" 441 data-l10n-id="firefoxview-synced-tabs-header" 442 class="recentbrowsing-header" 443 ></h3> 444 <div slot="main">${this.generateCardContent()}</div> 445 </card-container>` 446 ); 447 } else { 448 renderArray.push( 449 html`<div class="cards-container">${this.generateCardContent()}</div>` 450 ); 451 } 452 return renderArray; 453 } 454 455 updated() { 456 this.fullyUpdated = true; 457 this.toggleVisibilityInCardContainer(); 458 } 459 } 460 customElements.define("view-syncedtabs", SyncedTabsInView);