tor-browser

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

link-preview-card.mjs (18063B)


      1 /**
      2 * This Source Code Form is subject to the terms of the Mozilla Public
      3 * License, v. 2.0. If a copy of the MPL was not distributed with this
      4 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
      5 */
      6 
      7 import {
      8  createRef,
      9  html,
     10  ref,
     11 } from "chrome://global/content/vendor/lit.all.mjs";
     12 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
     13 
     14 const lazy = {};
     15 ChromeUtils.defineESModuleGetters(lazy, {
     16  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
     17 });
     18 
     19 ChromeUtils.defineLazyGetter(
     20  lazy,
     21  "numberFormat",
     22  () => new Services.intl.NumberFormat()
     23 );
     24 
     25 ChromeUtils.defineLazyGetter(
     26  lazy,
     27  "pluralRules",
     28  () => new Services.intl.PluralRules()
     29 );
     30 
     31 ChromeUtils.importESModule(
     32  "chrome://browser/content/genai/content/model-optin.mjs",
     33  {
     34    global: "current",
     35  }
     36 );
     37 
     38 window.MozXULElement.insertFTLIfNeeded("browser/genai.ftl");
     39 
     40 /**
     41 * Class representing a link preview element.
     42 *
     43 * @augments MozLitElement
     44 */
     45 class LinkPreviewCard extends MozLitElement {
     46  static AI_ICON = "chrome://global/skin/icons/highlights.svg";
     47  // Number of placeholder rows to show when loading
     48  static PLACEHOLDER_COUNT = 3;
     49 
     50  static properties = {
     51    collapsed: { type: Boolean },
     52    generating: { type: Number }, // 0 = off, 1-4 = generating & dots state
     53    isMissingDataErrorState: { type: Boolean },
     54    generationError: { type: Object }, // null = no error, otherwise contains error info
     55    keyPoints: { type: Array },
     56    canShowKeyPoints: { type: Boolean },
     57    optin: { type: Boolean },
     58    pageData: { type: Object },
     59    progress: { type: Number }, // -1 = off, 0-100 = download progress
     60  };
     61 
     62  constructor() {
     63    super();
     64    this.collapsed = false;
     65    this.generationError = null;
     66    this.isMissingDataErrorState = false;
     67    this.keyPoints = [];
     68    this.canShowKeyPoints = true;
     69    this.optin = false;
     70    this.optinRef = createRef();
     71    this.firstTimeModalRef = createRef();
     72    this.progress = -1;
     73  }
     74 
     75  /**
     76   * Handles click events on the settings button.
     77   *
     78   * Prevents the default event behavior and opens Firefox's preferences
     79   * page with the link preview settings section focused.
     80   *
     81   * @param {MouseEvent} _event - The click event from the settings button.
     82   */
     83  handleSettingsClick(_event) {
     84    const win = this.ownerGlobal;
     85    win.openPreferences("general-link-preview");
     86    this.dispatchEvent(
     87      new CustomEvent("LinkPreviewCard:dismiss", {
     88        detail: "settings",
     89      })
     90    );
     91  }
     92 
     93  addKeyPoint(text) {
     94    this.keyPoints.push(text);
     95    this.requestUpdate();
     96  }
     97 
     98  /**
     99   * Handles click events on the <a> element.
    100   *
    101   * @param {MouseEvent} event - The click event.
    102   */
    103  handleLink(event) {
    104    event.preventDefault();
    105 
    106    const anchor = event.target.closest("a");
    107    const url = anchor.href;
    108 
    109    const win = this.ownerGlobal;
    110    const params = {
    111      triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
    112        {}
    113      ),
    114    };
    115 
    116    // Determine where to open the link based on the event (e.g., new tab,
    117    // current tab)
    118    const where = lazy.BrowserUtils.whereToOpenLink(event, false, true);
    119    win.openLinkIn(url, where, params);
    120 
    121    this.dispatchEvent(
    122      new CustomEvent("LinkPreviewCard:dismiss", {
    123        detail: event.target.dataset.source ?? "error",
    124      })
    125    );
    126  }
    127 
    128  /**
    129   * Handles retry request for key points generation.
    130   *
    131   * @param {MouseEvent} event - The click event.
    132   */
    133  handleRetry(event) {
    134    event.preventDefault();
    135    // Dispatch retry event to be handled by LinkPreview.sys.mjs
    136    this.dispatchEvent(new CustomEvent("LinkPreviewCard:retry"));
    137  }
    138 
    139  /**
    140   * Toggles the expanded state of the key points section
    141   *
    142   * @param {MouseEvent} _event - The click event
    143   */
    144  toggleKeyPoints(_event) {
    145    // Do not allow collapsing while a download is in progress.
    146    if (this.progress >= 0) {
    147      return;
    148    }
    149    Services.prefs.setBoolPref(
    150      "browser.ml.linkPreview.collapsed",
    151      !this.collapsed
    152    );
    153 
    154    // When expanded, if there are existing key points, we won't trigger
    155    // another generation
    156    if (!this.collapsed) {
    157      this.dispatchEvent(new CustomEvent("LinkPreviewCard:generate"));
    158    }
    159  }
    160 
    161  updated(_properties) {
    162    if (this.optinRef.value) {
    163      this.optinRef.value.headingIcon = LinkPreviewCard.AI_ICON;
    164    }
    165 
    166    if (this.firstTimeModalRef.value) {
    167      this.firstTimeModalRef.value.headingIcon = LinkPreviewCard.AI_ICON;
    168      this.firstTimeModalRef.value.iconAtEnd = true;
    169      this.firstTimeModalRef.value.footerMessageL10nId = "";
    170 
    171      if (this.progress >= 0) {
    172        this.firstTimeModalRef.value.isLoading = true;
    173        this.firstTimeModalRef.value.progressStatus = this.progress;
    174      }
    175    }
    176  }
    177 
    178  /**
    179   * Get the appropriate Fluent ID for the error message based on the error state.
    180   *
    181   * @returns {string} The Fluent ID for the error message.
    182   */
    183  get errorMessageL10nId() {
    184    if (this.isMissingDataErrorState) {
    185      return "link-preview-generation-error-missing-data-v2";
    186    } else if (this.generationError) {
    187      return "link-preview-generation-error-unexpected";
    188    }
    189    return "";
    190  }
    191 
    192  /**
    193   * Renders the error generation card for when we have a generation error.
    194   *
    195   * @returns {import('lit').TemplateResult} The error generation card HTML
    196   */
    197  renderErrorGenerationCard() {
    198    // Only show the retry link if we have a generation error that's not a memory error
    199    const showRetryLink =
    200      this.generationError &&
    201      this.generationError.name !== "NotEnoughMemoryError";
    202 
    203    return html`
    204      <div class="ai-content">
    205        <p class="og-error-message-container">
    206          <span
    207            class="og-error-message"
    208            data-l10n-id=${this.errorMessageL10nId}
    209          ></span>
    210          ${showRetryLink
    211            ? html`
    212                <span class="retry-link">
    213                  <a
    214                    href="#"
    215                    @click=${this.handleRetry}
    216                    data-l10n-id="link-preview-generation-retry"
    217                  ></a>
    218                </span>
    219              `
    220            : ""}
    221        </p>
    222      </div>
    223    `;
    224  }
    225 
    226  /**
    227   * Renders a placeholder generation card for the opt-in mode,
    228   * showing only loading animations without real content.
    229   *
    230   * @returns {import('lit').TemplateResult} The opt-in placeholder card HTML
    231   */
    232  renderOptInPlaceholderCard() {
    233    return html`
    234      <div class="ai-content">
    235        <h3
    236          class="keypoints-header"
    237          @click=${this._handleOptinDeny}
    238          tabindex="0"
    239          role="button"
    240          aria-expanded=${!this.collapsed}
    241        >
    242          <div class="chevron-icon"></div>
    243          <span data-l10n-id="link-preview-key-points-header"></span>
    244          <img
    245            class="icon"
    246            xmlns="http://www.w3.org/1999/xhtml"
    247            role="presentation"
    248            src="chrome://global/skin/icons/highlights.svg"
    249          />
    250        </h3>
    251        <div class="keypoints-content ${this.collapsed ? "hidden" : ""}">
    252          <ul class="keypoints-list">
    253            ${
    254              /* Always show 3 placeholder loading items */
    255              Array(LinkPreviewCard.PLACEHOLDER_COUNT)
    256                .fill()
    257                .map(
    258                  () =>
    259                    html` <li class="content-item loading static">
    260                      <div></div>
    261                      <div></div>
    262                      <div></div>
    263                    </li>`
    264                )
    265            }
    266          </ul>
    267          ${this.renderModelOptIn()}
    268        </div>
    269      </div>
    270    `;
    271  }
    272 
    273  /**
    274   * Renders the normal generation card for displaying key points.
    275   *
    276   * @param {string} pageUrl - URL of the page being previewed
    277   * @returns {import('lit').TemplateResult} The normal generation card HTML
    278   */
    279  renderNormalGenerationCard(pageUrl) {
    280    // Extract the links section into its own variable
    281    const linksSection = html`
    282      <p data-l10n-id="link-preview-key-points-disclaimer"></p>
    283    `;
    284 
    285    return html`
    286      <div class="ai-content">
    287        <h3
    288          class="keypoints-header"
    289          @click=${this.toggleKeyPoints}
    290          tabindex="0"
    291          role="button"
    292          aria-expanded=${!this.collapsed}
    293        >
    294          <span data-l10n-id="link-preview-key-points-header"></span>
    295          <img
    296            class="icon"
    297            xmlns="http://www.w3.org/1999/xhtml"
    298            role="presentation"
    299            src="chrome://global/skin/icons/highlights.svg"
    300          />
    301        </h3>
    302        <div class="keypoints-content ${this.collapsed ? "hidden" : ""}">
    303          <ul class="keypoints-list">
    304            ${
    305              /* All populated content items */
    306              this.keyPoints.map(
    307                item => html`<li class="content-item">${item}</li>`
    308              )
    309            }
    310            ${
    311              /* Loading placeholders with three divs each */
    312              this.generating || this.progress >= 0
    313                ? Array(
    314                    Math.max(
    315                      0,
    316                      LinkPreviewCard.PLACEHOLDER_COUNT - this.keyPoints.length
    317                    )
    318                  )
    319                    .fill()
    320                    .map(
    321                      () =>
    322                        html` <li
    323                          class="content-item loading ${this.progress >= 0
    324                            ? "static"
    325                            : ""}"
    326                        >
    327                          <div></div>
    328                          <div></div>
    329                          <div></div>
    330                        </li>`
    331                    )
    332                : []
    333            }
    334          </ul>
    335          ${!(this.generating || this.progress >= 0)
    336            ? html`
    337                <div class="visit-link-container">
    338                  <a
    339                    @click=${this.handleLink}
    340                    data-source="visit"
    341                    href=${pageUrl}
    342                    class="visit-link"
    343                  >
    344                    <span data-l10n-id="link-preview-visit-link"></span>
    345                  </a>
    346                </div>
    347              `
    348            : ""}
    349          ${this.renderModalFirstTime()}
    350          ${!(this.generating || this.progress >= 0)
    351            ? html`
    352                <hr />
    353                ${linksSection}
    354              `
    355            : ""}
    356        </div>
    357      </div>
    358    `;
    359  }
    360 
    361  /**
    362   * Renders the model opt-in component that prompts users to optin to AI features.
    363   * This component allows users to opt in or out of the link preview AI functionality
    364   * and includes a support link for more information.
    365   *
    366   * @returns {import('lit').TemplateResult} The model opt-in component HTML
    367   */
    368  renderModelOptIn() {
    369    return html`
    370      <model-optin
    371        ${ref(this.optinRef)}
    372        headingIcon=${LinkPreviewCard.AI_ICON}
    373        headingL10nId="link-preview-optin-title"
    374        iconAtEnd
    375        messageL10nId="link-preview-optin-message"
    376        @MlModelOptinConfirm=${this._handleOptinConfirm}
    377        @MlModelOptinDeny=${this._handleOptinDeny}
    378      >
    379      </model-optin>
    380    `;
    381  }
    382 
    383  /**
    384   * Renders the first-time setup modal with progress bar.
    385   * Shows a modal-style component when progress is being tracked (this.progress >= 0).
    386   *
    387   * @returns {import('lit').TemplateResult} The first-time setup modal HTML
    388   */
    389  renderModalFirstTime() {
    390    if (this.progress < 0) {
    391      return "";
    392    }
    393 
    394    return html`
    395      <model-optin
    396        ${ref(this.firstTimeModalRef)}
    397        headingL10nId="link-preview-first-time-setup-title"
    398        messageL10nId="link-preview-first-time-setup-message"
    399        progressStatus=${this.progress}
    400        @MlModelOptinCancelDownload=${this._handleCancelDownload}
    401      >
    402      </model-optin>
    403    `;
    404  }
    405 
    406  /**
    407   * Handles the user confirming the opt-in prompt for link preview.
    408   * Sets preference values to enable the feature, hides the prompt for future sessions,
    409   * and triggers a retry to generate the preview.
    410   */
    411  _handleOptinConfirm() {
    412    Services.prefs.setBoolPref("browser.ml.linkPreview.optin", true);
    413 
    414    this.dispatchEvent(new CustomEvent("LinkPreviewCard:generate"));
    415  }
    416 
    417  /**
    418   * Handles the user canceling the first-time model download.
    419   */
    420  _handleCancelDownload() {
    421    this.dispatchEvent(new CustomEvent("LinkPreviewCard:cancelDownload"));
    422  }
    423 
    424  /**
    425   * Handles the user denying the opt-in prompt for link preview.
    426   * Sets preference values to disable the feature and hides
    427   * the prompt for future sessions.
    428   */
    429  _handleOptinDeny() {
    430    Services.prefs.setBoolPref("browser.ml.linkPreview.optin", false);
    431    Services.prefs.setBoolPref("browser.ml.linkPreview.collapsed", true);
    432 
    433    Glean.genaiLinkpreview.cardAiConsent.record({ option: "cancel" });
    434  }
    435 
    436  /**
    437   * Renders the appropriate content card based on state.
    438   *
    439   * @param {string} pageUrl - URL of the page being previewed
    440   * @returns {import('lit').TemplateResult} The content card HTML
    441   */
    442  renderKeyPointsSection(pageUrl) {
    443    if (!this.canShowKeyPoints) {
    444      return "";
    445    }
    446 
    447    // Determine if there's any generation error state
    448    const isGenerationError =
    449      this.isMissingDataErrorState || this.generationError;
    450 
    451    // If we should show the opt-in prompt, show our special placeholder card
    452    if (!this.optin && !this.collapsed) {
    453      return this.renderOptInPlaceholderCard();
    454    }
    455 
    456    if (isGenerationError) {
    457      return this.renderErrorGenerationCard(pageUrl);
    458    }
    459 
    460    // Always render the ai-content, otherwise we won't have header to expand/collapse
    461    return this.renderNormalGenerationCard(pageUrl);
    462  }
    463 
    464  /**
    465   * Renders the link preview element.
    466   *
    467   * @returns {import('lit').TemplateResult} The rendered HTML template.
    468   */
    469  render() {
    470    const articleData = this.pageData?.article || {};
    471    const pageUrl = this.pageData?.url || "about:blank";
    472    const siteName =
    473      articleData.siteName || this.pageData?.urlComponents?.domain || "";
    474 
    475    const { title, description, imageUrl } = this.pageData.meta;
    476 
    477    const readingTimeMinsFast = articleData.readingTimeMinsFast || "";
    478    const readingTimeMinsSlow = articleData.readingTimeMinsSlow || "";
    479    const readingTimeMinsFastStr =
    480      lazy.numberFormat.format(readingTimeMinsFast);
    481    const readingTimeRange = lazy.numberFormat.formatRange(
    482      readingTimeMinsFast,
    483      readingTimeMinsSlow
    484    );
    485 
    486    // Check if both metadata and article text content are missing
    487    const isMissingAllContent = !description && !articleData.textContent;
    488 
    489    const filename = this.pageData?.urlComponents?.filename;
    490 
    491    // Error Link Preview card UI: A simplified version of the preview card showing only an error message
    492    // and a link to visit the URL. This is a fallback UI for cases when we don't have
    493    // enough metadata to generate a useful preview.
    494    const errorCard = html`
    495      <div class="og-card">
    496        <div class="og-card-content">
    497          <div class="og-error-content">
    498            <p
    499              class="og-error-message"
    500              data-l10n-id="link-preview-error-message-v2"
    501            ></p>
    502            <a
    503              class="og-card-title"
    504              @click=${this.handleLink}
    505              data-l10n-id="link-preview-visit-link"
    506              href=${pageUrl}
    507            ></a>
    508          </div>
    509        </div>
    510      </div>
    511    `;
    512 
    513    // Normal Link Preview card UI: Shown when we have sufficient metadata (at least title and description)
    514    // Displays rich preview information including optional elements like site name, image,
    515    // reading time, and AI-generated key points if available
    516    const normalCard = html`
    517      <div class="og-card">
    518        <div class="og-card-content">
    519          ${imageUrl.startsWith("https://")
    520            ? html` <img class="og-card-img" src=${imageUrl} alt=${title} /> `
    521            : ""}
    522          ${siteName
    523            ? html`
    524                <div class="page-info-and-card-setting-container">
    525                  <span class="site-name">${siteName}</span>
    526                </div>
    527              `
    528            : ""}
    529          <h2 class="og-card-title">
    530            <a @click=${this.handleLink} data-source="title" href=${pageUrl}
    531              >${title || filename}</a
    532            >
    533          </h2>
    534          ${description
    535            ? html`<p class="og-card-description">${description}</p>`
    536            : ""}
    537          <div class="reading-time-settings-container">
    538            ${readingTimeMinsFast && readingTimeMinsSlow
    539              ? html`
    540                  <div
    541                    class="og-card-reading-time"
    542                    data-l10n-id="link-preview-reading-time"
    543                    data-l10n-args=${JSON.stringify({
    544                      range:
    545                        readingTimeMinsFast === readingTimeMinsSlow
    546                          ? `~${readingTimeMinsFastStr}`
    547                          : `${readingTimeRange}`,
    548                      rangePlural:
    549                        readingTimeMinsFast === readingTimeMinsSlow
    550                          ? lazy.pluralRules.select(readingTimeMinsFast)
    551                          : lazy.pluralRules.selectRange(
    552                              readingTimeMinsFast,
    553                              readingTimeMinsSlow
    554                            ),
    555                    })}
    556                  ></div>
    557                `
    558              : html`<div></div>`}
    559            <moz-button
    560              type="icon ghost"
    561              iconSrc="chrome://global/skin/icons/settings.svg"
    562              data-l10n-id="link-preview-settings-button"
    563              data-l10n-attrs="title"
    564              @click=${this.handleSettingsClick}
    565            >
    566            </moz-button>
    567          </div>
    568        </div>
    569        ${this.renderKeyPointsSection(pageUrl)}
    570      </div>
    571    `;
    572 
    573    return html`
    574      <link
    575        rel="stylesheet"
    576        href="chrome://browser/content/genai/content/link-preview-card.css"
    577      />
    578      ${isMissingAllContent ? errorCard : normalCard}
    579    `;
    580  }
    581 }
    582 
    583 customElements.define("link-preview-card", LinkPreviewCard);