RealtimeSuggestProvider.sys.mjs (21005B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 import { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 11 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 12 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 13 UrlbarSearchUtils: 14 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 15 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 16 }); 17 18 /** 19 * A Suggest feature that manages realtime suggestions (a.k.a. "carrots"), 20 * including both opt-in and online suggestions for a given realtime type. It is 21 * intended to be subclassed rather than used as is. 22 * 23 * Each subclass should manage one realtime type. If the user has not opted in 24 * to online suggestions, this class will serve the realtime type's opt-in 25 * suggestion. Once the user has opted in, it will switch to serving online 26 * Merino suggestions for the realtime type. 27 */ 28 export class RealtimeSuggestProvider extends SuggestProvider { 29 // The following methods must be overridden. 30 31 /** 32 * The type of the realtime suggestion provider. 33 * 34 * @type {string} 35 */ 36 get realtimeType() { 37 throw new Error("Trying to access the base class, must be overridden"); 38 } 39 40 getViewTemplateForDescriptionTop(_item, _index) { 41 throw new Error("Trying to access the base class, must be overridden"); 42 } 43 44 getViewTemplateForDescriptionBottom(_item, _index) { 45 throw new Error("Trying to access the base class, must be overridden"); 46 } 47 48 getViewUpdateForPayloadItem(_item, _index) { 49 throw new Error("Trying to access the base class, must be overridden"); 50 } 51 52 // The following getters depend on `realtimeType` and should be overridden as 53 // necessary. 54 55 /** 56 * @returns {string[]} 57 * The opt-in suggestion is a dynamic Rust suggestion. `suggestion_type` in 58 * the RS record is `${this.realtimeType}_opt_in` by default. 59 */ 60 get dynamicRustSuggestionTypes() { 61 return [this.realtimeType + "_opt_in"]; 62 } 63 64 /** 65 * @returns {string} 66 * The online suggestions are served by Merino. The Merino provider is 67 * `this.realtimeType` by default. 68 */ 69 get merinoProvider() { 70 return this.realtimeType; 71 } 72 73 get baseTelemetryType() { 74 return this.realtimeType; 75 } 76 77 get realtimeTypeForFtl() { 78 return this.realtimeType.replace(/([A-Z])/g, "-$1").toLowerCase(); 79 } 80 81 get featureGatePref() { 82 return this.realtimeType + "FeatureGate"; 83 } 84 85 get suggestPref() { 86 return "suggest." + this.realtimeType; 87 } 88 89 get minKeywordLengthPref() { 90 return this.realtimeType + ".minKeywordLength"; 91 } 92 93 get showLessFrequentlyCountPref() { 94 return this.realtimeType + ".showLessFrequentlyCount"; 95 } 96 97 get optInIcon() { 98 return `chrome://browser/skin/illustrations/${this.realtimeType}-opt-in.svg`; 99 } 100 101 get optInTitleL10n() { 102 return { 103 id: `urlbar-result-${this.realtimeTypeForFtl}-opt-in-title`, 104 }; 105 } 106 107 get optInDescriptionL10n() { 108 return { 109 id: `urlbar-result-${this.realtimeTypeForFtl}-opt-in-description`, 110 parseMarkup: true, 111 }; 112 } 113 114 get notInterestedCommandL10n() { 115 return { 116 id: "urlbar-result-menu-dont-show-" + this.realtimeTypeForFtl, 117 }; 118 } 119 120 get acknowledgeDismissalL10n() { 121 return { 122 id: "urlbar-result-dismissal-acknowledgment-" + this.realtimeTypeForFtl, 123 }; 124 } 125 126 get ariaGroupL10n() { 127 return { 128 id: "urlbar-result-aria-group-" + this.realtimeTypeForFtl, 129 attribute: "aria-label", 130 }; 131 } 132 133 get isSponsored() { 134 return false; 135 } 136 137 /** 138 * @returns {string} 139 * The dynamic result type that will be set in the Merino result's payload 140 * as `result.payload.dynamicType`. Note that "dynamic" here refers to the 141 * concept of dynamic result types as used in the view and 142 * `UrlbarUtils.RESULT_TYPE.DYNAMIC`, not Rust dynamic suggestions. 143 * 144 * If you override this, make sure the value starts with "realtime-" because 145 * there are CSS rules that depend on that. 146 */ 147 get dynamicResultType() { 148 return "realtime-" + this.realtimeType; 149 } 150 151 // The following methods can be overridden but hopefully it's not necessary. 152 153 get rustSuggestionType() { 154 return "Dynamic"; 155 } 156 157 get enablingPreferences() { 158 return [ 159 "suggest.quicksuggest.all", 160 "suggest.realtimeOptIn", 161 "quicksuggest.realtimeOptIn.dismissTypes", 162 "quicksuggest.realtimeOptIn.notNowTimeSeconds", 163 "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays", 164 "quickSuggestOnlineAvailable", 165 "quicksuggest.online.enabled", 166 this.featureGatePref, 167 this.suggestPref, 168 169 // We could include the sponsored pref only if `this.isSponsored` is true, 170 // but for maximum flexibility `this.isSponsored` is only a fallback for 171 // when individual suggestions do not have an `isSponsored` property. 172 // Since individual suggestions may be sponsored or not, we include the 173 // pref here. 174 "suggest.quicksuggest.sponsored", 175 ]; 176 } 177 178 get primaryUserControlledPreferences() { 179 return [ 180 "suggest.realtimeOptIn", 181 "quicksuggest.realtimeOptIn.dismissTypes", 182 "quicksuggest.realtimeOptIn.notNowTimeSeconds", 183 "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays", 184 this.suggestPref, 185 ]; 186 } 187 188 get shouldEnable() { 189 if ( 190 !lazy.UrlbarPrefs.get(this.featureGatePref) || 191 !lazy.UrlbarPrefs.get("quickSuggestOnlineAvailable") || 192 !lazy.UrlbarPrefs.get("suggest.quicksuggest.all") 193 ) { 194 // The feature gate is disabled, online suggestions aren't available, or 195 // all Suggest suggestions are disabled. Don't show opt-in or online 196 // suggestions for this realtime type. 197 return false; 198 } 199 200 if (lazy.UrlbarPrefs.get("quicksuggest.online.enabled")) { 201 // Online suggestions are enabled. Show this realtime type if the user 202 // didn't disable it. 203 return lazy.UrlbarPrefs.get(this.suggestPref); 204 } 205 206 if (!lazy.UrlbarPrefs.get("suggest.realtimeOptIn")) { 207 // The user dismissed opt-in suggestions for all realtime types. 208 return false; 209 } 210 211 let dismissTypes = lazy.UrlbarPrefs.get( 212 "quicksuggest.realtimeOptIn.dismissTypes" 213 ); 214 if (dismissTypes.has(this.realtimeType)) { 215 // The user dismissed opt-in suggestions for this realtime type. 216 return false; 217 } 218 219 let notNowTimeSeconds = lazy.UrlbarPrefs.get( 220 "quicksuggest.realtimeOptIn.notNowTimeSeconds" 221 ); 222 if (!notNowTimeSeconds) { 223 return true; 224 } 225 226 let notNowReshowAfterPeriodDays = lazy.UrlbarPrefs.get( 227 "quicksuggest.realtimeOptIn.notNowReshowAfterPeriodDays" 228 ); 229 230 let timeSecs = notNowReshowAfterPeriodDays * 24 * 60 * 60; 231 return Date.now() / 1000 - notNowTimeSeconds > timeSecs; 232 } 233 234 isSuggestionSponsored(suggestion) { 235 switch (suggestion.source) { 236 case "merino": 237 if (suggestion.hasOwnProperty("is_sponsored")) { 238 return !!suggestion.is_sponsored; 239 } 240 break; 241 case "rust": 242 if (suggestion.data?.result?.payload?.hasOwnProperty("isSponsored")) { 243 return suggestion.data.result.payload.isSponsored; 244 } 245 break; 246 } 247 return this.isSponsored; 248 } 249 250 /** 251 * The telemetry type for a suggestion from this provider. (This string does 252 * not include the `${source}_` prefix, e.g., "rust_".) 253 * 254 * Since realtime providers serve two types of suggestions, the opt-in and the 255 * online suggestion, this will return two possible telemetry types depending 256 * on the passed-in suggestion. Telemetry types for each are: 257 * 258 * Opt-in suggestion: `${this.baseTelemetryType}_opt_in` 259 * Online suggestion: this.baseTelemetryType 260 * 261 * Individual suggestions can override these telemetry types, but that's 262 * expected to be uncommon. 263 * 264 * @param {object} suggestion 265 * A suggestion from this provider. 266 * @returns {string} 267 * The suggestion's telemetry type. 268 */ 269 getSuggestionTelemetryType(suggestion) { 270 switch (suggestion.source) { 271 case "merino": 272 if (suggestion.hasOwnProperty("telemetry_type")) { 273 return suggestion.telemetry_type; 274 } 275 break; 276 case "rust": 277 if (suggestion.data?.result?.payload?.hasOwnProperty("telemetryType")) { 278 return suggestion.data.result.payload.telemetryType; 279 } 280 return this.baseTelemetryType + "_opt_in"; 281 } 282 return this.baseTelemetryType; 283 } 284 285 filterSuggestions(suggestions) { 286 // The Rust opt-in suggestion can always be matched regardless of whether 287 // online is enabled, so return only Merino suggestions when it is enabled. 288 if (lazy.UrlbarPrefs.get("quicksuggest.online.enabled")) { 289 return suggestions.filter(s => s.source == "merino"); 290 } 291 return suggestions; 292 } 293 294 makeResult(queryContext, suggestion, searchString) { 295 // For maximum flexibility individual suggestions can indicate whether they 296 // are sponsored or not, despite `this.isSponsored`, which is a fallback. 297 if ( 298 !lazy.UrlbarPrefs.get("suggest.quicksuggest.all") || 299 (this.isSuggestionSponsored(suggestion) && 300 !lazy.UrlbarPrefs.get("suggest.quicksuggest.sponsored")) 301 ) { 302 return null; 303 } 304 305 switch (suggestion.source) { 306 case "merino": 307 return this.makeMerinoResult(queryContext, suggestion, searchString); 308 case "rust": 309 return this.makeOptInResult(queryContext, suggestion); 310 } 311 return null; 312 } 313 314 makeMerinoResult( 315 queryContext, 316 suggestion, 317 searchString, 318 additionalOptions = {} 319 ) { 320 if (!this.isEnabled) { 321 return null; 322 } 323 324 if ( 325 this.showLessFrequentlyCount && 326 searchString.length < this.#minKeywordLength 327 ) { 328 return null; 329 } 330 331 let values = suggestion.custom_details?.[this.merinoProvider]?.values; 332 if (!values?.length) { 333 return null; 334 } 335 336 let engine; 337 if (values.some(v => v.query)) { 338 engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate); 339 if (!engine) { 340 return null; 341 } 342 } 343 344 let result = new lazy.UrlbarResult({ 345 type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, 346 source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, 347 isBestMatch: true, 348 hideRowLabel: true, 349 ...additionalOptions, 350 payload: { 351 items: values.map((v, i) => this.makePayloadItem(v, i)), 352 dynamicType: this.dynamicResultType, 353 engine: engine?.name, 354 }, 355 }); 356 357 return result; 358 } 359 360 /** 361 * Returns the object that should be stored as `result.payload.items[i]` for 362 * the Merino result. The default implementation here returns the 363 * corresponding value in the suggestion. 364 * 365 * It's useful to override this if there's a significant amount of logic 366 * that's used by the different code paths of the view update. In that case, 367 * you can override this method, perform the logic, store the results in the 368 * item, and then your different view update paths can all use it. 369 * 370 * @param {object} value 371 * The value in the suggestion's `values` array. 372 * @param {number} _index 373 * The index of the value in the array. 374 * @returns {object} 375 * The object that should be stored in `result.payload.items[_index]`. 376 */ 377 makePayloadItem(value, _index) { 378 return value; 379 } 380 381 makeOptInResult(queryContext, _suggestion) { 382 let notNowTypes = lazy.UrlbarPrefs.get( 383 "quicksuggest.realtimeOptIn.notNowTypes" 384 ); 385 let splitButtonMain = notNowTypes.has(this.realtimeType) 386 ? { 387 command: "dismiss", 388 l10n: { 389 id: "urlbar-result-realtime-opt-in-dismiss", 390 }, 391 } 392 : { 393 command: "not_now", 394 l10n: { 395 id: "urlbar-result-realtime-opt-in-not-now", 396 }, 397 }; 398 399 return new lazy.UrlbarResult({ 400 type: lazy.UrlbarUtils.RESULT_TYPE.TIP, 401 source: lazy.UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL, 402 isBestMatch: true, 403 hideRowLabel: true, 404 payload: { 405 // This `type` is the tip type, required for `TIP` results. 406 type: "realtime_opt_in", 407 icon: this.optInIcon, 408 titleL10n: this.optInTitleL10n, 409 descriptionL10n: this.optInDescriptionL10n, 410 descriptionLearnMoreTopic: lazy.QuickSuggest.HELP_TOPIC, 411 buttons: [ 412 { 413 command: "opt_in", 414 l10n: { 415 id: "urlbar-result-realtime-opt-in-allow", 416 }, 417 input: queryContext.searchString, 418 attributes: { 419 primary: "", 420 }, 421 }, 422 { 423 ...splitButtonMain, 424 menu: [ 425 { 426 name: "not_interested", 427 l10n: { 428 id: "urlbar-result-realtime-opt-in-dismiss-all", 429 }, 430 }, 431 ], 432 }, 433 ], 434 }, 435 }); 436 } 437 438 getViewTemplate(result) { 439 let { items } = result.payload; 440 let hasMultipleItems = items.length > 1; 441 return { 442 name: "root", 443 overflowable: true, 444 attributes: { 445 selectable: hasMultipleItems ? null : "", 446 role: hasMultipleItems ? "group" : "option", 447 }, 448 classList: ["urlbarView-realtime-root"], 449 children: items.map((item, i) => ({ 450 name: `item_${i}`, 451 tag: "span", 452 classList: ["urlbarView-realtime-item"], 453 attributes: { 454 selectable: !hasMultipleItems ? null : "", 455 role: hasMultipleItems ? "option" : "presentation", 456 }, 457 children: [ 458 // Create an image inside a container so that the image appears inset 459 // into a square. This is atypical because we normally use only an 460 // image and give it padding and a background color to achieve that 461 // effect, but that only works when the image size is fixed. 462 // Unfortunately Merino serves market icons of different sizes due to 463 // its reliance on a third-party API. 464 { 465 name: `image_container_${i}`, 466 tag: "span", 467 classList: ["urlbarView-realtime-image-container"], 468 children: this.getViewTemplateForImage(item, i), 469 }, 470 471 { 472 tag: "span", 473 classList: ["urlbarView-realtime-description"], 474 children: [ 475 { 476 tag: "div", 477 classList: ["urlbarView-realtime-description-top"], 478 children: this.getViewTemplateForDescriptionTop(item, i), 479 }, 480 { 481 tag: "div", 482 classList: ["urlbarView-realtime-description-bottom"], 483 children: this.getViewTemplateForDescriptionBottom(item, i), 484 }, 485 ], 486 }, 487 ], 488 })), 489 }; 490 } 491 492 /** 493 * Returns the view template inside the `image_container`. This default 494 * implementation creates an `img` element. Override it if you need something 495 * else. 496 * 497 * @param {object} _item 498 * An item from the `result.payload.items` array. 499 * @param {number} index 500 * The index of the item in the array. 501 * @returns {Array} 502 * View template for the image, an array of objects. 503 */ 504 getViewTemplateForImage(_item, index) { 505 return [ 506 { 507 name: `image_${index}`, 508 tag: "img", 509 classList: ["urlbarView-realtime-image"], 510 }, 511 ]; 512 } 513 514 getViewUpdate(result) { 515 let { items } = result.payload; 516 let hasMultipleItems = items.length > 1; 517 518 let update = { 519 root: { 520 dataset: { 521 // This `url` or `query` will be used when there's only one item. 522 url: items[0].url, 523 query: items[0].query, 524 }, 525 l10n: hasMultipleItems ? this.ariaGroupL10n : null, 526 }, 527 }; 528 529 for (let i = 0; i < items.length; i++) { 530 let item = items[i]; 531 Object.assign(update, this.getViewUpdateForPayloadItem(item, i)); 532 533 // These `url` or `query`s will be used when there are multiple items. 534 let itemName = `item_${i}`; 535 update[itemName] ??= {}; 536 update[itemName].dataset ??= {}; 537 update[itemName].dataset.url ??= item.url; 538 update[itemName].dataset.query ??= item.query; 539 } 540 541 return update; 542 } 543 544 getResultCommands(result) { 545 if (result.payload.source == "rust") { 546 // The opt-in result should not have a result menu. 547 return null; 548 } 549 550 /** @type {UrlbarResultCommand[]} */ 551 let commands = [ 552 { 553 name: "not_interested", 554 l10n: this.notInterestedCommandL10n, 555 }, 556 ]; 557 558 if (this.canShowLessFrequently) { 559 commands.push({ 560 name: "show_less_frequently", 561 l10n: { 562 id: "urlbar-result-menu-show-less-frequently", 563 }, 564 }); 565 } 566 567 commands.push( 568 { name: "separator" }, 569 { 570 name: "manage", 571 l10n: { 572 id: "urlbar-result-menu-manage-firefox-suggest", 573 }, 574 }, 575 { 576 name: "help", 577 l10n: { 578 id: "urlbar-result-menu-learn-more-about-firefox-suggest", 579 }, 580 } 581 ); 582 583 return commands; 584 } 585 586 onEngagement(queryContext, controller, details, searchString) { 587 switch (details.result.payload.source) { 588 case "merino": 589 this.onMerinoEngagement( 590 queryContext, 591 controller, 592 details, 593 searchString 594 ); 595 break; 596 case "rust": 597 this.onOptInEngagement(queryContext, controller, details, searchString); 598 break; 599 } 600 } 601 602 onMerinoEngagement(queryContext, controller, details, searchString) { 603 let { result } = details; 604 switch (details.selType) { 605 case "help": 606 case "manage": { 607 // "help" and "manage" are handled by UrlbarInput, no need to do 608 // anything here. 609 break; 610 } 611 case "not_interested": { 612 lazy.UrlbarPrefs.set(this.suggestPref, false); 613 result.acknowledgeDismissalL10n = this.acknowledgeDismissalL10n; 614 controller.removeResult(result); 615 break; 616 } 617 case "show_less_frequently": { 618 controller.view.acknowledgeFeedback(result); 619 this.incrementShowLessFrequentlyCount(); 620 if (!this.canShowLessFrequently) { 621 controller.view.invalidateResultMenuCommands(); 622 } 623 lazy.UrlbarPrefs.set( 624 this.minKeywordLengthPref, 625 searchString.length + 1 626 ); 627 break; 628 } 629 } 630 } 631 632 onOptInEngagement(queryContext, controller, details, _searchString) { 633 switch (details.selType) { 634 case "opt_in": 635 lazy.UrlbarPrefs.set("quicksuggest.online.enabled", true); 636 controller.input.startQuery({ allowAutofill: false }); 637 break; 638 case "not_now": { 639 lazy.UrlbarPrefs.set( 640 "quicksuggest.realtimeOptIn.notNowTimeSeconds", 641 Date.now() / 1000 642 ); 643 lazy.UrlbarPrefs.add( 644 "quicksuggest.realtimeOptIn.notNowTypes", 645 this.realtimeType 646 ); 647 controller.removeResult(details.result); 648 break; 649 } 650 case "dismiss": { 651 lazy.UrlbarPrefs.add( 652 "quicksuggest.realtimeOptIn.dismissTypes", 653 this.realtimeType 654 ); 655 details.result.acknowledgeDismissalL10n = this.acknowledgeDismissalL10n; 656 controller.removeResult(details.result); 657 break; 658 } 659 case "not_interested": { 660 lazy.UrlbarPrefs.set("suggest.realtimeOptIn", false); 661 details.result.acknowledgeDismissalL10n = { 662 id: "urlbar-result-dismissal-acknowledgment-all", 663 }; 664 controller.removeResult(details.result); 665 break; 666 } 667 } 668 } 669 670 incrementShowLessFrequentlyCount() { 671 if (this.canShowLessFrequently) { 672 lazy.UrlbarPrefs.set( 673 this.showLessFrequentlyCountPref, 674 this.showLessFrequentlyCount + 1 675 ); 676 } 677 } 678 679 get showLessFrequentlyCount() { 680 const pref = this.showLessFrequentlyCountPref; 681 const count = lazy.UrlbarPrefs.get(pref) || 0; 682 return Math.max(count, 0); 683 } 684 685 get canShowLessFrequently() { 686 const cap = 687 lazy.UrlbarPrefs.get("realtimeShowLessFrequentlyCap") || 688 lazy.QuickSuggest.config.showLessFrequentlyCap || 689 0; 690 return !cap || this.showLessFrequentlyCount < cap; 691 } 692 693 get #minKeywordLength() { 694 let hasUserValue = Services.prefs.prefHasUserValue( 695 "browser.urlbar." + this.minKeywordLengthPref 696 ); 697 let nimbusValue = lazy.UrlbarPrefs.get("realtimeMinKeywordLength"); 698 let minLength = 699 hasUserValue || nimbusValue === null 700 ? lazy.UrlbarPrefs.get(this.minKeywordLengthPref) 701 : nimbusValue; 702 return Math.max(minLength, 0); 703 } 704 }