1
0
Fork 0
firefox/browser/components/genai/LinkPreview.sys.mjs
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

1068 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
LinkPreviewModel:
"moz-src:///browser/components/genai/LinkPreviewModel.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
Region: "resource://gre/modules/Region.sys.mjs",
});
export const LABS_STATE = Object.freeze({
NOT_ENROLLED: 0,
ENROLLED: 1,
ROLLOUT_ENDED: 2,
});
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"allowedLanguages",
"browser.ml.linkPreview.allowedLanguages"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"collapsed",
"browser.ml.linkPreview.collapsed",
null,
(_pref, _old, val) => LinkPreview.onCollapsedPref(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"enabled",
"browser.ml.linkPreview.enabled",
null,
(_pref, _old, val) => LinkPreview.onEnabledPref(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"ignoreMs",
"browser.ml.linkPreview.ignoreMs"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"labs",
"browser.ml.linkPreview.labs",
LABS_STATE.NOT_ENROLLED
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"longPress",
"browser.ml.linkPreview.longPress",
null,
(_pref, _old, val) => LinkPreview.onLongPressPrefChange(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"longPressMs",
"browser.ml.linkPreview.longPressMs"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"nimbus",
"browser.ml.linkPreview.nimbus"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"noKeyPointsRegions",
"browser.ml.linkPreview.noKeyPointsRegions"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"onboardingCooldownPeriodMs",
"browser.ml.linkPreview.onboardingCooldownPeriodMs",
7 * 24 * 60 * 60 * 1000 // Constant for onboarding reactivation cooldown period (7 days in milliseconds)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"onboardingHoverLinkMs",
"browser.ml.linkPreview.onboardingHoverLinkMs",
1000
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"onboardingMaxShowFreq",
"browser.ml.linkPreview.onboardingMaxShowFreq",
2
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"onboardingTimes",
"browser.ml.linkPreview.onboardingTimes",
"", // default (when PREF_INVALID)
null, // no onUpdate callback
rawValue => {
if (!rawValue) {
return [];
}
return rawValue.split(",").map(Number);
}
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"optin",
"browser.ml.linkPreview.optin",
null,
(_pref, _old, val) => LinkPreview.onOptinPref(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"prefetchOnEnable",
"browser.ml.linkPreview.prefetchOnEnable",
true
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"recentTypingMs",
"browser.ml.linkPreview.recentTypingMs"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"shift",
"browser.ml.linkPreview.shift",
null,
(_pref, _old, val) => LinkPreview.onShiftPrefChange(val)
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"shiftAlt",
"browser.ml.linkPreview.shiftAlt",
null,
(_pref, _old, val) => LinkPreview.onShiftAltPrefChange(val)
);
export const LinkPreview = {
// Shared downloading state to use across multiple previews
progress: -1, // -1 = off, 0-100 = download progress
cancelLongPress: null,
keyboardComboActive: false,
overLinkTime: 0,
recentTyping: 0,
_windowStates: new Map(),
linkPreviewPanelId: "link-preview-panel",
/**
* Gets the context value for the current tab.
* For about: pages, returns the URI's filePath (e.g., "home", "newtab", "preferences").
* For regular webpages, returns undefined.
*
* @param {Window} win - The browser window context.
* @returns {string|undefined} The tab context value or undefined if not an about: page.
* @private
*/
_getTabContextValue(win) {
const uri = win.gBrowser.selectedBrowser.currentURI;
// Check if uri exists, scheme is 'about', and filePath is a truthy string
if (uri?.scheme === "about" && uri.filePath) {
return uri.filePath;
}
return undefined;
},
get canShowKeyPoints() {
return this._isRegionSupported();
},
get canShowLegacy() {
return lazy.labs != LABS_STATE.NOT_ENROLLED;
},
get canShowPreferences() {
return lazy.enabled;
},
get showOnboarding() {
const timesArray = lazy.onboardingTimes;
const lastValidTime = timesArray.at(-1) || 0;
const timeSinceLastOnboarding = Date.now() - lastValidTime;
return (
timesArray.length < lazy.onboardingMaxShowFreq &&
timeSinceLastOnboarding >= lazy.onboardingCooldownPeriodMs
);
},
shouldShowContextMenu(nsContextMenu) {
// In a future patch, we can further analyze the link, etc.
//link url value: nsContextMenu.linkURL
// For now, lets rely on whether LinkPreview is enabled and region supported
//link conditions are borrowed from context-stripOnShareLink
return (
this._isRegionSupported() &&
lazy.enabled &&
(nsContextMenu.onLink || nsContextMenu.onPlainTextLink) &&
!nsContextMenu.onMailtoLink &&
!nsContextMenu.onTelLink &&
!nsContextMenu.onMozExtLink
);
},
/**
* Handles the preference change for the 'shift' key activation.
*
* @param {boolean} enabled - The new state of the shift key preference.
*/
onShiftPrefChange(enabled) {
Glean.genaiLinkpreview.prefChanged.record({ enabled, pref: "shift" });
this._updateShortcutMetric();
},
/**
* Handles the preference change for the 'shift+alt' key activation.
*
* @param {boolean} enabled - The new state of the shift+alt key preference.
*/
onShiftAltPrefChange(enabled) {
Glean.genaiLinkpreview.prefChanged.record({
enabled,
pref: "shift_alt",
});
this._updateShortcutMetric();
},
/**
* Handles the preference change for the long press activation.
*
* @param {boolean} enabled - The new state of the long press preference.
*/
onLongPressPrefChange(enabled) {
Glean.genaiLinkpreview.prefChanged.record({
enabled,
pref: "long_press",
});
this._updateShortcutMetric();
},
/**
* Handles the preference change for enabling/disabling Link Preview.
* It adds or removes event listeners for all tracked windows based on the new preference value.
*
* @param {boolean} enabled - The new state of the Link Preview preference.
*/
onEnabledPref(enabled) {
const method = enabled ? "_addEventListeners" : "_removeEventListeners";
for (const win of this._windowStates.keys()) {
this[method](win);
}
// Prefetch the model when enabling by simulating a request.
if (enabled && lazy.prefetchOnEnable && this._isRegionSupported()) {
this.generateKeyPoints();
}
Glean.genaiLinkpreview.enabled.set(enabled);
Glean.genaiLinkpreview.prefChanged.record({
enabled,
pref: "link_previews",
});
this.handleNimbusPrefs();
},
/**
* Updates a property on the link-preview-card element for all window states.
*
* @param {string} prop - The property to update.
* @param {*} value - The value to set for the property.
*/
updateCardProperty(prop, value) {
for (const [win] of this._windowStates) {
const panel = win.document.getElementById(this.linkPreviewPanelId);
if (!panel) {
continue;
}
const card = panel.querySelector("link-preview-card");
if (card) {
card[prop] = value;
}
}
},
/**
* Handles the preference change for opt-in state.
* Updates all link preview cards with the new opt-in state.
*
* @param {boolean} optin - The new state of the opt-in preference.
*/
onOptinPref(optin) {
this.updateCardProperty("optin", optin);
Glean.genaiLinkpreview.cardAiConsent.record({
option: optin ? "continue" : "cancel",
});
Glean.genaiLinkpreview.prefChanged.record({
enabled: optin,
pref: "key_points",
});
Glean.genaiLinkpreview.aiOptin.set(optin);
},
/**
* Handles the preference change for collapsed state.
* Updates all link preview cards with the new collapsed state.
*
* @param {boolean} collapsed - The new state of the collapsed preference.
*/
onCollapsedPref(collapsed) {
this.updateCardProperty("collapsed", collapsed);
Glean.genaiLinkpreview.keyPointsToggle.record({
expand: !collapsed,
});
Glean.genaiLinkpreview.keyPoints.set(!collapsed);
},
/**
* Handles Nimbus preferences, e.g., migrating, restoring, setting.
*/
handleNimbusPrefs() {
// For those who turned on via labs with enabled setPref variable, persist
// the pref and allow using shift_alt matching labs copy.
if (
lazy.NimbusFeatures.linkPreviews.getVariable("enabled") &&
lazy.labs == LABS_STATE.NOT_ENROLLED
) {
Services.prefs.setIntPref(
"browser.ml.linkPreview.labs",
LABS_STATE.ENROLLED
);
Services.prefs.setBoolPref("browser.ml.linkPreview.shiftAlt", true);
}
// Restore pref once if previously enabled via labs assuming rollout ended.
else if (!lazy.enabled && lazy.labs == LABS_STATE.ENROLLED) {
Services.prefs.setIntPref(
"browser.ml.linkPreview.labs",
LABS_STATE.ROLLOUT_ENDED
);
Services.prefs.setBoolPref("browser.ml.linkPreview.enabled", true);
}
// Handle nimbus feature pref setting
if (this._nimbusRegistered) {
return;
}
this._nimbusRegistered = true;
const featureId = "linkPreviews";
lazy.NimbusFeatures[featureId].onUpdate(() => {
const enrollment = lazy.NimbusFeatures[featureId].getEnrollmentMetadata();
if (!enrollment) {
return;
}
// Set prefs on any branch if we have a new enrollment slug, otherwise
// only set default branch as those only last for the session
const slug = enrollment.slug + ":" + enrollment.branch;
const anyBranch = slug != lazy.nimbus;
const setPref = ([pref, { branch = "user", value = null }]) => {
if (anyBranch || branch == "default") {
lazy.PrefUtils.setPref("browser.ml.linkPreview." + pref, value, {
branch,
});
}
};
setPref(["nimbus", { value: slug }]);
Object.entries(
lazy.NimbusFeatures[featureId].getVariable("prefs") ?? []
).forEach(setPref);
});
},
/**
* Handles startup tasks such as telemetry and adding listeners.
*
* @param {Window} win - The window context used to add event listeners.
*/
init(win) {
// Access getters for side effects of observing pref changes
lazy.collapsed;
lazy.enabled;
lazy.longPress;
lazy.optin;
lazy.shift;
lazy.shiftAlt;
this._windowStates.set(win, {});
if (!win.customElements.get("link-preview-card")) {
win.ChromeUtils.importESModule(
"chrome://browser/content/genai/content/link-preview-card.mjs",
{ global: "current" }
);
}
if (!win.customElements.get("link-preview-card-onboarding")) {
win.ChromeUtils.importESModule(
"chrome://browser/content/genai/content/link-preview-card-onboarding.mjs",
{ global: "current" }
);
}
this.handleNimbusPrefs();
if (lazy.enabled) {
this._addEventListeners(win);
}
Glean.genaiLinkpreview.aiOptin.set(lazy.optin);
Glean.genaiLinkpreview.enabled.set(lazy.enabled);
Glean.genaiLinkpreview.keyPoints.set(!lazy.collapsed);
this._updateShortcutMetric();
},
/**
* Teardown the Link Preview feature for the given window.
* Removes event listeners from the specified window and removes it from the window map.
*
* @param {Window} win - The window context to uninitialize.
*/
teardown(win) {
// Remove event listeners from the specified window
if (lazy.enabled) {
this._removeEventListeners(win);
}
// Remove the panel if it exists
const doc = win.document;
doc.getElementById(this.linkPreviewPanelId)?.remove();
// Remove the window from the map
this._windowStates.delete(win);
},
/**
* Adds all needed event listeners and updates the state.
*
* @param {Window} win - The window to which event listeners are added.
*/
_addEventListeners(win) {
win.addEventListener("OverLink", this, true);
win.addEventListener("keydown", this, true);
win.addEventListener("keyup", this, true);
win.addEventListener("mousedown", this, true);
},
/**
* Removes all event listeners and updates the state.
*
* @param {Window} win - The window from which event listeners are removed.
*/
_removeEventListeners(win) {
win.removeEventListener("OverLink", this, true);
win.removeEventListener("keydown", this, true);
win.removeEventListener("keyup", this, true);
win.removeEventListener("mousedown", this, true);
// Long press might have added listeners to this window.
this.cancelLongPress?.();
},
/**
* Handles keyboard events ("keydown" and "keyup") for the Link Preview feature.
* Adjusts the state of keyboardComboActive based on modifier keys.
*
* @param {KeyboardEvent} event - The keyboard event to be processed.
*/
handleEvent(event) {
switch (event.type) {
case "keydown":
case "keyup":
this._onKeyEvent(event);
break;
case "OverLink":
this._onLinkPreview(event);
break;
case "dragstart":
case "mousedown":
case "mouseup":
this._onPressEvent(event);
break;
default:
break;
}
},
/**
* Handles "keydown" and "keyup" events.
*
* @param {KeyboardEvent} event - The keyboard event to be processed.
*/
_onKeyEvent(event) {
const win = event.currentTarget;
// Track regular typing to suppress keyboard previews.
if (event.key.length == 1 || ["Enter", "Tab"].includes(event.key)) {
this.recentTyping = Date.now();
}
// Keyboard combos requires shift and neither ctrl nor meta.
this.keyboardComboActive = false;
if (!event.shiftKey || event.ctrlKey || event.metaKey) {
return;
}
// Handle shift without alt if preference is set.
if (!event.altKey && lazy.shift) {
this.keyboardComboActive = "shift";
}
// Handle shift with alt if preference is set.
else if (event.altKey && lazy.shiftAlt) {
this.keyboardComboActive = "shift_alt";
}
// New presses or releases can result in desired combo for previewing.
this._maybeLinkPreview(win);
},
/**
* Handles "OverLink" events.
* Stores the hovered link URL in the per-window state object and processes the
* link preview if the keyboard combination is active.
*
* @param {CustomEvent} event - The event object containing details about the link preview.
*/
_onLinkPreview(event) {
const win = event.currentTarget;
const url = event.detail.url;
// Store the current overLink in the per-window state object filtering out
// links common for dynamic single page apps.
const stateObject = this._windowStates.get(win);
stateObject.overLink =
url.endsWith("#") || url.startsWith("javascript:") ? "" : url;
this.overLinkTime = Date.now();
// If the keyboard combo is active, always check for link preview
// regardless of whether it's the same URL.
if (this.keyboardComboActive) {
this._maybeLinkPreview(win);
} else if (this.showOnboarding) {
this._maybeOnboard(win, url, stateObject);
}
},
_maybeOnboard(win, url, stateObject) {
if (!url) {
return;
}
const panel = win.document.getElementById(this.linkPreviewPanelId);
const isPanelOpen = panel && panel.state !== "closed";
// If panel is open or it's the same URL as last hover, don't start
// hover-based onboarding timer.
if (isPanelOpen || url === stateObject.lastHoveredUrl) {
return;
}
// Clear any existing timer when moving to a new link
if (stateObject.hoverTimerId) {
win.clearTimeout(stateObject.hoverTimerId);
stateObject.hoverTimerId = null;
}
// Update last hovered URL
stateObject.lastHoveredUrl = url;
stateObject.hoverTimerId = win.setTimeout(() => {
// Only show if we're still hovering the same URL
if (stateObject.overLink === url) {
this.renderOnboardingPanel(win, url);
}
stateObject.lastHoveredUrl = "";
stateObject.hoverTimerId = null;
}, lazy.onboardingHoverLinkMs);
},
/**
* Renders the onboarding panel for link preview.
* Updates onboardingTimes and renders onboarding card
*
* @param {Window} win - The browser window context.
* @param {string} url - The URL of the link to be previewed.
*/
async renderOnboardingPanel(win, url) {
// Short-circuit if onboarding is no longer eligible - prevents race condition
// where onboarding might start rendering after showOnboarding status has changed
if (!this.showOnboarding) {
return;
}
// Append the current time to onboarding times.
Services.prefs.setStringPref("browser.ml.linkPreview.onboardingTimes", [
...lazy.onboardingTimes,
Date.now(),
]);
const doc = win.document;
const onboardingCard = doc.createElement("link-preview-card-onboarding");
onboardingCard.style.width = "100%";
onboardingCard.onboardingType = lazy.longPress ? "longPress" : "shiftKey";
// Telemetry for onboarding card view
Glean.genaiLinkpreview.onboardingCard.record({
action: "view",
type: onboardingCard.onboardingType,
});
// Now show the preview as an "onboarding" source
const panel = this.initOrResetPreviewPanel(win, "onboarding");
panel.onboardingType = onboardingCard.onboardingType;
onboardingCard.addEventListener(
"LinkPreviewCard:onboardingComplete",
() => {
Glean.genaiLinkpreview.onboardingCard.record({
action: "try_it_now",
type: onboardingCard.onboardingType,
});
this.renderLinkPreviewPanel(win, url, "onboarding");
}
);
onboardingCard.addEventListener("LinkPreviewCard:onboardingClose", () => {
panel.hidePopup();
});
panel.append(onboardingCard);
panel.openPopupNearMouse();
},
/**
* Initializes a new link preview panel or resets an existing one.
* Ensures the panel is ready to display content.
*
* @param {Window} win - The browser window context.
* @param {string} cardType - The trigger source for the panel initialization
* @returns {Panel} The initialized or reset panel element.
*/
initOrResetPreviewPanel(win, cardType) {
const doc = win.document;
let panel = doc.getElementById(this.linkPreviewPanelId);
// If it already exists, hide any open popup and clear out old content.
if (panel) {
// Transitioning from onboarding reuses the panel without hiding.
if (panel.cardType == "linkpreview") {
panel.hidePopup();
}
panel.replaceChildren();
} else {
panel = doc
.getElementById("mainPopupSet")
.appendChild(doc.createXULElement("panel"));
panel.className = "panel-no-padding";
panel.id = this.linkPreviewPanelId;
panel.setAttribute("noautofocus", true);
panel.setAttribute("type", "arrow");
panel.style.width = "362px";
panel.style.setProperty("--og-padding", "var(--space-xlarge)");
// Match the radius of the image extended out by the padding.
panel.style.setProperty(
"--panel-border-radius",
"calc(var(--border-radius-small) + var(--og-padding))"
);
const openPopup = () => {
const { _x: x, _y: y } = win.MousePosTracker;
// Open near the mouse offsetting so link in the card can be clicked.
panel.openPopup(doc.documentElement, "overlap", x - 20, y - 160);
panel.openTime = Date.now();
};
panel.openPopupNearMouse = openPopup;
// Add a single, unified popuphidden listener once on panel init. This
// listener will check panel.cardType to determine the correct Glean call.
panel.addEventListener("popuphidden", () => {
if (panel.cardType === "onboarding") {
Glean.genaiLinkpreview.onboardingCard.record({
action: "close",
type: panel.onboardingType,
});
} else if (panel.cardType === "linkpreview") {
const tabValue = this._getTabContextValue(win);
Glean.genaiLinkpreview.cardClose.record({
duration: Date.now() - panel.openTime,
tab: tabValue,
});
}
});
}
panel.cardType = cardType;
return panel;
},
/**
* Handles long press events.
*
* @param {MouseEvent} event - The mouse related events to be processed.
*/
_onPressEvent(event) {
if (!lazy.longPress) {
return;
}
// Check for the start of a long unmodified primary button press on a link.
const win = event.currentTarget;
const stateObject = this._windowStates.get(win);
if (
event.type == "mousedown" &&
!event.button &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.shiftKey &&
stateObject.overLink
) {
// Detect events to cancel the long press.
win.addEventListener("dragstart", this, true);
win.addEventListener("mouseup", this, true);
// Show preview after a delay if not cancelled.
const timer = win.setTimeout(() => {
this.cancelLongPress();
this.renderLinkPreviewPanel(win, stateObject.overLink, "long_press");
}, lazy.longPressMs);
// Provide a way to clean up.
this.cancelLongPress = () => {
win.clearTimeout(timer);
win.removeEventListener("dragstart", this, true);
win.removeEventListener("mouseup", this, true);
this.cancelLongPress = null;
};
} else {
this.cancelLongPress?.();
}
},
/**
* Checks if the user's region is supported for key points generation.
*
* @returns {boolean} True if the region is supported, false otherwise.
*/
_isRegionSupported() {
const disallowedRegions = lazy.noKeyPointsRegions
.split(",")
.map(region => region.trim().toUpperCase());
const userRegion = lazy.Region.home?.toUpperCase();
return !disallowedRegions.includes(userRegion);
},
/**
* Creates an Open Graph (OG) card using meta information from the page.
*
* @param {Document} doc - The document object where the OG card will be
* created.
* @param {object} pageData - An object containing page data, including meta
* tags and article information.
* @param {object} [pageData.article] - Optional article-specific data.
* @param {object} [pageData.metaInfo] - Optional meta tag key-value pairs.
* @returns {Element} A DOM element representing the OG card.
*/
createOGCard(doc, pageData) {
const ogCard = doc.createElement("link-preview-card");
ogCard.style.width = "100%";
ogCard.pageData = pageData;
ogCard.optin = lazy.optin;
ogCard.collapsed = lazy.collapsed;
ogCard.regionSupported = this._isRegionSupported();
// Reflect the shared download progress to this preview.
const updateProgress = () => {
ogCard.progress = this.progress;
// If we are still downloading, update the progress again.
if (this.progress >= 0) {
doc.ownerGlobal.setTimeout(
() => ogCard.isConnected && updateProgress(),
250
);
}
};
updateProgress();
if (!this._isRegionSupported()) {
// Region not supported, just don't show key points section
return ogCard;
}
// Generate key points if we have content, language and configured for any
// language or restricted.
if (
pageData.article.textContent &&
pageData.article.detectedLanguage &&
(!lazy.allowedLanguages ||
lazy.allowedLanguages
.split(",")
.includes(pageData.article.detectedLanguage))
) {
this.generateKeyPoints(ogCard);
} else {
ogCard.isMissingDataErrorState = true;
}
return ogCard;
},
/**
* Generate AI key points for card.
*
* @param {LinkPreviewCard} ogCard to add key points
* @param {boolean} _retry Indicates whether to retry the operation.
*/
async generateKeyPoints(ogCard, _retry = false) {
// Prevent keypoints if user not opt-in to link preview or user is set
// keypoints to be collapsed.
if (!lazy.optin || lazy.collapsed) {
return;
}
// Support prefetching without a card by mocking expected properties.
let outcome = ogCard ? "success" : "prefetch";
if (!ogCard) {
ogCard = { addKeyPoint() {}, isConnected: true, keyPoints: [] };
}
const startTime = Date.now();
ogCard.generating = true;
// Ensure sequential AI processing to reduce memory usage by passing our
// promise to the next request before waiting on the previous.
const previous = this.lastRequest;
const { promise, resolve } = Promise.withResolvers();
this.lastRequest = promise;
await previous;
const delay = Date.now() - startTime;
// No need to generate if already removed.
if (!ogCard.isConnected) {
resolve();
Glean.genaiLinkpreview.generate.record({
delay,
outcome: "removed",
});
return;
}
let download, latency;
try {
await lazy.LinkPreviewModel.generateTextAI(
ogCard.pageData?.article.textContent ?? "",
{
onDownload: (downloading, percentage) => {
// Initial percentage is NaN, so set to 0.
percentage = isNaN(percentage) ? 0 : percentage;
// Use the percentage while downloading, otherwise disable with -1.
this.progress = downloading ? percentage : -1;
ogCard.progress = this.progress;
download = Date.now() - startTime;
},
onError: error => {
console.error(error);
outcome = error;
ogCard.generationError = error;
},
onText: text => {
// Clear waiting in case a different generate handled download.
ogCard.showWait = false;
ogCard.addKeyPoint(text);
latency = latency ?? Date.now() - startTime;
},
}
);
} finally {
resolve();
ogCard.generating = false;
Glean.genaiLinkpreview.generate.record({
delay,
download,
latency,
outcome,
sentences: ogCard.keyPoints.length,
time: Date.now() - startTime,
});
}
},
/**
* Handles key points generation requests from different user actions.
* This is a shared handler for both retry and initial generation events.
* Resets error states and triggers key points generation.
*
* @param {LinkPreviewCard} ogCard - The card element to generate key points for
* @private
*/
_handleKeyPointsGenerationEvent(ogCard) {
// Reset error states
ogCard.isMissingDataErrorState = false;
ogCard.isGenerationErrorState = false;
this.generateKeyPoints(ogCard, true);
},
/**
* Renders the link preview panel at the specified coordinates.
*
* @param {Window} win - The browser window context.
* @param {string} url - The URL of the link to be previewed.
* @param {string} source - Optional trigging behavior.
*/
async renderLinkPreviewPanel(win, url, source = "shortcut") {
// If link preview is used once not via onboarding, stop onboarding.
if (source !== "onboarding") {
const maxFreq = lazy.onboardingMaxShowFreq;
// Fill the times array up to maxFreq with an array of 0 timestamps.
Services.prefs.setStringPref(
"browser.ml.linkPreview.onboardingTimes",
[...lazy.onboardingTimes, ...Array(maxFreq).fill("0")].slice(0, maxFreq)
);
}
// Transition from onboarding to preview content with transparency.
const doc = win.document;
let panel = doc.getElementById(this.linkPreviewPanelId);
if (source == "onboarding") {
panel.style.setProperty("opacity", "0");
}
// Get tab context value for telemetry
const tabValue = this._getTabContextValue(win);
// Reuse or initialize panel.
if (panel && panel.previewUrl == url) {
if (panel.state == "closed") {
panel.openPopupNearMouse();
Glean.genaiLinkpreview.start.record({
cached: true,
source,
tab: tabValue,
});
}
return;
}
panel = this.initOrResetPreviewPanel(win, "linkpreview");
panel.previewUrl = url;
Glean.genaiLinkpreview.start.record({
cached: false,
source,
tab: tabValue,
});
// TODO we want to immediately add a card as a placeholder to have UI be
// more responsive while we wait on fetching page data.
const browsingContext = win.browsingContext;
const actor = browsingContext.currentWindowGlobal.getActor("LinkPreview");
const fetchTime = Date.now();
const pageData = await actor.fetchPageData(url);
// Skip updating content if we've moved on to showing something else.
const skipped = pageData.url != panel.previewUrl;
Glean.genaiLinkpreview.fetch.record({
description: !!pageData.meta.description,
image: !!pageData.meta.imageUrl,
length:
Math.round((pageData.article.textContent?.length ?? 0) * 0.01) * 100,
outcome: pageData.error?.result ?? "success",
sitename: !!pageData.article.siteName,
skipped,
tab: tabValue,
time: Date.now() - fetchTime,
title: !!pageData.meta.title,
});
if (skipped) {
return;
}
const ogCard = this.createOGCard(doc, pageData);
panel.append(ogCard);
ogCard.addEventListener("LinkPreviewCard:dismiss", event => {
panel.hidePopup();
Glean.genaiLinkpreview.cardLink.record({
key_points: !lazy.collapsed,
source: event.detail,
tab: tabValue,
});
});
ogCard.addEventListener("LinkPreviewCard:retry", _event => {
this._handleKeyPointsGenerationEvent(ogCard, "retry");
Glean.genaiLinkpreview.cardLink.record({
key_points: !lazy.collapsed,
source: "retry",
tab: tabValue,
});
});
ogCard.addEventListener("LinkPreviewCard:generate", _event => {
if (ogCard.keyPoints?.length || ogCard.generating) {
return;
}
this._handleKeyPointsGenerationEvent(ogCard, "generate");
});
// Make sure panel is visible if previously showing onboarding.
panel.style.setProperty("opacity", "1");
if (source !== "onboarding") {
panel.openPopupNearMouse();
}
},
/**
* Determines whether to process or cancel the link preview based on the current state.
* If a URL is available and the keyboard combination is active, it processes the link preview.
* Otherwise, it cancels the link preview.
*
* @param {Window} win - The window context in which the link preview may occur.
*/
_maybeLinkPreview(win) {
const stateObject = this._windowStates.get(win);
const url = stateObject.overLink;
// Render preview if we have url, keyboard combo and not recently typing.
// Ignore check intends to avoid cases where mouse happens to be over a
// link, e.g., after navigating then using an in-page keyboard shortcut or
// typing characters that require shift.
if (
url &&
this.keyboardComboActive &&
Date.now() - this.overLinkTime <= lazy.ignoreMs &&
Date.now() - this.recentTyping >= lazy.recentTypingMs
) {
this.renderLinkPreviewPanel(win, url, this.keyboardComboActive);
}
},
/**
* Handles the link preview context menu click using the provided URL
* and nsContextMenu, prompting the link preview panel to open.
*
* @param {string} url - The URL of the link to be previewed.
* @param {object} nsContextMenu - The context menu object containing browser information.
*/
async handleContextMenuClick(url, nsContextMenu) {
let win = nsContextMenu.browser.ownerGlobal;
this.renderLinkPreviewPanel(win, url, "context");
},
/**
* Updates the Glean metric for active shortcuts.
* This metric is a comma-separated string of active shortcut types.
*
* @private
*/
_updateShortcutMetric() {
const activeShortcuts = [];
if (lazy.shift) {
activeShortcuts.push("shift");
}
if (lazy.shiftAlt) {
activeShortcuts.push("shift_alt");
}
if (lazy.longPress) {
activeShortcuts.push("long_press");
}
Glean.genaiLinkpreview.shortcut.set(activeShortcuts.join(","));
},
};