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