edit-profile-card.mjs (15511B)
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 import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; 6 import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs"; 7 8 /** 9 * Like DeferredTask but usable from content. 10 */ 11 class Debounce { 12 timeout = null; 13 #callback = null; 14 #timeoutId = null; 15 16 constructor(callback, timeout) { 17 this.#callback = callback; 18 this.timeout = timeout; 19 this.#timeoutId = null; 20 } 21 22 #trigger() { 23 this.#timeoutId = null; 24 this.#callback(); 25 } 26 27 arm() { 28 this.disarm(); 29 this.#timeoutId = setTimeout(() => this.#trigger(), this.timeout); 30 } 31 32 disarm() { 33 if (this.isArmed) { 34 clearTimeout(this.#timeoutId); 35 this.#timeoutId = null; 36 } 37 } 38 39 finalize() { 40 if (this.isArmed) { 41 this.disarm(); 42 this.#callback(); 43 } 44 } 45 46 get isArmed() { 47 return this.#timeoutId !== null; 48 } 49 } 50 51 // eslint-disable-next-line import/no-unassigned-import 52 import "chrome://global/content/elements/moz-card.mjs"; 53 // eslint-disable-next-line import/no-unassigned-import 54 import "chrome://global/content/elements/moz-button.mjs"; 55 // eslint-disable-next-line import/no-unassigned-import 56 import "chrome://global/content/elements/moz-button-group.mjs"; 57 // eslint-disable-next-line import/no-unassigned-import 58 import "chrome://global/content/elements/moz-visual-picker.mjs"; 59 // eslint-disable-next-line import/no-unassigned-import 60 import "chrome://browser/content/profiles/avatar.mjs"; 61 // eslint-disable-next-line import/no-unassigned-import 62 import "chrome://browser/content/profiles/profiles-theme-card.mjs"; 63 // eslint-disable-next-line import/no-unassigned-import 64 import "chrome://browser/content/profiles/profile-avatar-selector.mjs"; 65 // eslint-disable-next-line import/no-unassigned-import 66 import "chrome://global/content/elements/moz-toggle.mjs"; 67 68 const SAVE_NAME_TIMEOUT = 2000; 69 const SAVED_MESSAGE_TIMEOUT = 5000; 70 71 /** 72 * Element used for updating a profile's name, theme, and avatar. 73 */ 74 export class EditProfileCard extends MozLitElement { 75 static properties = { 76 hasDesktopShortcut: { type: Boolean }, 77 profile: { type: Object }, 78 profiles: { type: Array }, 79 themes: { type: Array }, 80 isCopy: { type: Boolean, reflect: true }, 81 }; 82 83 static queries = { 84 avatarSelector: "profile-avatar-selector", 85 avatarSelectorLink: "#profile-avatar-selector-link", 86 deleteButton: "#delete-button", 87 doneButton: "#done-button", 88 errorMessage: "#error-message", 89 headerAvatar: "#header-avatar", 90 moreThemesLink: "#more-themes", 91 mozCard: "moz-card", 92 nameInput: "#profile-name", 93 savedMessage: "#saved-message", 94 shortcutToggle: "#desktop-shortcut-toggle", 95 themesPicker: "#themes", 96 }; 97 98 updateNameDebouncer = null; 99 clearSavedMessageTimer = null; 100 101 get themeCards() { 102 return this.themesPicker.childElements; 103 } 104 105 constructor() { 106 super(); 107 108 this.updateNameDebouncer = new Debounce( 109 () => this.updateName(), 110 SAVE_NAME_TIMEOUT 111 ); 112 113 this.clearSavedMessageTimer = new Debounce( 114 () => this.hideSavedMessage(), 115 SAVED_MESSAGE_TIMEOUT 116 ); 117 } 118 119 connectedCallback() { 120 super.connectedCallback(); 121 122 window.addEventListener("beforeunload", this); 123 window.addEventListener("pagehide", this); 124 document.addEventListener("Profiles:CustomAvatarUpload", this); 125 document.addEventListener("Profiles:AvatarSelected", this); 126 127 this.init().then(() => (this.initialized = true)); 128 } 129 130 async init() { 131 if (this.initialized) { 132 return; 133 } 134 135 this.isCopy = document.location.hash.includes("#copiedProfileName"); 136 let fakeParams = new URLSearchParams( 137 document.location.hash.replace("#", "") 138 ); 139 this.copiedProfileName = fakeParams.get("copiedProfileName"); 140 141 let { 142 currentProfile, 143 hasDesktopShortcut, 144 isInAutomation, 145 platform, 146 profiles, 147 themes, 148 } = await RPMSendQuery("Profiles:GetEditProfileContent"); 149 150 if (isInAutomation) { 151 this.updateNameDebouncer.timeout = 50; 152 } 153 154 this.hasDesktopShortcut = hasDesktopShortcut; 155 this.platform = platform; 156 this.profiles = profiles; 157 this.setProfile(currentProfile); 158 this.themes = themes; 159 160 await this.setInitialInput(); 161 } 162 163 async setInitialInput() { 164 if (!this.isCopy) { 165 return; 166 } 167 168 await this.getUpdateComplete(); 169 170 this.nameInput.value = ""; 171 } 172 173 createAvatarURL() { 174 if (this.profile.avatarFiles?.file16) { 175 const objURL = URL.createObjectURL(this.profile.avatarFiles.file16); 176 this.profile.avatarURLs.url16 = objURL; 177 this.profile.avatarURLs.url80 = objURL; 178 } 179 } 180 181 async getUpdateComplete() { 182 const result = await super.getUpdateComplete(); 183 184 await Promise.all( 185 Array.from(this.themeCards).map(card => card.updateComplete) 186 ); 187 188 await this.mozCard.updateComplete; 189 190 return result; 191 } 192 193 setProfile(newProfile) { 194 if (this.profile?.hasCustomAvatar && this.profile?.avatarURLs.url16) { 195 URL.revokeObjectURL(this.profile.avatarURLs.url16); 196 delete this.profile.avatarURLs.url16; 197 delete this.profile.avatarURLs.url80; 198 } 199 200 if (this.profile?.favicon) { 201 URL.revokeObjectURL(this.profile.favicon); 202 } 203 204 this.profile = newProfile; 205 206 if (this.profile.hasCustomAvatar) { 207 this.createAvatarURL(); 208 } 209 210 this.setFavicon(); 211 } 212 213 setFavicon() { 214 const favicon = document.getElementById("favicon"); 215 216 if (this.profile.hasCustomAvatar) { 217 favicon.href = this.profile.avatarURLs.url16; 218 return; 219 } 220 221 const faviconBlob = new Blob([this.profile.faviconSVGText], { 222 type: "image/svg+xml", 223 }); 224 const faviconObjURL = URL.createObjectURL(faviconBlob); 225 this.profile.favicon = faviconObjURL; 226 favicon.href = faviconObjURL; 227 } 228 229 handleEvent(event) { 230 switch (event.type) { 231 case "beforeunload": { 232 let newName = this.nameInput.value.trim(); 233 if (newName === "") { 234 this.showErrorMessage("edit-profile-page-no-name"); 235 event.preventDefault(); 236 } else { 237 this.updateNameDebouncer.finalize(); 238 } 239 break; 240 } 241 case "pagehide": { 242 RPMSendAsyncMessage("Profiles:PageHide"); 243 break; 244 } 245 case "Profiles:CustomAvatarUpload": { 246 let { file } = event.detail; 247 this.updateAvatar(file); 248 break; 249 } 250 case "Profiles:AvatarSelected": { 251 let { avatar } = event.detail; 252 this.updateAvatar(avatar); 253 break; 254 } 255 } 256 } 257 258 updated() { 259 super.updated(); 260 261 if (!this.profile) { 262 return; 263 } 264 265 let { themeFg, themeBg } = this.profile; 266 this.headerAvatar.style.fill = themeBg; 267 this.headerAvatar.style.stroke = themeFg; 268 } 269 270 updateName() { 271 this.updateNameDebouncer.disarm(); 272 this.showSavedMessage(); 273 274 let newName = this.nameInput.value.trim(); 275 if (!newName) { 276 return; 277 } 278 279 this.profile.name = newName; 280 RPMSendAsyncMessage("Profiles:UpdateProfileName", this.profile); 281 } 282 283 async updateTheme(newThemeId) { 284 if (newThemeId === this.profile.themeId) { 285 return; 286 } 287 288 let updatedProfile = await RPMSendQuery( 289 "Profiles:UpdateProfileTheme", 290 newThemeId 291 ); 292 293 this.setProfile(updatedProfile); 294 this.requestUpdate(); 295 } 296 297 async updateAvatar(newAvatar) { 298 if (newAvatar === this.profile.avatar) { 299 return; 300 } 301 302 let updatedProfile = await RPMSendQuery("Profiles:UpdateProfileAvatar", { 303 avatarOrFile: newAvatar, 304 }); 305 306 this.setProfile(updatedProfile); 307 this.requestUpdate(); 308 } 309 310 isDuplicateName(newName) { 311 return !!this.profiles.find( 312 p => p.id !== this.profile.id && p.name === newName 313 ); 314 } 315 316 async handleInputEvent() { 317 this.hideSavedMessage(); 318 let newName = this.nameInput.value.trim(); 319 if (newName === "") { 320 this.showErrorMessage("edit-profile-page-no-name"); 321 } else if (this.isDuplicateName(newName)) { 322 this.showErrorMessage("edit-profile-page-duplicate-name"); 323 } else { 324 this.hideErrorMessage(); 325 this.updateNameDebouncer.arm(); 326 } 327 } 328 329 showErrorMessage(l10nId) { 330 this.updateNameDebouncer.disarm(); 331 document.l10n.setAttributes(this.errorMessage, l10nId); 332 this.errorMessage.parentElement.hidden = false; 333 this.nameInput.setCustomValidity("invalid"); 334 } 335 336 hideErrorMessage() { 337 this.errorMessage.parentElement.hidden = true; 338 this.nameInput.setCustomValidity(""); 339 } 340 341 showSavedMessage() { 342 this.savedMessage.parentElement.hidden = false; 343 this.clearSavedMessageTimer.arm(); 344 } 345 346 hideSavedMessage() { 347 this.savedMessage.parentElement.hidden = true; 348 this.clearSavedMessageTimer.disarm(); 349 } 350 351 headerTemplate() { 352 if (this.isCopy) { 353 return html`<div> 354 <h1 355 data-l10n-id="copied-profile-page-header" 356 data-l10n-args=${JSON.stringify({ 357 profilename: this.copiedProfileName, 358 })} 359 ></h1> 360 <p data-l10n-id="copied-profile-page-header-description"></p> 361 </div>`; 362 } 363 364 return html`<h1 365 id="profile-header" 366 data-l10n-id="edit-profile-page-header" 367 ></h1>`; 368 } 369 370 nameInputTemplate() { 371 return html`<input 372 type="text" 373 id="profile-name" 374 size="64" 375 aria-errormessage="error-message" 376 .value=${this.profile.name} 377 @input=${this.handleInputEvent} 378 />`; 379 } 380 381 profilesNameTemplate() { 382 return html`<div id="profile-name-area"> 383 <label 384 data-l10n-id="edit-profile-page-profile-name-label" 385 for="profile-name" 386 ></label> 387 ${this.nameInputTemplate()} 388 <div class="message-parent"> 389 <span class="message" hidden 390 ><img 391 class="message-icon" 392 id="error-icon" 393 src="chrome://global/skin/icons/info.svg" 394 /> 395 <span id="error-message" role="alert"></span> 396 </span> 397 <span class="message" hidden 398 ><img 399 class="message-icon" 400 id="saved-icon" 401 src="chrome://global/skin/icons/check-filled.svg" 402 /> 403 <span 404 id="saved-message" 405 data-l10n-id="edit-profile-page-profile-saved" 406 ></span> 407 </span> 408 </div> 409 </div>`; 410 } 411 412 themesTemplate() { 413 if (!this.themes) { 414 return null; 415 } 416 417 return html`<moz-visual-picker 418 type="listbox" 419 id="themes" 420 value=${this.profile.themeId} 421 data-l10n-id="edit-profile-page-theme-header-2" 422 headingLevel="2" 423 name="theme" 424 @change=${this.handleThemeChange} 425 > 426 ${this.themes.map( 427 t => 428 html`<moz-visual-picker-item 429 class="theme-item" 430 l10nId=${ifDefined(t.dataL10nId)} 431 name=${ifDefined(t.name)} 432 value=${t.id} 433 > 434 <profiles-theme-card 435 aria-hidden="true" 436 .theme=${t} 437 value=${t.id} 438 ></profiles-theme-card> 439 </moz-visual-picker-item>` 440 )} 441 </moz-visual-picker>`; 442 } 443 444 desktopShortcutTemplate() { 445 if (this.platform !== "win") { 446 return null; 447 } 448 449 return html`<div id="desktop-shortcut-section"> 450 <label 451 for="desktop-shortcut-toggle" 452 data-l10n-id="edit-profile-page-desktop-shortcut-header" 453 ></label> 454 <moz-toggle 455 id="desktop-shortcut-toggle" 456 data-l10n-id="edit-profile-page-desktop-shortcut-toggle" 457 ?pressed=${this.hasDesktopShortcut} 458 @click=${this.handleDesktopShortcutToggle} 459 ></moz-toggle> 460 </div>`; 461 } 462 463 async handleDesktopShortcutToggle(event) { 464 event.preventDefault(); 465 let { hasDesktopShortcut } = await RPMSendQuery( 466 "Profiles:SetDesktopShortcut", 467 { 468 shouldEnable: event.target.pressed, 469 } 470 ); 471 this.shortcutToggle.pressed = hasDesktopShortcut; 472 this.requestUpdate(); 473 } 474 475 handleThemeChange() { 476 this.updateTheme(this.themesPicker.value); 477 } 478 479 headerAvatarTemplate() { 480 return html`<div class="avatar-header-content"> 481 <img 482 id="header-avatar" 483 data-l10n-id=${this.profile.avatarL10nId} 484 src=${this.profile.avatarURLs.url80} 485 /> 486 <a 487 id="profile-avatar-selector-link" 488 tabindex="0" 489 @click=${this.toggleAvatarSelectorCard} 490 @keydown=${this.handleAvatarSelectorKeyDown} 491 data-l10n-id="edit-profile-page-avatar-selector-opener-link" 492 ></a> 493 <div class="avatar-selector-parent"> 494 <profile-avatar-selector 495 hidden 496 value=${this.profile.avatar} 497 ></profile-avatar-selector> 498 </div> 499 </div>`; 500 } 501 502 toggleAvatarSelectorCard(event) { 503 event.stopPropagation(); 504 this.avatarSelector.toggleHidden(); 505 } 506 507 handleAvatarSelectorKeyDown(event) { 508 if (event.code === "Enter" || event.code === "Space") { 509 event.preventDefault(); 510 this.toggleAvatarSelectorCard(event); 511 } 512 } 513 514 onDeleteClick() { 515 window.removeEventListener("beforeunload", this); 516 RPMSendAsyncMessage("Profiles:OpenDeletePage"); 517 } 518 519 onDoneClick() { 520 let newName = this.nameInput.value.trim(); 521 if (newName === "") { 522 this.showErrorMessage("edit-profile-page-no-name"); 523 } else if (this.isDuplicateName(newName)) { 524 this.showErrorMessage("edit-profile-page-duplicate-name"); 525 } else { 526 this.updateNameDebouncer.finalize(); 527 // Remove the pagehide listener early to prevent double-counting the 528 // profiles.existing.closed Glean event. 529 window.removeEventListener("pagehide", this); 530 RPMSendAsyncMessage("Profiles:CloseProfileTab"); 531 } 532 } 533 534 onMoreThemesClick() { 535 // Include the starting URI because the page will navigate before the 536 // event is asynchronously handled by Glean code in the parent actor. 537 RPMSendAsyncMessage("Profiles:MoreThemes", { 538 source: window.location.href, 539 }); 540 } 541 542 buttonsTemplate() { 543 return html`<moz-button 544 id="delete-button" 545 data-l10n-id="edit-profile-page-delete-button" 546 @click=${this.onDeleteClick} 547 ></moz-button> 548 <moz-button 549 id="done-button" 550 data-l10n-id="new-profile-page-done-button" 551 @click=${this.onDoneClick} 552 type="primary" 553 ></moz-button>`; 554 } 555 556 render() { 557 if (!this.profile) { 558 return null; 559 } 560 561 return html`<link 562 rel="stylesheet" 563 href="chrome://browser/content/profiles/edit-profile-card.css" 564 /> 565 <link 566 rel="stylesheet" 567 href="chrome://global/skin/in-content/common.css" 568 /> 569 <moz-card 570 ><div id="edit-profile-card" aria-labelledby="profile-header"> 571 ${this.headerAvatarTemplate()} 572 <div id="profile-content"> 573 ${this.headerTemplate()}${this.profilesNameTemplate()} 574 ${this.themesTemplate()} ${this.desktopShortcutTemplate()} 575 576 <a 577 id="more-themes" 578 href="https://addons.mozilla.org/firefox/themes/" 579 target="_blank" 580 @click=${this.onMoreThemesClick} 581 data-l10n-id="edit-profile-page-explore-themes" 582 ></a> 583 584 <moz-button-group>${this.buttonsTemplate()}</moz-button-group> 585 </div> 586 </div></moz-card 587 >`; 588 } 589 } 590 591 customElements.define("edit-profile-card", EditProfileCard);