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