Source code
Revision control
Copy as Markdown
Other Tools
/**
* 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
*/
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, let’s 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(","));
},
};