tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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);