sidebar-syncedtabs.mjs (10264B)
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 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 8 SyncedTabsController: "resource:///modules/SyncedTabsController.sys.mjs", 9 SidebarTreeView: 10 "moz-src:///browser/components/sidebar/SidebarTreeView.sys.mjs", 11 }); 12 13 import { 14 html, 15 ifDefined, 16 when, 17 } from "chrome://global/content/vendor/lit.all.mjs"; 18 import { 19 escapeHtmlEntities, 20 navigateToLink, 21 } from "chrome://browser/content/firefoxview/helpers.mjs"; 22 23 import { SidebarPage } from "./sidebar-page.mjs"; 24 25 class SyncedTabsInSidebar extends SidebarPage { 26 controller = new lazy.SyncedTabsController(this); 27 28 static queries = { 29 cards: { all: "moz-card" }, 30 lists: { all: "sidebar-tab-list" }, 31 searchTextbox: "moz-input-search", 32 }; 33 34 constructor() { 35 super(); 36 this.onSearchQuery = this.onSearchQuery.bind(this); 37 this.onSecondaryAction = this.onSecondaryAction.bind(this); 38 this.treeView = new lazy.SidebarTreeView(this, { multiSelect: false }); 39 } 40 41 connectedCallback() { 42 super.connectedCallback(); 43 this.controller.addSyncObservers(); 44 this.controller.updateStates().then(() => 45 Glean.syncedTabs.sidebarToggle.record({ 46 opened: true, 47 synced_tabs_loaded: this.controller.isSyncedTabsLoaded, 48 version: "new", 49 }) 50 ); 51 this.addContextMenuListeners(); 52 this.addSidebarFocusedListeners(); 53 } 54 55 disconnectedCallback() { 56 super.disconnectedCallback(); 57 this.controller.removeSyncObservers(); 58 Glean.syncedTabs.sidebarToggle.record({ 59 opened: false, 60 synced_tabs_loaded: this.controller.isSyncedTabsLoaded, 61 version: "new", 62 }); 63 this.removeContextMenuListeners(); 64 this.removeSidebarFocusedListeners(); 65 } 66 67 handleContextMenuEvent(e) { 68 this.triggerNode = 69 this.findTriggerNode(e, "sidebar-tab-row") || 70 this.findTriggerNode(e, "moz-input-search"); 71 if (!this.triggerNode) { 72 e.preventDefault(); 73 return; 74 } 75 const contextMenu = this._contextMenu; 76 const closeTabMenuItem = contextMenu.querySelector( 77 "#sidebar-context-menu-close-remote-tab" 78 ); 79 closeTabMenuItem.setAttribute( 80 "data-l10n-args", 81 this.triggerNode.secondaryL10nArgs 82 ); 83 // Enable the feature only if the device supports it 84 closeTabMenuItem.disabled = !this.triggerNode.canClose; 85 86 let privateWindowMenuItem = contextMenu.querySelector( 87 "#sidebar-synced-tabs-context-open-in-private-window" 88 ); 89 privateWindowMenuItem.hidden = !lazy.PrivateBrowsingUtils.enabled; 90 } 91 92 handleCommandEvent(e) { 93 switch (e.target.id) { 94 case "sidebar-context-menu-close-remote-tab": 95 this.requestOrRemoveTabToClose( 96 this.triggerNode.url, 97 this.triggerNode.fxaDeviceId, 98 this.triggerNode.secondaryActionClass 99 ); 100 break; 101 default: 102 super.handleCommandEvent(e); 103 break; 104 } 105 } 106 107 handleSidebarFocusedEvent() { 108 this.searchTextbox?.focus(); 109 } 110 111 onSecondaryAction(e) { 112 const { url, fxaDeviceId, secondaryActionClass } = e.originalTarget; 113 this.requestOrRemoveTabToClose(url, fxaDeviceId, secondaryActionClass); 114 } 115 116 requestOrRemoveTabToClose(url, fxaDeviceId, secondaryActionClass) { 117 if (secondaryActionClass === "dismiss-button") { 118 // Set new pending close tab 119 this.controller.requestCloseRemoteTab(fxaDeviceId, url); 120 } else if (secondaryActionClass === "undo-button") { 121 // User wants to undo 122 this.controller.removePendingTabToClose(fxaDeviceId, url); 123 } 124 this.requestUpdate(); 125 } 126 127 /** 128 * The template shown when the list of synced devices is currently 129 * unavailable. 130 * 131 * @param {object} options 132 * @param {string} options.action 133 * @param {string} options.buttonLabel 134 * @param {string[]} options.descriptionArray 135 * @param {string} options.descriptionLink 136 * @param {string} options.header 137 * @param {string} options.mainImageUrl 138 * @returns {TemplateResult} 139 */ 140 messageCardTemplate({ 141 action, 142 buttonLabel, 143 descriptionArray, 144 descriptionLink, 145 header, 146 mainImageUrl, 147 }) { 148 return html` 149 <fxview-empty-state 150 headerLabel=${header} 151 .descriptionLabels=${descriptionArray} 152 .descriptionLink=${ifDefined(descriptionLink)} 153 class="empty-state synced-tabs error" 154 isSelectedTab 155 mainImageUrl=${ifDefined(mainImageUrl)} 156 id="empty-container" 157 > 158 <moz-button 159 type="primary" 160 slot="primary-action" 161 ?hidden=${!buttonLabel} 162 data-l10n-id=${ifDefined(buttonLabel)} 163 data-action=${action} 164 @click=${e => this.controller.handleEvent(e)} 165 ></moz-button> 166 </fxview-empty-state> 167 `; 168 } 169 170 /** 171 * The template shown for a device that has tabs. 172 * 173 * @param {string} deviceName 174 * @param {string} deviceType 175 * @param {Array} tabItems 176 * @returns {TemplateResult} 177 */ 178 deviceTemplate(deviceName, deviceType, tabItems) { 179 return html`<moz-card 180 type="accordion" 181 expanded 182 .heading=${deviceName} 183 .iconSrc=${this.getDeviceIconSrc(deviceType)} 184 class=${deviceType} 185 @keydown=${e => this.treeView.handleCardKeydown(e)} 186 > 187 <sidebar-tab-list 188 compactRows 189 maxTabsLength="-1" 190 .tabItems=${tabItems} 191 .multiSelect=${false} 192 .updatesPaused=${false} 193 .searchQuery=${this.controller.searchQuery} 194 @fxview-tab-list-primary-action=${navigateToLink} 195 @fxview-tab-list-secondary-action=${this.onSecondaryAction} 196 ></sidebar-tab-list> 197 </moz-card>`; 198 } 199 200 /** 201 * The template shown for a device that has no tabs. 202 * 203 * @param {string} deviceName 204 * @param {string} deviceType 205 * @returns {TemplateResult} 206 */ 207 noDeviceTabsTemplate(deviceName, deviceType) { 208 return html`<moz-card 209 .heading=${deviceName} 210 .iconSrc=${this.getDeviceIconSrc(deviceType)} 211 class=${deviceType} 212 data-l10n-id="firefoxview-syncedtabs-device-notabs" 213 > 214 </moz-card>`; 215 } 216 217 /** 218 * The template shown for a device that has tabs, but no tabs that match the 219 * current search query. 220 * 221 * @param {string} deviceName 222 * @param {string} deviceType 223 * @returns {TemplateResult} 224 */ 225 noSearchResultsTemplate(deviceName, deviceType) { 226 return html`<moz-card 227 .heading=${deviceName} 228 .iconSrc=${this.getDeviceIconSrc(deviceType)} 229 class=${deviceType} 230 data-l10n-id="firefoxview-search-results-empty" 231 data-l10n-args=${JSON.stringify({ 232 query: escapeHtmlEntities(this.controller.searchQuery), 233 })} 234 > 235 </moz-card>`; 236 } 237 238 /** 239 * The template shown for the list of synced devices. 240 * 241 * @returns {TemplateResult[]} 242 */ 243 deviceListTemplate() { 244 return Object.values(this.controller.getRenderInfo()).map( 245 ({ name: deviceName, deviceType, tabItems, canClose, tabs }) => { 246 if (tabItems.length) { 247 return this.deviceTemplate( 248 deviceName, 249 deviceType, 250 this.getTabItems(tabItems, deviceName, canClose) 251 ); 252 } else if (tabs.length) { 253 return this.noSearchResultsTemplate(deviceName, deviceType); 254 } 255 return this.noDeviceTabsTemplate(deviceName, deviceType); 256 } 257 ); 258 } 259 260 getTabItems(items, deviceName, canClose) { 261 return items 262 .map(item => { 263 // We always show the option to close remotely on right-click but 264 // disable it if the device doesn't support actually closing it 265 let secondaryL10nId = "synced-tabs-context-close-tab-title"; 266 let secondaryL10nArgs = JSON.stringify({ deviceName }); 267 if (!canClose) { 268 return { 269 ...item, 270 canClose, 271 secondaryL10nId, 272 secondaryL10nArgs, 273 }; 274 } 275 276 // Default show the close/dismiss button 277 let secondaryActionClass = "dismiss-button"; 278 item.closeRequested = false; 279 280 // If this item has been requested to be closed, show 281 // the undo instead 282 if (item.url === this.controller.lastClosedURL) { 283 secondaryActionClass = "undo-button"; 284 secondaryL10nId = "text-action-undo"; 285 secondaryL10nArgs = null; 286 item.closeRequested = true; 287 } 288 289 return { 290 ...item, 291 canClose, 292 secondaryActionClass, 293 secondaryL10nId, 294 secondaryL10nArgs, 295 }; 296 }) 297 .filter( 298 item => 299 !this.controller.isURLQueuedToClose(item.fxaDeviceId, item.url) || 300 item.url === this.controller.lastClosedURL 301 ); 302 } 303 304 getDeviceIconSrc(deviceType) { 305 const phone = "chrome://browser/skin/device-phone.svg"; 306 const desktop = "chrome://browser/skin/device-desktop.svg"; 307 const tablet = "chrome://browser/skin/device-tablet.svg"; 308 309 const deviceIcons = { 310 desktop, 311 mobile: phone, 312 phone, 313 tablet, 314 }; 315 316 return deviceIcons[deviceType] || null; 317 } 318 319 render() { 320 const messageCard = this.controller.getMessageCard(); 321 return html` 322 ${this.stylesheet()} 323 <div class="sidebar-panel"> 324 <sidebar-panel-header 325 data-l10n-id="sidebar-menu-syncedtabs-header" 326 data-l10n-attrs="heading" 327 view="viewTabsSidebar" 328 > 329 <moz-input-search 330 data-l10n-id="firefoxview-search-text-box-tabs" 331 data-l10n-attrs="placeholder" 332 @MozInputSearch:search=${this.onSearchQuery} 333 ></moz-input-search> 334 </sidebar-panel-header> 335 <div class="sidebar-panel-scrollable-content"> 336 ${when( 337 messageCard, 338 () => this.messageCardTemplate(messageCard), 339 () => html`${this.deviceListTemplate()}` 340 )} 341 </div> 342 </div> 343 `; 344 } 345 346 onSearchQuery(e) { 347 this.controller.searchQuery = e.detail.query; 348 this.requestUpdate(); 349 } 350 } 351 352 customElements.define("sidebar-syncedtabs", SyncedTabsInSidebar);