tor-browser

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

LinkPreview.sys.mjs (34297B)


      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 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      7 
      8 const lazy = {};
      9 ChromeUtils.defineESModuleGetters(lazy, {
     10  LinkPreviewModel:
     11    "moz-src:///browser/components/genai/LinkPreviewModel.sys.mjs",
     12  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     13  PrefUtils: "moz-src:///toolkit/modules/PrefUtils.sys.mjs",
     14  Region: "resource://gre/modules/Region.sys.mjs",
     15 });
     16 
     17 export const LABS_STATE = Object.freeze({
     18  NOT_ENROLLED: 0,
     19  ENROLLED: 1,
     20  ROLLOUT_ENDED: 2,
     21 });
     22 
     23 XPCOMUtils.defineLazyPreferenceGetter(
     24  lazy,
     25  "allowedLanguages",
     26  "browser.ml.linkPreview.allowedLanguages"
     27 );
     28 XPCOMUtils.defineLazyPreferenceGetter(
     29  lazy,
     30  "collapsed",
     31  "browser.ml.linkPreview.collapsed",
     32  null,
     33  (_pref, _old, val) => LinkPreview.onCollapsedPref(val)
     34 );
     35 XPCOMUtils.defineLazyPreferenceGetter(
     36  lazy,
     37  "enabled",
     38  "browser.ml.linkPreview.enabled",
     39  null,
     40  (_pref, _old, val) => LinkPreview.onEnabledPref(val)
     41 );
     42 XPCOMUtils.defineLazyPreferenceGetter(
     43  lazy,
     44  "ignoreMs",
     45  "browser.ml.linkPreview.ignoreMs"
     46 );
     47 XPCOMUtils.defineLazyPreferenceGetter(
     48  lazy,
     49  "labs",
     50  "browser.ml.linkPreview.labs",
     51  LABS_STATE.NOT_ENROLLED
     52 );
     53 XPCOMUtils.defineLazyPreferenceGetter(
     54  lazy,
     55  "longPress",
     56  "browser.ml.linkPreview.longPress",
     57  null,
     58  (_pref, _old, val) => LinkPreview.onLongPressPrefChange(val)
     59 );
     60 XPCOMUtils.defineLazyPreferenceGetter(
     61  lazy,
     62  "longPressMs",
     63  "browser.ml.linkPreview.longPressMs"
     64 );
     65 XPCOMUtils.defineLazyPreferenceGetter(
     66  lazy,
     67  "nimbus",
     68  "browser.ml.linkPreview.nimbus"
     69 );
     70 XPCOMUtils.defineLazyPreferenceGetter(
     71  lazy,
     72  "noKeyPointsRegions",
     73  "browser.ml.linkPreview.noKeyPointsRegions"
     74 );
     75 XPCOMUtils.defineLazyPreferenceGetter(
     76  lazy,
     77  "onboardingHoverLinkMs",
     78  "browser.ml.linkPreview.onboardingHoverLinkMs",
     79  1000
     80 );
     81 XPCOMUtils.defineLazyPreferenceGetter(
     82  lazy,
     83  "onboardingMaxShowFreq",
     84  "browser.ml.linkPreview.onboardingMaxShowFreq",
     85  0
     86 );
     87 XPCOMUtils.defineLazyPreferenceGetter(
     88  lazy,
     89  "onboardingTimes",
     90  "browser.ml.linkPreview.onboardingTimes",
     91  "", // default (when PREF_INVALID)
     92  null, // no onUpdate callback
     93  rawValue => {
     94    if (!rawValue) {
     95      return [];
     96    }
     97    return rawValue.split(",").map(Number);
     98  }
     99 );
    100 XPCOMUtils.defineLazyPreferenceGetter(
    101  lazy,
    102  "optin",
    103  "browser.ml.linkPreview.optin",
    104  null,
    105  (_pref, _old, val) => LinkPreview.onOptinPref(val)
    106 );
    107 XPCOMUtils.defineLazyPreferenceGetter(
    108  lazy,
    109  "prefetchOnEnable",
    110  "browser.ml.linkPreview.prefetchOnEnable",
    111  true
    112 );
    113 XPCOMUtils.defineLazyPreferenceGetter(
    114  lazy,
    115  "recentTypingMs",
    116  "browser.ml.linkPreview.recentTypingMs"
    117 );
    118 XPCOMUtils.defineLazyPreferenceGetter(
    119  lazy,
    120  "shift",
    121  "browser.ml.linkPreview.shift",
    122  null,
    123  (_pref, _old, val) => LinkPreview.onShiftPrefChange(val)
    124 );
    125 XPCOMUtils.defineLazyPreferenceGetter(
    126  lazy,
    127  "shiftAlt",
    128  "browser.ml.linkPreview.shiftAlt",
    129  null,
    130  (_pref, _old, val) => LinkPreview.onShiftAltPrefChange(val)
    131 );
    132 XPCOMUtils.defineLazyPreferenceGetter(
    133  lazy,
    134  "supportedLocales",
    135  "browser.ml.linkPreview.supportedLocales"
    136 );
    137 
    138 export const LinkPreview = {
    139  // Shared downloading state to use across multiple previews
    140  progress: -1, // -1 = off, 0-100 = download progress
    141  _abortController: null,
    142 
    143  cancelLongPress: null,
    144  keyboardComboActive: false,
    145  overLinkTime: 0,
    146  recentTyping: 0,
    147  _windowStates: new Map(),
    148  linkPreviewPanelId: "link-preview-panel",
    149 
    150  /**
    151   * Gets the context value for the current tab.
    152   * For about: pages, returns the URI's filePath (e.g., "home", "newtab", "preferences").
    153   * For regular webpages, returns undefined.
    154   *
    155   * @param {Window} win - The browser window context.
    156   * @returns {string|undefined} The tab context value or undefined if not an about: page.
    157   * @private
    158   */
    159  _getTabContextValue(win) {
    160    const uri = win.gBrowser.selectedBrowser.currentURI;
    161    // Check if uri exists, scheme is 'about', and filePath is a truthy string
    162    if (uri?.scheme === "about" && uri.filePath) {
    163      return uri.filePath;
    164    }
    165    return undefined;
    166  },
    167 
    168  get canShowKeyPoints() {
    169    return (
    170      this._isRegionSupported() &&
    171      this._isLocaleSupported() &&
    172      !this._isDisabledByPolicy()
    173    );
    174  },
    175 
    176  get canShowLegacy() {
    177    return lazy.labs != LABS_STATE.NOT_ENROLLED;
    178  },
    179 
    180  get canShowPreferences() {
    181    // The setting is always shown.
    182    return true;
    183  },
    184 
    185  get showOnboarding() {
    186    return false;
    187  },
    188 
    189  shouldShowContextMenu(nsContextMenu) {
    190    // In a future patch, we can further analyze the link, etc.
    191    //link url value: nsContextMenu.linkURL
    192    // For now, let’s rely on whether LinkPreview is enabled and region supported
    193    //link conditions are borrowed from context-stripOnShareLink
    194 
    195    return (
    196      this._isRegionSupported() &&
    197      lazy.enabled &&
    198      (nsContextMenu.onLink || nsContextMenu.onPlainTextLink) &&
    199      !nsContextMenu.onMailtoLink &&
    200      !nsContextMenu.onTelLink &&
    201      !nsContextMenu.onMozExtLink
    202    );
    203  },
    204 
    205  /**
    206   * Handles the preference change for the 'shift' key activation.
    207   *
    208   * @param {boolean} enabled - The new state of the shift key preference.
    209   */
    210  onShiftPrefChange(enabled) {
    211    Glean.genaiLinkpreview.prefChanged.record({ enabled, pref: "shift" });
    212    this._updateShortcutMetric();
    213  },
    214 
    215  /**
    216   * Handles the preference change for the 'shift+alt' key activation.
    217   *
    218   * @param {boolean} enabled - The new state of the shift+alt key preference.
    219   */
    220  onShiftAltPrefChange(enabled) {
    221    Glean.genaiLinkpreview.prefChanged.record({
    222      enabled,
    223      pref: "shift_alt",
    224    });
    225    this._updateShortcutMetric();
    226  },
    227 
    228  /**
    229   * Handles the preference change for the long press activation.
    230   *
    231   * @param {boolean} enabled - The new state of the long press preference.
    232   */
    233  onLongPressPrefChange(enabled) {
    234    Glean.genaiLinkpreview.prefChanged.record({
    235      enabled,
    236      pref: "long_press",
    237    });
    238    this._updateShortcutMetric();
    239  },
    240 
    241  /**
    242   * Handles the preference change for enabling/disabling Link Preview.
    243   * It adds or removes event listeners for all tracked windows based on the new preference value.
    244   *
    245   * @param {boolean} enabled - The new state of the Link Preview preference.
    246   */
    247  onEnabledPref(enabled) {
    248    const method = enabled ? "_addEventListeners" : "_removeEventListeners";
    249    for (const win of this._windowStates.keys()) {
    250      this[method](win);
    251    }
    252 
    253    // Prefetch the model when enabling by simulating a request.
    254    if (enabled && lazy.prefetchOnEnable && this._isRegionSupported()) {
    255      this.generateKeyPoints();
    256    }
    257 
    258    Glean.genaiLinkpreview.enabled.set(enabled);
    259    Glean.genaiLinkpreview.prefChanged.record({
    260      enabled,
    261      pref: "link_previews",
    262    });
    263 
    264    this.handleNimbusPrefs();
    265  },
    266 
    267  /**
    268   * Updates a property on the link-preview-card element for all window states.
    269   *
    270   * @param {string} prop - The property to update.
    271   * @param {*} value - The value to set for the property.
    272   */
    273  updateCardProperty(prop, value) {
    274    for (const [win] of this._windowStates) {
    275      const panel = win.document.getElementById(this.linkPreviewPanelId);
    276      if (!panel) {
    277        continue;
    278      }
    279 
    280      const card = panel.querySelector("link-preview-card");
    281      if (card) {
    282        card[prop] = value;
    283      }
    284    }
    285  },
    286 
    287  /**
    288   * Handles the preference change for opt-in state.
    289   * Updates all link preview cards with the new opt-in state.
    290   *
    291   * @param {boolean} optin - The new state of the opt-in preference.
    292   */
    293  onOptinPref(optin) {
    294    this.updateCardProperty("optin", optin);
    295    Glean.genaiLinkpreview.cardAiConsent.record({
    296      option: optin ? "continue" : "cancel",
    297    });
    298    Glean.genaiLinkpreview.prefChanged.record({
    299      enabled: optin,
    300      pref: "key_points",
    301    });
    302    Glean.genaiLinkpreview.aiOptin.set(optin);
    303  },
    304 
    305  /**
    306   * Handles the preference change for collapsed state.
    307   * Updates all link preview cards with the new collapsed state.
    308   *
    309   * @param {boolean} collapsed - The new state of the collapsed preference.
    310   */
    311  onCollapsedPref(collapsed) {
    312    this.updateCardProperty("collapsed", collapsed);
    313    Glean.genaiLinkpreview.keyPointsToggle.record({
    314      expand: !collapsed,
    315    });
    316    Glean.genaiLinkpreview.keyPoints.set(!collapsed);
    317 
    318    // If user collapses while a model download is in progress, stop showing the progress bar.
    319    if (collapsed && this.progress >= 0) {
    320      this.progress = -1;
    321      this.updateCardProperty("progress", this.progress);
    322    }
    323  },
    324 
    325  /**
    326   * Handles Nimbus preferences, e.g., migrating, restoring, setting.
    327   */
    328  handleNimbusPrefs() {
    329    // For those who turned on via labs with enabled setPref variable, persist
    330    // the pref and allow using shift_alt matching labs copy.
    331    if (
    332      lazy.NimbusFeatures.linkPreviews.getVariable("enabled") &&
    333      lazy.labs == LABS_STATE.NOT_ENROLLED
    334    ) {
    335      Services.prefs.setIntPref(
    336        "browser.ml.linkPreview.labs",
    337        LABS_STATE.ENROLLED
    338      );
    339      Services.prefs.setBoolPref("browser.ml.linkPreview.shiftAlt", true);
    340    }
    341    // Restore pref once if previously enabled via labs assuming rollout ended.
    342    else if (!lazy.enabled && lazy.labs == LABS_STATE.ENROLLED) {
    343      Services.prefs.setIntPref(
    344        "browser.ml.linkPreview.labs",
    345        LABS_STATE.ROLLOUT_ENDED
    346      );
    347      Services.prefs.setBoolPref("browser.ml.linkPreview.enabled", true);
    348    }
    349 
    350    // Handle nimbus feature pref setting
    351    if (this._nimbusRegistered) {
    352      return;
    353    }
    354    this._nimbusRegistered = true;
    355    const featureId = "linkPreviews";
    356    lazy.NimbusFeatures[featureId].onUpdate(() => {
    357      const enrollment = lazy.NimbusFeatures[featureId].getEnrollmentMetadata();
    358      if (!enrollment) {
    359        return;
    360      }
    361 
    362      // Set prefs on any branch if we have a new enrollment slug, otherwise
    363      // only set default branch as those only last for the session
    364      const slug = enrollment.slug + ":" + enrollment.branch;
    365      const anyBranch = slug != lazy.nimbus;
    366      const setPref = ([pref, { branch = "user", value = null }]) => {
    367        if (anyBranch || branch == "default") {
    368          lazy.PrefUtils.setPref("browser.ml.linkPreview." + pref, value, {
    369            branch,
    370          });
    371        }
    372      };
    373      setPref(["nimbus", { value: slug }]);
    374      Object.entries(
    375        lazy.NimbusFeatures[featureId].getVariable("prefs") ?? []
    376      ).forEach(setPref);
    377    });
    378  },
    379 
    380  /**
    381   * Handles startup tasks such as telemetry and adding listeners.
    382   *
    383   * @param {Window} win - The window context used to add event listeners.
    384   */
    385  init(win) {
    386    // Access getters for side effects of observing pref changes
    387    lazy.collapsed;
    388    lazy.enabled;
    389    lazy.longPress;
    390    lazy.optin;
    391    lazy.shift;
    392    lazy.shiftAlt;
    393 
    394    this._windowStates.set(win, {});
    395    if (!win.customElements.get("link-preview-card")) {
    396      win.ChromeUtils.importESModule(
    397        "chrome://browser/content/genai/content/link-preview-card.mjs",
    398        { global: "current" }
    399      );
    400    }
    401    if (!win.customElements.get("link-preview-card-onboarding")) {
    402      win.ChromeUtils.importESModule(
    403        "chrome://browser/content/genai/content/link-preview-card-onboarding.mjs",
    404        { global: "current" }
    405      );
    406    }
    407 
    408    this.handleNimbusPrefs();
    409 
    410    if (lazy.enabled) {
    411      this._addEventListeners(win);
    412    }
    413 
    414    Glean.genaiLinkpreview.aiOptin.set(lazy.optin);
    415    Glean.genaiLinkpreview.enabled.set(lazy.enabled);
    416    Glean.genaiLinkpreview.keyPoints.set(!lazy.collapsed);
    417    this._updateShortcutMetric();
    418  },
    419 
    420  /**
    421   * Teardown the Link Preview feature for the given window.
    422   * Removes event listeners from the specified window and removes it from the window map.
    423   *
    424   * @param {Window} win - The window context to uninitialize.
    425   */
    426  teardown(win) {
    427    // Remove event listeners from the specified window
    428    if (lazy.enabled) {
    429      this._removeEventListeners(win);
    430    }
    431 
    432    // Remove the panel if it exists
    433    const doc = win.document;
    434    doc.getElementById(this.linkPreviewPanelId)?.remove();
    435 
    436    // Remove the window from the map
    437    this._windowStates.delete(win);
    438  },
    439 
    440  /**
    441   * Adds all needed event listeners and updates the state.
    442   *
    443   * @param {Window} win - The window to which event listeners are added.
    444   */
    445  _addEventListeners(win) {
    446    win.addEventListener("OverLink", this, true);
    447    win.addEventListener("keydown", this, true);
    448    win.addEventListener("keyup", this, true);
    449    win.addEventListener("mousedown", this, true);
    450  },
    451 
    452  /**
    453   * Removes all event listeners and updates the state.
    454   *
    455   * @param {Window} win - The window from which event listeners are removed.
    456   */
    457  _removeEventListeners(win) {
    458    win.removeEventListener("OverLink", this, true);
    459    win.removeEventListener("keydown", this, true);
    460    win.removeEventListener("keyup", this, true);
    461    win.removeEventListener("mousedown", this, true);
    462 
    463    // Long press might have added listeners to this window.
    464    this.cancelLongPress?.();
    465  },
    466 
    467  /**
    468   * Handles keyboard events ("keydown" and "keyup") for the Link Preview feature.
    469   * Adjusts the state of keyboardComboActive based on modifier keys.
    470   *
    471   * @param {KeyboardEvent} event - The keyboard event to be processed.
    472   */
    473  handleEvent(event) {
    474    switch (event.type) {
    475      case "keydown":
    476      case "keyup":
    477        this._onKeyEvent(event);
    478        break;
    479      case "OverLink":
    480        this._onLinkPreview(event);
    481        break;
    482      case "dragstart":
    483      case "mousedown":
    484      case "mouseup":
    485        this._onPressEvent(event);
    486        break;
    487      default:
    488        break;
    489    }
    490  },
    491 
    492  /**
    493   * Handles "keydown" and "keyup" events.
    494   *
    495   * @param {KeyboardEvent} event - The keyboard event to be processed.
    496   */
    497  _onKeyEvent(event) {
    498    const win = event.currentTarget;
    499 
    500    // Track regular typing to suppress keyboard previews.
    501    if (event.key.length == 1 || ["Enter", "Tab"].includes(event.key)) {
    502      this.recentTyping = Date.now();
    503    }
    504 
    505    // Keyboard combos requires shift and neither ctrl nor meta.
    506    this.keyboardComboActive = false;
    507    if (!event.shiftKey || event.ctrlKey || event.metaKey) {
    508      return;
    509    }
    510 
    511    // Handle shift without alt if preference is set.
    512    if (!event.altKey && lazy.shift) {
    513      this.keyboardComboActive = "shift";
    514    }
    515    // Handle shift with alt if preference is set.
    516    else if (event.altKey && lazy.shiftAlt) {
    517      this.keyboardComboActive = "shift_alt";
    518    }
    519    // New presses or releases can result in desired combo for previewing.
    520    this._maybeLinkPreview(win);
    521  },
    522 
    523  /**
    524   * Handles "OverLink" events.
    525   * Stores the hovered link URL in the per-window state object and processes the
    526   * link preview if the keyboard combination is active.
    527   *
    528   * @param {CustomEvent} event - The event object containing details about the link preview.
    529   */
    530  _onLinkPreview(event) {
    531    const win = event.currentTarget;
    532    const url = event.detail.url;
    533 
    534    // Store the current overLink in the per-window state object filtering out
    535    // links common for dynamic single page apps.
    536    const stateObject = this._windowStates.get(win);
    537    stateObject.overLink =
    538      url.endsWith("#") || url.startsWith("javascript:") ? "" : url;
    539    this.overLinkTime = Date.now();
    540 
    541    // If the keyboard combo is active, always check for link preview
    542    // regardless of whether it's the same URL.
    543    if (this.keyboardComboActive) {
    544      this._maybeLinkPreview(win);
    545    } else if (this.showOnboarding) {
    546      this._maybeOnboard(win, url, stateObject);
    547    }
    548  },
    549 
    550  _maybeOnboard(win, url, stateObject) {
    551    if (!url) {
    552      return;
    553    }
    554 
    555    const panel = win.document.getElementById(this.linkPreviewPanelId);
    556    const isPanelOpen = panel && panel.state !== "closed";
    557 
    558    // If panel is open or it's the same URL as last hover, don't start
    559    // hover-based onboarding timer.
    560    if (isPanelOpen || url === stateObject.lastHoveredUrl) {
    561      return;
    562    }
    563 
    564    // Clear any existing timer when moving to a new link
    565    if (stateObject.hoverTimerId) {
    566      win.clearTimeout(stateObject.hoverTimerId);
    567      stateObject.hoverTimerId = null;
    568    }
    569 
    570    // Update last hovered URL
    571    stateObject.lastHoveredUrl = url;
    572    stateObject.hoverTimerId = win.setTimeout(() => {
    573      // Only show if we're still hovering the same URL
    574      if (stateObject.overLink === url) {
    575        this.renderOnboardingPanel(win, url);
    576      }
    577      stateObject.lastHoveredUrl = "";
    578      stateObject.hoverTimerId = null;
    579    }, lazy.onboardingHoverLinkMs);
    580  },
    581 
    582  /**
    583   * Renders the onboarding panel for link preview.
    584   * Updates onboardingTimes and renders onboarding card
    585   *
    586   * @param {Window} win - The browser window context.
    587   * @param {string} url - The URL of the link to be previewed.
    588   */
    589  async renderOnboardingPanel(win, url) {
    590    // Short-circuit if onboarding is no longer eligible - prevents race condition
    591    // where onboarding might start rendering after showOnboarding status has changed
    592    if (!this.showOnboarding) {
    593      return;
    594    }
    595 
    596    // Append the current time to onboarding times.
    597    Services.prefs.setStringPref("browser.ml.linkPreview.onboardingTimes", [
    598      ...lazy.onboardingTimes,
    599      Date.now(),
    600    ]);
    601 
    602    const doc = win.document;
    603    const onboardingCard = doc.createElement("link-preview-card-onboarding");
    604    onboardingCard.style.width = "100%";
    605 
    606    // Telemetry for onboarding card view
    607    Glean.genaiLinkpreview.onboardingCard.record({
    608      action: "view",
    609      type: onboardingCard.onboardingType,
    610    });
    611 
    612    // Now show the preview as an "onboarding" source
    613    const panel = this.initOrResetPreviewPanel(win, "onboarding");
    614    panel.onboardingType = onboardingCard.onboardingType;
    615 
    616    onboardingCard.addEventListener(
    617      "LinkPreviewCard:onboardingComplete",
    618      () => {
    619        Glean.genaiLinkpreview.onboardingCard.record({
    620          action: "try_it_now",
    621          type: onboardingCard.onboardingType,
    622        });
    623        this.renderLinkPreviewPanel(win, url, "onboarding");
    624      }
    625    );
    626    onboardingCard.addEventListener("LinkPreviewCard:onboardingClose", () => {
    627      panel.hidePopup();
    628    });
    629 
    630    panel.append(onboardingCard);
    631    panel.openPopupNearMouse();
    632  },
    633 
    634  /**
    635   * Initializes a new link preview panel or resets an existing one.
    636   * Ensures the panel is ready to display content.
    637   *
    638   * @param {Window} win - The browser window context.
    639   * @param {string} cardType - The trigger source for the panel initialization
    640   * @returns {Panel} The initialized or reset panel element.
    641   */
    642  initOrResetPreviewPanel(win, cardType) {
    643    const doc = win.document;
    644    let panel = doc.getElementById(this.linkPreviewPanelId);
    645 
    646    // If it already exists, hide any open popup and clear out old content.
    647    if (panel) {
    648      // Transitioning from onboarding reuses the panel without hiding.
    649      if (panel.cardType == "linkpreview") {
    650        panel.hidePopup();
    651      }
    652      panel.replaceChildren();
    653    } else {
    654      panel = doc
    655        .getElementById("mainPopupSet")
    656        .appendChild(doc.createXULElement("panel"));
    657      panel.className = "panel-no-padding";
    658      panel.id = this.linkPreviewPanelId;
    659      panel.setAttribute("noautofocus", true);
    660      panel.setAttribute("type", "arrow");
    661      panel.style.width = "362px";
    662      panel.style.setProperty("--og-padding", "var(--space-xlarge)");
    663      // Match the radius of the image extended out by the padding.
    664      panel.style.setProperty(
    665        "--panel-border-radius",
    666        "calc(var(--border-radius-small) + var(--og-padding))"
    667      );
    668 
    669      const openPopup = () => {
    670        const { _x: x, _y: y } = win.MousePosTracker;
    671        // Open near the mouse offsetting so link in the card can be clicked.
    672        panel.openPopup(doc.documentElement, "overlap", x - 20, y - 160);
    673        panel.openTime = Date.now();
    674      };
    675      panel.openPopupNearMouse = openPopup;
    676 
    677      // Add a single, unified popuphidden listener once on panel init. This
    678      // listener will check panel.cardType to determine the correct Glean call.
    679      panel.addEventListener("popuphidden", () => {
    680        if (panel.cardType === "onboarding") {
    681          Glean.genaiLinkpreview.onboardingCard.record({
    682            action: "close",
    683            type: panel.onboardingType,
    684          });
    685        } else if (panel.cardType === "linkpreview") {
    686          const tabValue = this._getTabContextValue(win);
    687          Glean.genaiLinkpreview.cardClose.record({
    688            duration: Date.now() - panel.openTime,
    689            tab: tabValue,
    690          });
    691        }
    692      });
    693    }
    694    panel.cardType = cardType;
    695    return panel;
    696  },
    697 
    698  /**
    699   * Handles long press events.
    700   *
    701   * @param {MouseEvent} event - The mouse related events to be processed.
    702   */
    703  _onPressEvent(event) {
    704    if (!lazy.longPress) {
    705      return;
    706    }
    707 
    708    // Check for the start of a long unmodified primary button press on a link.
    709    const win = event.currentTarget;
    710    const stateObject = this._windowStates.get(win);
    711    if (
    712      event.type == "mousedown" &&
    713      !event.button &&
    714      !event.altKey &&
    715      !event.ctrlKey &&
    716      !event.metaKey &&
    717      !event.shiftKey &&
    718      stateObject.overLink
    719    ) {
    720      // Detect events to cancel the long press.
    721      win.addEventListener("dragstart", this, true);
    722      win.addEventListener("mouseup", this, true);
    723 
    724      // Show preview after a delay if not cancelled.
    725      const timer = win.setTimeout(() => {
    726        this.cancelLongPress();
    727        this.renderLinkPreviewPanel(win, stateObject.overLink, "long_press");
    728      }, lazy.longPressMs);
    729 
    730      // Provide a way to clean up.
    731      this.cancelLongPress = () => {
    732        win.clearTimeout(timer);
    733        win.removeEventListener("dragstart", this, true);
    734        win.removeEventListener("mouseup", this, true);
    735        this.cancelLongPress = null;
    736      };
    737    } else {
    738      this.cancelLongPress?.();
    739    }
    740  },
    741 
    742  /**
    743   * Checks if the user's region is supported for key points generation.
    744   *
    745   * @returns {boolean} True if the region is supported, false otherwise.
    746   */
    747  _isRegionSupported() {
    748    const disallowedRegions = lazy.noKeyPointsRegions
    749      .split(",")
    750      .map(region => region.trim().toUpperCase());
    751 
    752    const userRegion = lazy.Region.home?.toUpperCase();
    753    return !disallowedRegions.includes(userRegion);
    754  },
    755 
    756  /**
    757   * Checks if the user's locale is supported for key points generation.
    758   *
    759   * @returns {boolean} True if the locale is supported, false otherwise.
    760   */
    761  _isLocaleSupported() {
    762    const supportedLocales = lazy.supportedLocales
    763      .split(",")
    764      .map(locale => locale.trim().toLowerCase());
    765 
    766    const userLocale = Services.locale.appLocaleAsBCP47.toLowerCase();
    767    return supportedLocales.some(locale => userLocale.startsWith(locale));
    768  },
    769 
    770  /**
    771   * Checks if key points generation is disabled by policy.
    772   *
    773   * @returns {boolean} True if disabled by policy, false otherwise.
    774   */
    775  _isDisabledByPolicy() {
    776    return (
    777      !lazy.optin && Services.prefs.prefIsLocked("browser.ml.linkPreview.optin")
    778    );
    779  },
    780 
    781  /**
    782   * Creates an Open Graph (OG) card using meta information from the page.
    783   *
    784   * @param {Document} doc - The document object where the OG card will be
    785   * created.
    786   * @param {object} pageData - An object containing page data, including meta
    787   * tags and article information.
    788   * @param {object} [pageData.article] - Optional article-specific data.
    789   * @param {object} [pageData.metaInfo] - Optional meta tag key-value pairs.
    790   * @returns {Element} A DOM element representing the OG card.
    791   */
    792  createOGCard(doc, pageData) {
    793    const ogCard = doc.createElement("link-preview-card");
    794    ogCard.style.width = "100%";
    795    ogCard.pageData = pageData;
    796 
    797    ogCard.addEventListener("LinkPreviewCard:cancelDownload", () => {
    798      this._abortController?.abort();
    799      Services.prefs.setBoolPref("browser.ml.linkPreview.collapsed", true);
    800    });
    801 
    802    ogCard.optin = lazy.optin;
    803    ogCard.collapsed = lazy.collapsed;
    804    ogCard.canShowKeyPoints = this.canShowKeyPoints;
    805 
    806    // Reflect the shared download progress to this preview.
    807    const updateProgress = () => {
    808      ogCard.progress = this.progress;
    809      // If we are still downloading, update the progress again.
    810      if (this.progress >= 0) {
    811        doc.ownerGlobal.setTimeout(
    812          () => ogCard.isConnected && updateProgress(),
    813          250
    814        );
    815      }
    816    };
    817    updateProgress();
    818    // Generate key points if we have content, language and configured for any
    819    // language or restricted, and if key points can be shown.
    820    if (
    821      this.canShowKeyPoints &&
    822      pageData.article.textContent &&
    823      pageData.article.detectedLanguage &&
    824      (!lazy.allowedLanguages ||
    825        lazy.allowedLanguages
    826          .split(",")
    827          .includes(pageData.article.detectedLanguage))
    828    ) {
    829      this.generateKeyPoints(ogCard);
    830    } else {
    831      ogCard.isMissingDataErrorState = true;
    832    }
    833 
    834    return ogCard;
    835  },
    836 
    837  /**
    838   * Generate AI key points for card.
    839   *
    840   * @param {LinkPreviewCard} ogCard to add key points
    841   * @param {boolean} _retry Indicates whether to retry the operation.
    842   */
    843  async generateKeyPoints(ogCard, _retry = false) {
    844    // Prevent keypoints if user not opt-in to link preview or user is set
    845    // keypoints to be collapsed.
    846    if (!lazy.optin || lazy.collapsed) {
    847      return;
    848    }
    849    this._abortController = new AbortController();
    850 
    851    // Support prefetching without a card by mocking expected properties.
    852    let outcome = ogCard ? "success" : "prefetch";
    853    if (!ogCard) {
    854      ogCard = { addKeyPoint() {}, isConnected: true, keyPoints: [] };
    855    }
    856 
    857    const startTime = Date.now();
    858    ogCard.generating = true;
    859 
    860    // Ensure sequential AI processing to reduce memory usage by passing our
    861    // promise to the next request before waiting on the previous.
    862    const previous = this.lastRequest;
    863    const { promise, resolve } = Promise.withResolvers();
    864    this.lastRequest = promise;
    865    await previous;
    866    const delay = Date.now() - startTime;
    867 
    868    // No need to generate if already removed.
    869    if (!ogCard.isConnected) {
    870      resolve();
    871      Glean.genaiLinkpreview.generate.record({
    872        delay,
    873        outcome: "removed",
    874      });
    875      return;
    876    }
    877 
    878    let download, latency;
    879    try {
    880      await lazy.LinkPreviewModel.generateTextAI(
    881        ogCard.pageData?.article.textContent ?? "",
    882        {
    883          abortSignal: this._abortController.signal,
    884          onDownload: (downloading, percentage) => {
    885            // Initial percentage is NaN, so set to 0.
    886            percentage = isNaN(percentage) ? 0 : percentage;
    887            // Use the percentage while downloading, otherwise disable with -1.
    888            this.progress = downloading ? percentage : -1;
    889            ogCard.progress = this.progress;
    890            download = Date.now() - startTime;
    891          },
    892          onError: error => {
    893            if (
    894              error.name === "AbortError" ||
    895              error.message?.includes("AbortError")
    896            ) {
    897              // This is an expected error when the user cancels the download.
    898              // We don't need to show an error state.
    899              outcome = "aborted";
    900              this.lastRequest = Promise.resolve();
    901              return;
    902            }
    903            console.error(error);
    904            outcome = error;
    905            ogCard.generationError = error;
    906          },
    907          onText: text => {
    908            // Clear waiting in case a different generate handled download.
    909            ogCard.showWait = false;
    910            ogCard.addKeyPoint(text);
    911            latency = latency ?? Date.now() - startTime;
    912          },
    913        }
    914      );
    915    } finally {
    916      resolve();
    917      ogCard.generating = false;
    918      Glean.genaiLinkpreview.generate.record({
    919        delay,
    920        download,
    921        latency,
    922        outcome,
    923        sentences: ogCard.keyPoints.length,
    924        time: Date.now() - startTime,
    925      });
    926    }
    927  },
    928 
    929  /**
    930   * Handles key points generation requests from different user actions.
    931   * This is a shared handler for both retry and initial generation events.
    932   * Resets error states and triggers key points generation.
    933   *
    934   * @param {LinkPreviewCard} ogCard - The card element to generate key points for
    935   * @private
    936   */
    937  _handleKeyPointsGenerationEvent(ogCard) {
    938    // Reset error states
    939    ogCard.isMissingDataErrorState = false;
    940    ogCard.isGenerationErrorState = false;
    941 
    942    this.generateKeyPoints(ogCard, true);
    943  },
    944 
    945  /**
    946   * Renders the link preview panel at the specified coordinates.
    947   *
    948   * @param {Window} win - The browser window context.
    949   * @param {string} url - The URL of the link to be previewed.
    950   * @param {string} source - Optional trigging behavior.
    951   */
    952  async renderLinkPreviewPanel(win, url, source = "shortcut") {
    953    // If link preview is used once not via onboarding, stop onboarding.
    954    if (source !== "onboarding") {
    955      const maxFreq = lazy.onboardingMaxShowFreq;
    956      // Fill the times array up to maxFreq with an array of 0 timestamps.
    957      Services.prefs.setStringPref(
    958        "browser.ml.linkPreview.onboardingTimes",
    959        [...lazy.onboardingTimes, ...Array(maxFreq).fill("0")].slice(0, maxFreq)
    960      );
    961    }
    962 
    963    // Transition from onboarding to preview content with transparency.
    964    const doc = win.document;
    965    let panel = doc.getElementById(this.linkPreviewPanelId);
    966    if (source == "onboarding") {
    967      panel.style.setProperty("opacity", "0");
    968    }
    969 
    970    // Get tab context value for telemetry
    971    const tabValue = this._getTabContextValue(win);
    972 
    973    // Reuse or initialize panel.
    974    if (panel && panel.previewUrl == url) {
    975      if (panel.state == "closed") {
    976        panel.openPopupNearMouse();
    977        Glean.genaiLinkpreview.start.record({
    978          cached: true,
    979          source,
    980          tab: tabValue,
    981        });
    982      }
    983      return;
    984    }
    985    panel = this.initOrResetPreviewPanel(win, "linkpreview");
    986    panel.previewUrl = url;
    987 
    988    Glean.genaiLinkpreview.start.record({
    989      cached: false,
    990      source,
    991      tab: tabValue,
    992    });
    993 
    994    // TODO we want to immediately add a card as a placeholder to have UI be
    995    // more responsive while we wait on fetching page data.
    996    const browsingContext = win.browsingContext;
    997    const actor = browsingContext.currentWindowGlobal.getActor("LinkPreview");
    998    const fetchTime = Date.now();
    999    const pageData = await actor.fetchPageData(url);
   1000    // Skip updating content if we've moved on to showing something else.
   1001    const skipped = pageData.url != panel.previewUrl;
   1002    Glean.genaiLinkpreview.fetch.record({
   1003      description: !!pageData.meta.description,
   1004      image: !!pageData.meta.imageUrl,
   1005      length:
   1006        Math.round((pageData.article.textContent?.length ?? 0) * 0.01) * 100,
   1007      outcome: pageData.error?.result ?? "success",
   1008      sitename: !!pageData.article.siteName,
   1009      skipped,
   1010      tab: tabValue,
   1011      time: Date.now() - fetchTime,
   1012      title: !!pageData.meta.title,
   1013    });
   1014    if (skipped) {
   1015      return;
   1016    }
   1017 
   1018    const ogCard = this.createOGCard(doc, pageData);
   1019    panel.append(ogCard);
   1020    ogCard.addEventListener("LinkPreviewCard:dismiss", event => {
   1021      panel.hidePopup();
   1022      Glean.genaiLinkpreview.cardLink.record({
   1023        key_points: !lazy.collapsed,
   1024        source: event.detail,
   1025        tab: tabValue,
   1026      });
   1027    });
   1028 
   1029    ogCard.addEventListener("LinkPreviewCard:retry", _event => {
   1030      this._handleKeyPointsGenerationEvent(ogCard, "retry");
   1031      Glean.genaiLinkpreview.cardLink.record({
   1032        key_points: !lazy.collapsed,
   1033        source: "retry",
   1034        tab: tabValue,
   1035      });
   1036    });
   1037 
   1038    ogCard.addEventListener("LinkPreviewCard:generate", _event => {
   1039      if (ogCard.keyPoints?.length || ogCard.generating) {
   1040        return;
   1041      }
   1042      this._handleKeyPointsGenerationEvent(ogCard, "generate");
   1043    });
   1044 
   1045    // Make sure panel is visible if previously showing onboarding.
   1046    panel.style.setProperty("opacity", "1");
   1047    if (source !== "onboarding") {
   1048      panel.openPopupNearMouse();
   1049    }
   1050  },
   1051 
   1052  /**
   1053   * Determines whether to process or cancel the link preview based on the current state.
   1054   * If a URL is available and the keyboard combination is active, it processes the link preview.
   1055   * Otherwise, it cancels the link preview.
   1056   *
   1057   * @param {Window} win - The window context in which the link preview may occur.
   1058   */
   1059  _maybeLinkPreview(win) {
   1060    const stateObject = this._windowStates.get(win);
   1061    const url = stateObject.overLink;
   1062    // Render preview if we have url, keyboard combo and not recently typing.
   1063    // Ignore check intends to avoid cases where mouse happens to be over a
   1064    // link, e.g., after navigating then using an in-page keyboard shortcut or
   1065    // typing characters that require shift.
   1066    if (
   1067      url &&
   1068      this.keyboardComboActive &&
   1069      Date.now() - this.overLinkTime <= lazy.ignoreMs &&
   1070      Date.now() - this.recentTyping >= lazy.recentTypingMs
   1071    ) {
   1072      this.renderLinkPreviewPanel(win, url, this.keyboardComboActive);
   1073    }
   1074  },
   1075 
   1076  /**
   1077   * Handles the link preview context menu click using the provided URL
   1078   * and nsContextMenu, prompting the link preview panel to open.
   1079   *
   1080   * @param {string} url - The URL of the link to be previewed.
   1081   * @param {object} nsContextMenu - The context menu object containing browser information.
   1082   */
   1083  async handleContextMenuClick(url, nsContextMenu) {
   1084    let win = nsContextMenu.browser.ownerGlobal;
   1085    this.renderLinkPreviewPanel(win, url, "context");
   1086  },
   1087 
   1088  /**
   1089   * Updates the Glean metric for active shortcuts.
   1090   * This metric is a comma-separated string of active shortcut types.
   1091   *
   1092   * @private
   1093   */
   1094  _updateShortcutMetric() {
   1095    const activeShortcuts = [];
   1096    if (lazy.shift) {
   1097      activeShortcuts.push("shift");
   1098    }
   1099    if (lazy.shiftAlt) {
   1100      activeShortcuts.push("shift_alt");
   1101    }
   1102    if (lazy.longPress) {
   1103      activeShortcuts.push("long_press");
   1104    }
   1105    Glean.genaiLinkpreview.shortcut.set(activeShortcuts.join(","));
   1106  },
   1107 };