UrlbarMuxerStandard.sys.mjs (57777B)
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 /** 6 * This module exports a component used to sort results in a UrlbarQueryContext. 7 */ 8 9 import { 10 UrlbarMuxer, 11 UrlbarUtils, 12 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs"; 13 14 const lazy = {}; 15 16 ChromeUtils.defineESModuleGetters(lazy, { 17 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 18 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 19 UrlbarProviderOpenTabs: 20 "moz-src:///browser/components/urlbar/UrlbarProviderOpenTabs.sys.mjs", 21 UrlbarProviderQuickSuggest: 22 "moz-src:///browser/components/urlbar/UrlbarProviderQuickSuggest.sys.mjs", 23 UrlbarSearchUtils: 24 "moz-src:///browser/components/urlbar/UrlbarSearchUtils.sys.mjs", 25 }); 26 27 ChromeUtils.defineLazyGetter(lazy, "logger", () => 28 UrlbarUtils.getLogger({ prefix: "MuxerUnifiedComplete" }) 29 ); 30 31 const MS_PER_DAY = 1000 * 60 * 60 * 24; 32 33 /** 34 * Constructs the map key by joining the url with the userContextId if 35 * 'browser.urlbar.switchTabs.searchAllContainers' is set to true. 36 * Otherwise, just the url is used. 37 * 38 * @param {UrlbarResult} result The result object. 39 * @returns {string} map key 40 */ 41 function makeMapKeyForTabResult(result) { 42 return UrlbarUtils.tupleString( 43 result.payload.url, 44 lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") && 45 result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && 46 lazy.UrlbarProviderOpenTabs.isNonPrivateUserContextId( 47 result.payload.userContextId 48 ) 49 ? result.payload.userContextId 50 : undefined 51 ); 52 } 53 54 /** 55 * Class used to create a muxer. 56 * The muxer receives and sorts results in a UrlbarQueryContext. 57 */ 58 class MuxerUnifiedComplete extends UrlbarMuxer { 59 constructor() { 60 super(); 61 } 62 63 get name() { 64 return "UnifiedComplete"; 65 } 66 67 /** 68 * Sorts results in the given UrlbarQueryContext. 69 * 70 * @param {UrlbarQueryContext} context 71 * The query context. 72 * @param {Array} unsortedResults 73 * The array of UrlbarResult that is not sorted yet. 74 */ 75 sort(context, unsortedResults) { 76 // This method is called multiple times per keystroke, so it should be as 77 // fast and efficient as possible. We do two passes through the results: 78 // one to collect state for the second pass, and then a second to build the 79 // sorted list of results. If you find yourself writing something like 80 // context.results.find(), filter(), sort(), etc., modify one or both passes 81 // instead. 82 83 // Global state we'll use to make decisions during this sort. 84 let state = { 85 context, 86 // RESULT_GROUP => array of results belonging to the group, excluding 87 // group-relative suggestedIndex results 88 resultsByGroup: new Map(), 89 // RESULT_GROUP => array of group-relative suggestedIndex results 90 // belonging to the group 91 suggestedIndexResultsByGroup: new Map(), 92 // This is analogous to `maxResults` except it's the total available 93 // result span instead of the total available result count. We'll add 94 // results until `usedResultSpan` would exceed `availableResultSpan`. 95 availableResultSpan: context.maxResults, 96 // The total result span taken up by all global (non-group-relative) 97 // suggestedIndex results. 98 globalSuggestedIndexResultSpan: 0, 99 // The total span of results that have been added so far. 100 usedResultSpan: 0, 101 strippedUrlToTopPrefixAndTitle: new Map(), 102 baseAndTitleToTopRef: new Map(), 103 urlToTabResultType: new Map(), 104 addedRemoteTabUrls: new Set(), 105 addedSwitchTabUrls: new Set(), 106 addedResultUrls: new Set(), 107 canShowPrivateSearch: unsortedResults.length > 1, 108 canShowTailSuggestions: true, 109 // Form history and remote suggestions added so far. Used for deduping 110 // suggestions. Also includes the heuristic query string if the heuristic 111 // is a search result. All strings in the set are lowercased. 112 suggestions: new Set(), 113 canAddTabToSearch: true, 114 hasUnitConversionResult: false, 115 maxHeuristicResultSpan: 0, 116 maxTabToSearchResultSpan: 0, 117 // When you add state, update _copyState() as necessary. 118 }; 119 120 // Determine the result groups to use for this sort. 121 let rootGroup = lazy.UrlbarPrefs.getResultGroups({ context }); 122 lazy.logger.debug("Root groups", rootGroup); 123 124 // We must do a first pass over the result to reorder some groups. 125 // TODO (Bug 1970591): It would be better to sort while we insert results, 126 // but we cannot because the order matters to `_updateStatePreAdd`. 127 // This maps groups to { sortingField, start, length } objects. 128 let indicesToSort = new Map(); 129 for (let i = 0; i < unsortedResults.length; ++i) { 130 let group = UrlbarUtils.getResultGroup(unsortedResults[i]); 131 let groupObj = this.#getGroupAsObject(rootGroup, group); 132 let sortingField = groupObj?.orderBy; 133 if (sortingField) { 134 let indices = indicesToSort.get(group); 135 if (!indices) { 136 indices = { sortingField, start: i, length: 1 }; 137 indicesToSort.set(group, indices); 138 } else { 139 indices.length++; 140 } 141 } 142 } 143 for (let { sortingField, start, length } of indicesToSort.values()) { 144 let toSort = unsortedResults.slice(start, start + length); 145 // We assume sorting is always descending. 146 toSort.sort((a, b) => b.payload[sortingField] - a.payload[sortingField]); 147 unsortedResults.splice(start, length, ...toSort); 148 } 149 150 // Do the first pass over all results to build some state. 151 for (let result of unsortedResults) { 152 // Add each result to the appropriate `resultsByGroup` map. 153 let group = UrlbarUtils.getResultGroup(result); 154 let resultsByGroup = 155 result.hasSuggestedIndex && result.isSuggestedIndexRelativeToGroup 156 ? state.suggestedIndexResultsByGroup 157 : state.resultsByGroup; 158 let results = resultsByGroup.get(group); 159 if (!results) { 160 results = []; 161 resultsByGroup.set(group, results); 162 } 163 results.push(result); 164 165 // Update pre-add state. 166 this._updateStatePreAdd(result, state); 167 } 168 169 // Now that the first pass is done, adjust the available result span. More 170 // than one tab-to-search result may be present but only one will be shown; 171 // add the max TTS span to the total span of global suggestedIndex results. 172 state.globalSuggestedIndexResultSpan += state.maxTabToSearchResultSpan; 173 174 // Leave room for global suggestedIndex results at the end of the sort, by 175 // subtracting their total span from the total available span. For very 176 // small values of `maxRichResults`, their total span may be larger than 177 // `state.availableResultSpan`. 178 let globalSuggestedIndexAvailableSpan = Math.min( 179 state.availableResultSpan, 180 state.globalSuggestedIndexResultSpan 181 ); 182 state.availableResultSpan -= globalSuggestedIndexAvailableSpan; 183 184 if (state.maxHeuristicResultSpan) { 185 if (lazy.UrlbarPrefs.get("experimental.hideHeuristic")) { 186 // The heuristic is hidden. The muxer will include it but the view will 187 // hide it. Increase the available span to compensate so that the total 188 // visible span accurately reflects `context.maxResults`. 189 state.availableResultSpan += state.maxHeuristicResultSpan; 190 } else if (context.maxResults > 0) { 191 // `context.maxResults` is positive. Make sure there's room for the 192 // heuristic even if it means exceeding `context.maxResults`. 193 state.availableResultSpan = Math.max( 194 state.availableResultSpan, 195 state.maxHeuristicResultSpan 196 ); 197 } 198 } 199 200 // Fill the root group. 201 let [sortedResults] = this._fillGroup( 202 rootGroup, 203 { availableSpan: state.availableResultSpan, maxResultCount: Infinity }, 204 state 205 ); 206 207 // Add global suggestedIndex results. 208 let globalSuggestedIndexResults = state.resultsByGroup.get( 209 UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX 210 ); 211 if (globalSuggestedIndexResults) { 212 this._addSuggestedIndexResults( 213 globalSuggestedIndexResults, 214 sortedResults, 215 { 216 availableSpan: globalSuggestedIndexAvailableSpan, 217 maxResultCount: Infinity, 218 }, 219 state 220 ); 221 } 222 223 context.results = sortedResults; 224 } 225 226 /** 227 * Search for group in rootGroup and return it. 228 * 229 * @param {object} rootGroup Root group definition. 230 * @param {Values<typeof UrlbarUtils.RESULT_GROUP>} group The group to search for. 231 * @returns {object|null} Group object from the root group. The 232 * SUGGESTED_INDEX group is not included in the rootGroup, so this 233 * will return null for it. 234 */ 235 #getGroupAsObject(rootGroup, group) { 236 if ("children" in rootGroup) { 237 for (let child of rootGroup.children) { 238 if ("children" in child) { 239 let groupObj = this.#getGroupAsObject(child, group); 240 if (groupObj) { 241 return groupObj; 242 } 243 } else if (child.group == group) { 244 return child; 245 } 246 } 247 } 248 return null; 249 } 250 251 /** 252 * Returns a *deep* copy of state (except for `state.context`, which is 253 * shallow copied). i.e., any Maps, Sets, and arrays in the state should be 254 * recursively copied so that the original state is not modified when the copy 255 * is modified. 256 * 257 * @param {object} state 258 * The muxer state to copy. 259 * @returns {object} 260 * A deep copy of the state. 261 */ 262 _copyState(state) { 263 let copy = Object.assign({}, state, { 264 resultsByGroup: new Map(), 265 suggestedIndexResultsByGroup: new Map(), 266 strippedUrlToTopPrefixAndTitle: new Map( 267 state.strippedUrlToTopPrefixAndTitle 268 ), 269 baseAndTitleToTopRef: new Map(state.baseAndTitleToTopRef), 270 urlToTabResultType: new Map(state.urlToTabResultType), 271 addedRemoteTabUrls: new Set(state.addedRemoteTabUrls), 272 addedSwitchTabUrls: new Set(state.addedSwitchTabUrls), 273 suggestions: new Set(state.suggestions), 274 addedResultUrls: new Set(state.addedResultUrls), 275 }); 276 277 // Deep copy the `resultsByGroup` maps. 278 for (let key of ["resultsByGroup", "suggestedIndexResultsByGroup"]) { 279 for (let [group, results] of state[key]) { 280 copy[key].set(group, [...results]); 281 } 282 } 283 284 return copy; 285 } 286 287 /** 288 * Recursively fills a result group and its children. 289 * 290 * There are two ways to limit the number of results in a group: 291 * 292 * (1) By max total result span using the `availableSpan` property. The group 293 * will be filled so that the total span of its results does not exceed this 294 * value. 295 * 296 * (2) By max total result count using the `maxResultCount` property. The 297 * group will be filled so that the total number of its results does not 298 * exceed this value. 299 * 300 * Both `availableSpan` and `maxResultCount` may be defined, and the group's 301 * results will be capped to whichever limit is reached first. If either is 302 * not defined, then the group inherits that limit from its parent group. 303 * 304 * In addition to limiting their total number of results, groups can also 305 * control the composition of their child groups by using flex ratios. A group 306 * can define a `flexChildren: true` property, and in that case each of its 307 * children should have a `flex` property. Each child will be filled according 308 * to the ratio of its flex value and the sum of the flex values of all the 309 * children, similar to HTML flexbox. If some children do not fill up but 310 * others do, the filled-up children will be allowed to grow to use up the 311 * unfilled space. 312 * 313 * @param {object} group 314 * The result group to fill. 315 * @param {object} limits 316 * An object with optional `availableSpan` and `maxResultCount` properties 317 * as described above. They will be used as the limits for the group. 318 * @param {object} state 319 * The muxer state. 320 * @returns {Array} 321 * `[results, usedLimits, hasMoreResults]` -- see `_addResults`. 322 */ 323 _fillGroup(group, limits, state) { 324 // Get the group's suggestedIndex results. Reminder: `group.group` is a 325 // `RESULT_GROUP` constant. 326 let suggestedIndexResults; 327 let suggestedIndexAvailableSpan = 0; 328 let suggestedIndexAvailableCount = 0; 329 if ("group" in group) { 330 let results = state.suggestedIndexResultsByGroup.get(group.group); 331 if (results) { 332 // Subtract them from the group's limits so there will be room for them 333 // later. Discard results that can't be added. 334 let span = 0; 335 let resultCount = 0; 336 for (let result of results) { 337 if (this._canAddResult(result, state)) { 338 suggestedIndexResults ??= []; 339 suggestedIndexResults.push(result); 340 const spanSize = UrlbarUtils.getSpanForResult(result); 341 span += spanSize; 342 if (spanSize) { 343 resultCount++; 344 } 345 } 346 } 347 348 suggestedIndexAvailableSpan = Math.min(limits.availableSpan, span); 349 suggestedIndexAvailableCount = Math.min( 350 limits.maxResultCount, 351 resultCount 352 ); 353 354 // Create a new `limits` object so we don't modify the caller's. 355 limits = { ...limits }; 356 limits.availableSpan -= suggestedIndexAvailableSpan; 357 limits.maxResultCount -= suggestedIndexAvailableCount; 358 } 359 } 360 361 // Fill the group. If it has children, fill them recursively. Otherwise fill 362 // the group directly. 363 let [results, usedLimits, hasMoreResults] = group.children 364 ? this._fillGroupChildren(group, limits, state) 365 : this._addResults(group.group, limits, state); 366 367 // Add the group's suggestedIndex results. 368 if (suggestedIndexResults) { 369 let suggestedIndexUsedLimits = this._addSuggestedIndexResults( 370 suggestedIndexResults, 371 results, 372 { 373 availableSpan: suggestedIndexAvailableSpan, 374 maxResultCount: suggestedIndexAvailableCount, 375 }, 376 state 377 ); 378 for (let [key, value] of Object.entries(suggestedIndexUsedLimits)) { 379 usedLimits[key] += value; 380 } 381 } 382 383 return [results, usedLimits, hasMoreResults]; 384 } 385 386 /** 387 * Helper for `_fillGroup` that fills a group's children. 388 * 389 * @param {object} group 390 * The result group to fill. It's assumed to have a `children` property. 391 * @param {object} limits 392 * An object with optional `availableSpan` and `maxResultCount` properties 393 * as described in `_fillGroup`. 394 * @param {object} state 395 * The muxer state. 396 * @param {Array} flexDataArray 397 * See `_updateFlexData`. 398 * @returns {Array} 399 * `[results, usedLimits, hasMoreResults]` -- see `_addResults`. 400 */ 401 _fillGroupChildren(group, limits, state, flexDataArray = null) { 402 // If the group has flexed children, update the data we use during flex 403 // calculations. 404 // 405 // Handling flex is complicated so we discuss it briefly. We may do multiple 406 // passes for a group with flexed children in order to try to optimally fill 407 // them. If after one pass some children do not fill up but others do, we'll 408 // do another pass that tries to overfill the filled-up children while still 409 // respecting their flex ratios. We'll continue to do passes until all 410 // children stop filling up or we reach the parent's limits. The way we 411 // overfill children is by increasing their individual limits to make up for 412 // the unused space in their underfilled siblings. Before starting a new 413 // pass, we discard the results from the current pass so the new pass starts 414 // with a clean slate. That means we need to copy the global sort state 415 // (`state`) before modifying it in the current pass so we can use its 416 // original value in the next pass [1]. 417 // 418 // [1] Instead of starting each pass with a clean slate in this way, we 419 // could accumulate results with each pass since we only ever add results to 420 // flexed children and never remove them. However, that would subvert muxer 421 // logic related to the global state (deduping, `_canAddResult`) since we 422 // generally assume the muxer adds results in the order they appear. 423 let stateCopy; 424 if (group.flexChildren) { 425 stateCopy = this._copyState(state); 426 flexDataArray = this._updateFlexData(group, limits, flexDataArray); 427 } 428 429 // Fill each child group, collecting all results in the `results` array. 430 let results = []; 431 let usedLimits = {}; 432 for (let key of Object.keys(limits)) { 433 usedLimits[key] = 0; 434 } 435 let anyChildUnderfilled = false; 436 let anyChildHasMoreResults = false; 437 for (let i = 0; i < group.children.length; i++) { 438 let child = group.children[i]; 439 let flexData = flexDataArray?.[i]; 440 441 // Compute the child's limits. 442 let childLimits = {}; 443 for (let key of Object.keys(limits)) { 444 childLimits[key] = flexData 445 ? flexData.limits[key] 446 : Math.min( 447 typeof child[key] == "number" ? child[key] : Infinity, 448 limits[key] - usedLimits[key] 449 ); 450 } 451 452 // Recurse and fill the child. 453 let [childResults, childUsedLimits, childHasMoreResults] = 454 this._fillGroup(child, childLimits, state); 455 results = results.concat(childResults); 456 for (let key of Object.keys(usedLimits)) { 457 usedLimits[key] += childUsedLimits[key]; 458 } 459 anyChildHasMoreResults = anyChildHasMoreResults || childHasMoreResults; 460 461 if (flexData?.hasMoreResults) { 462 // The child is flexed and we possibly added more results to it. 463 flexData.usedLimits = childUsedLimits; 464 flexData.hasMoreResults = childHasMoreResults; 465 anyChildUnderfilled = 466 anyChildUnderfilled || 467 (!childHasMoreResults && 468 [...Object.entries(childLimits)].every( 469 ([key, limit]) => flexData.usedLimits[key] < limit 470 )); 471 } 472 } 473 474 // If the children are flexed and some underfilled but others still have 475 // more results, do another pass. 476 if (anyChildUnderfilled && anyChildHasMoreResults) { 477 [results, usedLimits, anyChildHasMoreResults] = this._fillGroupChildren( 478 group, 479 limits, 480 stateCopy, 481 flexDataArray 482 ); 483 484 // Update `state` in place so that it's also updated in the caller. 485 for (let [key, value] of Object.entries(stateCopy)) { 486 state[key] = value; 487 } 488 } 489 490 return [results, usedLimits, anyChildHasMoreResults]; 491 } 492 493 /** 494 * Updates flex-related state used while filling a group. 495 * 496 * @param {object} group 497 * The result group being filled. 498 * @param {object} limits 499 * An object defining the group's limits as described in `_fillGroup`. 500 * @param {Array} flexDataArray 501 * An array parallel to `group.children`. The object at index i corresponds 502 * to the child in `group.children` at index i. Each object maintains some 503 * flex-related state for its child and is updated during each pass in 504 * `_fillGroup` for `group`. When this method is called in the first pass, 505 * this argument should be null, and the method will create and return a new 506 * `flexDataArray` array that should be used in the remainder of the first 507 * pass and all subsequent passes. 508 * @returns {Array} 509 * A new `flexDataArray` when called in the first pass, and `flexDataArray` 510 * itself when called in subsequent passes. 511 */ 512 _updateFlexData(group, limits, flexDataArray) { 513 flexDataArray = 514 flexDataArray || 515 group.children.map((child, index) => { 516 let data = { 517 // The index of the corresponding child in `group.children`. 518 index, 519 // The child's limits. 520 limits: {}, 521 // The fractional parts of the child's unrounded limits; see below. 522 limitFractions: {}, 523 // The used-up portions of the child's limits. 524 usedLimits: {}, 525 // True if `state.resultsByGroup` has more results of the child's 526 // `RESULT_GROUP`. This is not related to the child's limits. 527 hasMoreResults: true, 528 // The child's flex value. 529 flex: typeof child.flex == "number" ? child.flex : 0, 530 }; 531 for (let key of Object.keys(limits)) { 532 data.limits[key] = 0; 533 data.limitFractions[key] = 0; 534 data.usedLimits[key] = 0; 535 } 536 return data; 537 }); 538 539 // The data objects for children with more results (i.e., that are still 540 // fillable). 541 let fillableDataArray = []; 542 543 // The sum of the flex values of children with more results. 544 let fillableFlexSum = 0; 545 546 for (let data of flexDataArray) { 547 if (data.hasMoreResults) { 548 fillableFlexSum += data.flex; 549 fillableDataArray.push(data); 550 } 551 } 552 553 // Update each limit. 554 for (let [key, limit] of Object.entries(limits)) { 555 // Calculate the group's limit only including children with more results. 556 let fillableLimit = limit; 557 for (let data of flexDataArray) { 558 if (!data.hasMoreResults) { 559 fillableLimit -= data.usedLimits[key]; 560 } 561 } 562 563 // Allow for the possibility that some children may have gone over limit. 564 // `fillableLimit` will be negative in that case. 565 fillableLimit = Math.max(fillableLimit, 0); 566 567 // Next we'll compute the limits of children with more results. This value 568 // is the sum of those limits. It may differ from `fillableLimit` due to 569 // the fact that each individual child limit must be an integer. 570 let summedFillableLimit = 0; 571 572 // Compute the limits of children with more results. If there are also 573 // children that don't have more results, then these new limits will be 574 // larger than they were in the previous pass. 575 for (let data of fillableDataArray) { 576 let unroundedLimit = fillableLimit * (data.flex / fillableFlexSum); 577 // `limitFraction` is the fractional part of the unrounded ideal limit. 578 // e.g., for 5.234 it will be 0.234. We use this to minimize the 579 // mathematical error when tweaking limits below. 580 data.limitFractions[key] = unroundedLimit - Math.floor(unroundedLimit); 581 data.limits[key] = Math.round(unroundedLimit); 582 summedFillableLimit += data.limits[key]; 583 } 584 585 // As mentioned above, the sum of the individual child limits may not 586 // equal the group's fillable limit. If the sum is smaller, the group will 587 // end up with too few results. If it's larger, the group will have the 588 // correct number of results (since we stop adding results once limits are 589 // reached) but it may end up with a composition that does not reflect the 590 // child flex ratios as accurately as possible. 591 // 592 // In either case, tweak the individual limits so that (1) their sum 593 // equals the group's fillable limit, and (2) the composition respects the 594 // flex ratios with as little mathematical error as possible. 595 if (summedFillableLimit != fillableLimit) { 596 // Collect the flex datas with a non-zero limit fractions. We'll round 597 // them up or down depending on whether the sum is larger or smaller 598 // than the group's fillable limit. 599 let fractionalDataArray = fillableDataArray.filter( 600 data => data.limitFractions[key] 601 ); 602 603 let diff; 604 if (summedFillableLimit < fillableLimit) { 605 // The sum is smaller. We'll increment individual limits until the sum 606 // is equal, starting with the child whose limit fraction is closest 607 // to 1 in order to minimize error. 608 diff = 1; 609 fractionalDataArray.sort((a, b) => { 610 // Sort by fraction descending so larger fractions are first. 611 let cmp = b.limitFractions[key] - a.limitFractions[key]; 612 // Secondarily sort by index ascending so that children with the 613 // same fraction are incremented in the order they appear, allowing 614 // earlier children to have larger spans. 615 return cmp || a.index - b.index; 616 }); 617 } else if (fillableLimit < summedFillableLimit) { 618 // The sum is larger. We'll decrement individual limits until the sum 619 // is equal, starting with the child whose limit fraction is closest 620 // to 0 in order to minimize error. 621 diff = -1; 622 fractionalDataArray.sort((a, b) => { 623 // Sort by fraction ascending so smaller fractions are first. 624 let cmp = a.limitFractions[key] - b.limitFractions[key]; 625 // Secondarily sort by index descending so that children with the 626 // same fraction are decremented in reverse order, allowing earlier 627 // children to retain larger spans. 628 return cmp || b.index - a.index; 629 }); 630 } 631 632 // Now increment or decrement individual limits until their sum is equal 633 // to the group's fillable limit. 634 while (summedFillableLimit != fillableLimit) { 635 if (!fractionalDataArray.length) { 636 // This shouldn't happen, but don't let it break us. 637 lazy.logger.error("fractionalDataArray is empty!"); 638 break; 639 } 640 let data = flexDataArray[fractionalDataArray.shift().index]; 641 data.limits[key] += diff; 642 summedFillableLimit += diff; 643 } 644 } 645 } 646 647 return flexDataArray; 648 } 649 650 /** 651 * Adds results to a group using the results from its `RESULT_GROUP` in 652 * `state.resultsByGroup`. 653 * 654 * @param {Values<typeof UrlbarUtils.RESULT_GROUP>} groupConst 655 * The group's `RESULT_GROUP`. 656 * @param {object} limits 657 * An object defining the group's limits as described in `_fillGroup`. 658 * @param {object} state 659 * Global state that we use to make decisions during this sort. 660 * @returns {Array} 661 * `[results, usedLimits, hasMoreResults]` where: 662 * results: A flat array of results in the group, empty if no results 663 * were added. 664 * usedLimits: An object defining the amount of each limit that the 665 * results use. For each possible limit property (see `_fillGroup`), 666 * there will be a corresponding property in this object. For example, 667 * if 3 results are added with a total span of 4, then this object will 668 * be: { maxResultCount: 3, availableSpan: 4 } 669 * hasMoreResults: True if `state.resultsByGroup` has more results of 670 * the same `RESULT_GROUP`. This is not related to the group's limits. 671 */ 672 _addResults(groupConst, limits, state) { 673 let usedLimits = {}; 674 for (let key of Object.keys(limits)) { 675 usedLimits[key] = 0; 676 } 677 678 // For form history, maxHistoricalSearchSuggestions == 0 implies the user 679 // has opted out of form history completely, so we override the max result 680 // count here in that case. Other values of maxHistoricalSearchSuggestions 681 // are ignored and we use the flex defined on the form history group. 682 if ( 683 groupConst == UrlbarUtils.RESULT_GROUP.FORM_HISTORY && 684 !lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions") 685 ) { 686 // Create a new `limits` object so we don't modify the caller's. 687 limits = { ...limits }; 688 limits.maxResultCount = 0; 689 } 690 691 let addedResults = []; 692 let groupResults = state.resultsByGroup.get(groupConst); 693 while ( 694 groupResults?.length && 695 state.usedResultSpan < state.availableResultSpan && 696 [...Object.entries(limits)].every(([k, limit]) => usedLimits[k] < limit) 697 ) { 698 let result = groupResults[0]; 699 if (this._canAddResult(result, state)) { 700 if (!this.#updateUsedLimits(result, limits, usedLimits, state)) { 701 // Adding the result would exceed the group's available span, so stop 702 // adding results to it. Skip the shift() below so the result can be 703 // added to later groups. 704 break; 705 } 706 addedResults.push(result); 707 } 708 709 // We either add or discard results in the order they appear in 710 // `groupResults`, so shift() them off. That way later groups with the 711 // same `RESULT_GROUP` won't include results that earlier groups have 712 // added or discarded. 713 groupResults.shift(); 714 } 715 716 return [addedResults, usedLimits, !!groupResults?.length]; 717 } 718 719 /** 720 * Returns whether a result can be added to its group given the current sort 721 * state. 722 * 723 * @param {UrlbarResult} result 724 * The result. 725 * @param {object} state 726 * Global state that we use to make decisions during this sort. 727 * @returns {boolean} 728 * True if the result can be added and false if it should be discarded. 729 */ 730 // TODO (Bug 1741273): Refactor this method to avoid an eslint complexity 731 // error or increase the complexity threshold. 732 // eslint-disable-next-line complexity 733 _canAddResult(result, state) { 734 // Typically the first visible Suggest result is always added. 735 if (result.providerName == lazy.UrlbarProviderQuickSuggest.name) { 736 if (result.isHiddenExposure) { 737 // Always allow hidden exposure Suggest results. 738 return true; 739 } 740 if ( 741 result.payload.suggestionObject?.suggestionType == "important_dates" 742 ) { 743 // Always allow important date results since they are considered 744 // utility suggestions rather than typical suggestions. 745 // We assume that there will be at most one. 746 return true; 747 } 748 749 if (state.quickSuggestResult && state.quickSuggestResult != result) { 750 // A Suggest result was already added. 751 return false; 752 } 753 754 // Don't add navigational suggestions that dupe the heuristic. 755 let heuristicUrl = state.context.heuristicResult?.payload.url; 756 if ( 757 heuristicUrl && 758 result.payload.telemetryType == "top_picks" && 759 !lazy.UrlbarPrefs.get("experimental.hideHeuristic") 760 ) { 761 let opts = { 762 stripHttp: true, 763 stripHttps: true, 764 stripWww: true, 765 trimSlash: true, 766 }; 767 result.payload.dupedHeuristic = 768 UrlbarUtils.stripPrefixAndTrim(heuristicUrl, opts)[0] == 769 UrlbarUtils.stripPrefixAndTrim(result.payload.url, opts)[0]; 770 return !result.payload.dupedHeuristic; 771 } 772 773 return true; 774 } 775 776 // We expect UrlbarProviderPlaces sent us the highest-ranked www. and non-www 777 // origins, if any. Now, compare them to each other and to the heuristic 778 // result. 779 // 780 // 1. If the heuristic result is lower ranked than both, discard the www 781 // origin, unless it has a different page title than the non-www 782 // origin. This is a guard against deduping when www.site.com and 783 // site.com have different content. 784 // 2. If the heuristic result is higher than either the www origin or 785 // non-www origin: 786 // 2a. If the heuristic is a www origin, discard the non-www origin. 787 // 2b. If the heuristic is a non-www origin, discard the www origin. 788 if ( 789 !result.heuristic && 790 result.type == UrlbarUtils.RESULT_TYPE.URL && 791 result.payload.url 792 ) { 793 let [strippedUrl, prefix] = UrlbarUtils.stripPrefixAndTrim( 794 result.payload.url, 795 { 796 stripHttp: true, 797 stripHttps: true, 798 stripWww: true, 799 trimEmptyQuery: true, 800 } 801 ); 802 let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl); 803 // If the condition below is not met, we are deduping a result against 804 // itself. 805 if ( 806 topPrefixData && 807 (prefix != topPrefixData.prefix || 808 result.providerName != topPrefixData.providerName) 809 ) { 810 let prefixRank = UrlbarUtils.getPrefixRank(prefix); 811 if ( 812 (prefixRank < topPrefixData.rank && 813 (prefix.endsWith("www.") == topPrefixData.prefix.endsWith("www.") || 814 result.payload?.title == topPrefixData.title)) || 815 (prefix == topPrefixData.prefix && 816 result.providerName != topPrefixData.providerName) 817 ) { 818 return false; 819 } 820 } 821 } 822 823 // Discard results that dupe autofill. 824 if ( 825 state.context.heuristicResult && 826 state.context.heuristicResult.autofill && 827 !result.autofill && 828 state.context.heuristicResult.payload?.url == result.payload.url && 829 state.context.heuristicResult.type == result.type && 830 !lazy.UrlbarPrefs.get("experimental.hideHeuristic") 831 ) { 832 return false; 833 } 834 835 // UrlbarProviderHeuristicFallback may add non-heuristic results in some cases, 836 // but those should be retained only if the heuristic result comes from it. 837 if ( 838 !result.heuristic && 839 result.providerName == "UrlbarProviderHeuristicFallback" && 840 state.context.heuristicResult?.providerName != 841 "UrlbarProviderHeuristicFallback" 842 ) { 843 return false; 844 } 845 846 if (result.providerName == "UrlbarProviderTabToSearch") { 847 // Discard the result if a tab-to-search result was added already. 848 if (!state.canAddTabToSearch) { 849 return false; 850 } 851 852 // In cases where the heuristic result is not a URL and we have a 853 // tab-to-search result, the tab-to-search provider determined that the 854 // typed string is similar to an engine domain. We can let the 855 // tab-to-search result through. 856 if (state.context.heuristicResult?.type == UrlbarUtils.RESULT_TYPE.URL) { 857 // Discard the result if the heuristic result is not autofill and we are 858 // not making an exception for a fuzzy match. 859 if ( 860 !state.context.heuristicResult.autofill && 861 !result.payload.satisfiesAutofillThreshold 862 ) { 863 return false; 864 } 865 866 let autofillHostname = new URL( 867 state.context.heuristicResult.payload.url 868 ).hostname; 869 let [autofillDomain] = UrlbarUtils.stripPrefixAndTrim( 870 autofillHostname, 871 { 872 stripWww: true, 873 } 874 ); 875 // Strip the public suffix because we want to allow matching "domain.it" 876 // with "domain.com". 877 autofillDomain = UrlbarUtils.stripPublicSuffixFromHost(autofillDomain); 878 if (!autofillDomain) { 879 return false; 880 } 881 882 // `searchUrlDomainWithoutSuffix` is the engine's domain with the public 883 // suffix already stripped, for example "www.mozilla.". 884 let [engineDomain] = UrlbarUtils.stripPrefixAndTrim( 885 result.payload.searchUrlDomainWithoutSuffix, 886 { 887 stripWww: true, 888 } 889 ); 890 // Discard if the engine domain does not end with the autofilled one. 891 if (!engineDomain.endsWith(autofillDomain)) { 892 return false; 893 } 894 } 895 } 896 897 // Discard "Search in a Private Window" if appropriate. 898 if ( 899 result.type == UrlbarUtils.RESULT_TYPE.SEARCH && 900 result.payload.inPrivateWindow && 901 !state.canShowPrivateSearch 902 ) { 903 return false; 904 } 905 906 // Discard form history and remote suggestions that dupe previously added 907 // suggestions or the heuristic. We do not deduplicate rich suggestions so 908 // they do not visually disapear as the suggestion is completed and 909 // becomes the same url as the heuristic result. 910 if ( 911 result.type == UrlbarUtils.RESULT_TYPE.SEARCH && 912 result.payload.lowerCaseSuggestion && 913 !result.isRichSuggestion 914 ) { 915 let suggestion = result.payload.lowerCaseSuggestion.trim(); 916 if (!suggestion || state.suggestions.has(suggestion)) { 917 return false; 918 } 919 } 920 921 // Discard tail suggestions if appropriate. 922 if ( 923 result.type == UrlbarUtils.RESULT_TYPE.SEARCH && 924 result.payload.tail && 925 !result.isRichSuggestion && 926 !state.canShowTailSuggestions 927 ) { 928 return false; 929 } 930 931 // Discard remote tab results that dupes another remote tab or a 932 // switch-to-tab result. 933 if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) { 934 if (state.addedRemoteTabUrls.has(result.payload.url)) { 935 return false; 936 } 937 let maybeDupeType = state.urlToTabResultType.get(result.payload.url); 938 if (maybeDupeType == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) { 939 return false; 940 } 941 } 942 943 // Discard switch-to-tab results that dupes another switch-to-tab result. 944 if ( 945 result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH && 946 state.addedSwitchTabUrls.has(makeMapKeyForTabResult(result)) 947 ) { 948 return false; 949 } 950 951 // Discard history results that dupe either remote or switch-to-tab results. 952 if ( 953 !result.heuristic && 954 result.type == UrlbarUtils.RESULT_TYPE.URL && 955 result.payload.url && 956 state.urlToTabResultType.has(result.payload.url) 957 ) { 958 return false; 959 } 960 961 // Discard SERPs from browser history that dupe either the heuristic or 962 // previously added suggestions. 963 if ( 964 result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && 965 result.type == UrlbarUtils.RESULT_TYPE.URL && 966 // If there's no suggestions, we're not going to have anything to match 967 // against, so avoid processing the url. 968 state.suggestions.size 969 ) { 970 let submission = Services.search.parseSubmissionURL(result.payload.url); 971 if (submission) { 972 let resultQuery = submission.terms.trim().toLocaleLowerCase(); 973 if (state.suggestions.has(resultQuery)) { 974 // If the result's URL is the same as a brand new SERP URL created 975 // from the query string modulo certain URL params, then treat the 976 // result as a dupe and discard it. 977 let [newSerpURL] = UrlbarUtils.getSearchQueryUrl( 978 submission.engine, 979 submission.terms 980 ); 981 if ( 982 lazy.UrlbarSearchUtils.serpsAreEquivalent( 983 result.payload.url, 984 newSerpURL 985 ) 986 ) { 987 return false; 988 } 989 } 990 } 991 } 992 993 // When in an engine search mode, discard URL results whose hostnames don't 994 // include the root domain of the search mode engine. 995 if (state.context.searchMode?.engineName && result.payload.url) { 996 let engine = Services.search.getEngineByName( 997 state.context.searchMode.engineName 998 ); 999 if (engine) { 1000 let searchModeRootDomain = 1001 lazy.UrlbarSearchUtils.getRootDomainFromEngine(engine); 1002 let resultUrl = new URL(result.payload.url); 1003 // Add a trailing "." to increase the stringency of the check. This 1004 // check covers most general cases. Some edge cases are not covered, 1005 // like `resultUrl` being ebay.mydomain.com, which would escape this 1006 // check if `searchModeRootDomain` was "ebay". 1007 if (!resultUrl.hostname.includes(`${searchModeRootDomain}.`)) { 1008 return false; 1009 } 1010 } 1011 } 1012 1013 // Discard history results that dupe the quick suggest result. 1014 if ( 1015 state.quickSuggestResult && 1016 !result.heuristic && 1017 result.type == UrlbarUtils.RESULT_TYPE.URL && 1018 lazy.QuickSuggest.isUrlEquivalentToResultUrl( 1019 result.payload.url, 1020 state.quickSuggestResult 1021 ) 1022 ) { 1023 return false; 1024 } 1025 1026 // Discard history results whose URLs were originally sponsored. We use the 1027 // presence of a partner's URL search param to detect these. The param is 1028 // defined in the pref below, which is also used for the newtab page. 1029 if ( 1030 result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && 1031 result.type == UrlbarUtils.RESULT_TYPE.URL 1032 ) { 1033 let param = Services.prefs.getCharPref( 1034 "browser.newtabpage.activity-stream.hideTopSitesWithSearchParam" 1035 ); 1036 if (param) { 1037 let [key, value] = param.split("="); 1038 let searchParams = URL.parse(result.payload.url)?.searchParams; 1039 if ( 1040 (value === undefined && searchParams?.has(key)) || 1041 (value !== undefined && searchParams?.getAll(key).includes(value)) 1042 ) { 1043 return false; 1044 } 1045 } 1046 } 1047 1048 // Heuristic results must always be the first result. If this result is a 1049 // heuristic but we've already added results, discard it. Normally this 1050 // should never happen because the standard result groups are set up so 1051 // that there's always at most one heuristic and it's always first, but 1052 // since result groups are stored in a pref and can therefore be modified 1053 // by the user, we perform this check. 1054 if (result.heuristic && state.usedResultSpan) { 1055 return false; 1056 } 1057 1058 // Google search engine might suggest a result for unit conversion with 1059 // format that starts with "= ". If our UnitConversion can provide the 1060 // result, we discard the suggestion of Google in order to deduplicate. 1061 if ( 1062 result.type == UrlbarUtils.RESULT_TYPE.SEARCH && 1063 result.payload.engine == "Google" && 1064 result.payload.suggestion?.startsWith("= ") && 1065 state.hasUnitConversionResult 1066 ) { 1067 return false; 1068 } 1069 1070 // Discard results that have an embedded "url" param with the same value 1071 // as another result's url 1072 if (result.payload.url) { 1073 let urlParams = result.payload.url.split("?").pop(); 1074 let embeddedUrl = new URLSearchParams(urlParams).get("url"); 1075 1076 if (state.addedResultUrls.has(embeddedUrl)) { 1077 return false; 1078 } 1079 } 1080 1081 // Dedupe history results with different ref. 1082 if ( 1083 lazy.UrlbarPrefs.get("deduplication.enabled") && 1084 result.source == UrlbarUtils.RESULT_SOURCE.HISTORY && 1085 result.type == UrlbarUtils.RESULT_TYPE.URL && 1086 !result.heuristic && 1087 result.payload.lastVisit 1088 ) { 1089 let { base, ref } = UrlbarUtils.extractRefFromUrl(result.payload.url); 1090 let baseAndTitle = `${base} ${result.payload.title}`; 1091 let topRef = state.baseAndTitleToTopRef.get(baseAndTitle); 1092 1093 let msSinceLastVisit = Date.now() - result.payload.lastVisit; 1094 let daysSinceLastVisit = msSinceLastVisit / MS_PER_DAY; 1095 let thresholdDays = lazy.UrlbarPrefs.get("deduplication.thresholdDays"); 1096 1097 if (daysSinceLastVisit >= thresholdDays && ref != topRef) { 1098 return false; 1099 } 1100 } 1101 1102 // Include the result. 1103 return true; 1104 } 1105 1106 /** 1107 * Updates the global state that we use to make decisions during sort. This 1108 * should be called for results before we've decided whether to add or discard 1109 * them. 1110 * 1111 * @param {UrlbarResult} result 1112 * The result. 1113 * @param {object} state 1114 * Global state that we use to make decisions during this sort. 1115 */ 1116 _updateStatePreAdd(result, state) { 1117 // Check whether the result should trigger exposure telemetry. If it will 1118 // not be visible because it's a hidden exposure, it should not affect the 1119 // muxer's state, so bail now. 1120 this.#setExposureTelemetryProperty(result); 1121 if (result.isHiddenExposure) { 1122 return; 1123 } 1124 1125 // Keep track of the largest heuristic result span. 1126 if (result.heuristic && this._canAddResult(result, state)) { 1127 state.maxHeuristicResultSpan = Math.max( 1128 state.maxHeuristicResultSpan, 1129 UrlbarUtils.getSpanForResult(result) 1130 ); 1131 } 1132 1133 // Keep track of the total span of global suggestedIndex results so we can 1134 // make room for them at the end of the sort. Tab-to-search results are an 1135 // exception: There can be multiple TTS results but only one will be shown, 1136 // so we track the max TTS span separately. 1137 if ( 1138 result.hasSuggestedIndex && 1139 !result.isSuggestedIndexRelativeToGroup && 1140 this._canAddResult(result, state) 1141 ) { 1142 let span = UrlbarUtils.getSpanForResult(result); 1143 if (result.providerName == "UrlbarProviderTabToSearch") { 1144 state.maxTabToSearchResultSpan = Math.max( 1145 state.maxTabToSearchResultSpan, 1146 span 1147 ); 1148 } else { 1149 state.globalSuggestedIndexResultSpan += span; 1150 } 1151 } 1152 1153 // Save some state we'll use later to dedupe URL results. 1154 if ( 1155 (result.type == UrlbarUtils.RESULT_TYPE.URL || 1156 result.type == UrlbarUtils.RESULT_TYPE.KEYWORD) && 1157 result.payload.url && 1158 (!result.heuristic || !lazy.UrlbarPrefs.get("experimental.hideHeuristic")) 1159 ) { 1160 let [strippedUrl, prefix] = UrlbarUtils.stripPrefixAndTrim( 1161 result.payload.url, 1162 { 1163 stripHttp: true, 1164 stripHttps: true, 1165 stripWww: true, 1166 trimEmptyQuery: true, 1167 } 1168 ); 1169 let prefixRank = UrlbarUtils.getPrefixRank(prefix); 1170 let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl); 1171 let topPrefixRank = topPrefixData ? topPrefixData.rank : -1; 1172 if ( 1173 topPrefixRank < prefixRank || 1174 // If a quick suggest result has the same stripped URL and prefix rank 1175 // as another result, store the quick suggest as the top rank so we 1176 // discard the other during deduping. That happens after the user picks 1177 // the quick suggest: The URL is added to history and later both a 1178 // history result and the quick suggest may match a query. 1179 (topPrefixRank == prefixRank && 1180 result.providerName == lazy.UrlbarProviderQuickSuggest.name) 1181 ) { 1182 // strippedUrl => { prefix, title, rank, providerName } 1183 state.strippedUrlToTopPrefixAndTitle.set(strippedUrl, { 1184 prefix, 1185 title: result.payload.title, 1186 rank: prefixRank, 1187 providerName: result.providerName, 1188 }); 1189 } 1190 } 1191 1192 // Save some state to dedupe results that only differ by the ref of their URL. 1193 // Even though we are considering tab results and URL results of all sources 1194 // here to find the top ref, we will only dedupe URL results with history source. 1195 if ( 1196 result.type == UrlbarUtils.RESULT_TYPE.URL || 1197 result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH 1198 ) { 1199 let { base, ref } = UrlbarUtils.extractRefFromUrl(result.payload.url); 1200 1201 // This is unique because all spaces in base will be url-encoded 1202 // so the part before the space is always the base url. 1203 let baseAndTitle = `${base} ${result.payload.title}`; 1204 1205 // The first result should have the highest frecency so we set it as the top 1206 // ref for its base url. If a result is heuristic, always override an existing 1207 // top ref for its base url in case the heuristic provider was slow and a non 1208 // heuristic result was added first for the same base url. 1209 if (!state.baseAndTitleToTopRef.has(baseAndTitle) || result.heuristic) { 1210 state.baseAndTitleToTopRef.set(baseAndTitle, ref); 1211 } 1212 } 1213 1214 // Save some state we'll use later to dedupe results from open/remote tabs. 1215 if ( 1216 result.payload.url && 1217 (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH || 1218 (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB && 1219 !state.urlToTabResultType.has(makeMapKeyForTabResult(result)))) 1220 ) { 1221 // url => result type 1222 state.urlToTabResultType.set(makeMapKeyForTabResult(result), result.type); 1223 } 1224 1225 // If we find results other than the heuristic, "Search in Private 1226 // Window," or tail suggestions, then we should hide tail suggestions 1227 // since they're a last resort. 1228 if ( 1229 state.canShowTailSuggestions && 1230 !result.heuristic && 1231 (result.type != UrlbarUtils.RESULT_TYPE.SEARCH || 1232 (!result.payload.inPrivateWindow && !result.payload.tail)) 1233 ) { 1234 state.canShowTailSuggestions = false; 1235 } 1236 1237 if ( 1238 result.providerName == lazy.UrlbarProviderQuickSuggest.name && 1239 result.payload.suggestionObject?.suggestionType != "important_dates" 1240 ) { 1241 state.quickSuggestResult ??= result; 1242 } 1243 1244 state.hasUnitConversionResult ||= 1245 result.providerName == "UrlbarProviderUnitConversion"; 1246 1247 // Keep track of result urls to dedupe results with the same url embedded 1248 // in its query string 1249 if (result.payload.url) { 1250 state.addedResultUrls.add(result.payload.url); 1251 } 1252 } 1253 1254 /** 1255 * Updates the global state that we use to make decisions during sort. This 1256 * should be called for results after they've been added. It should not be 1257 * called for discarded results. 1258 * 1259 * @param {UrlbarResult} result 1260 * The result. 1261 * @param {object} state 1262 * Global state that we use to make decisions during this sort. 1263 */ 1264 _updateStatePostAdd(result, state) { 1265 // bail early if the result will be hidden from the final view. 1266 if (result.isHiddenExposure) { 1267 return; 1268 } 1269 1270 // Update heuristic state. 1271 if (result.heuristic) { 1272 state.context.heuristicResult = result; 1273 if ( 1274 result.type == UrlbarUtils.RESULT_TYPE.SEARCH && 1275 result.payload.query && 1276 !lazy.UrlbarPrefs.get("experimental.hideHeuristic") 1277 ) { 1278 let query = result.payload.query.trim().toLocaleLowerCase(); 1279 if (query) { 1280 state.suggestions.add(query); 1281 } 1282 } 1283 } 1284 1285 // The "Search in a Private Window" result should only be shown when there 1286 // are other results and all of them are searches. It should not be shown 1287 // if the user typed an alias because that's an explicit engine choice. 1288 if ( 1289 !Services.search.separatePrivateDefaultUrlbarResultEnabled || 1290 (state.canShowPrivateSearch && 1291 (result.type != UrlbarUtils.RESULT_TYPE.SEARCH || 1292 result.payload.providesSearchMode || 1293 (result.heuristic && result.payload.keyword))) 1294 ) { 1295 state.canShowPrivateSearch = false; 1296 } 1297 1298 // Update suggestions. 1299 if ( 1300 result.type == UrlbarUtils.RESULT_TYPE.SEARCH && 1301 result.payload.lowerCaseSuggestion 1302 ) { 1303 let suggestion = result.payload.lowerCaseSuggestion.trim(); 1304 if (suggestion) { 1305 state.suggestions.add(suggestion); 1306 } 1307 } 1308 1309 // Avoid multiple tab-to-search results. 1310 // TODO (Bug 1670185): figure out better strategies to manage this case. 1311 if (result.providerName == "UrlbarProviderTabToSearch") { 1312 state.canAddTabToSearch = false; 1313 } 1314 1315 // Sync will send us duplicate remote tabs if multiple copies of a tab are 1316 // open on a synced client. Keep track of which remote tabs we've added to 1317 // dedupe these. 1318 if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) { 1319 state.addedRemoteTabUrls.add(result.payload.url); 1320 } 1321 1322 // Keep track of which switch tabs we've added to dedupe switch tabs. 1323 if (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) { 1324 state.addedSwitchTabUrls.add(makeMapKeyForTabResult(result)); 1325 } 1326 } 1327 1328 /** 1329 * Inserts results with suggested indexes. This can be called for either 1330 * global or group-relative suggestedIndex results. It should be called after 1331 * `sortedResults` has been filled in. 1332 * 1333 * @param {Array} suggestedIndexResults 1334 * Results with a `suggestedIndex` property. 1335 * @param {Array} sortedResults 1336 * The sorted results. For global suggestedIndex results, this should be the 1337 * final list of all results before suggestedIndex results are inserted. For 1338 * group-relative suggestedIndex results, this should be the final list of 1339 * results in the group before group-relative suggestedIndex results are 1340 * inserted. 1341 * @param {object} limits 1342 * An object defining span and count limits. See `_fillGroup()`. 1343 * @param {object} state 1344 * Global state that we use to make decisions during this sort. 1345 * @returns {object} 1346 * A `usedLimits` object that describes the total span and count of all the 1347 * added results. See `_addResults`. 1348 */ 1349 _addSuggestedIndexResults( 1350 suggestedIndexResults, 1351 sortedResults, 1352 limits, 1353 state 1354 ) { 1355 let usedLimits = { 1356 availableSpan: 0, 1357 maxResultCount: 0, 1358 }; 1359 1360 if (!suggestedIndexResults?.length) { 1361 // This is just a slight optimization; no need to continue. 1362 return usedLimits; 1363 } 1364 1365 // Partition the results into positive- and negative-index arrays. Positive 1366 // indexes are relative to the start of the list and negative indexes are 1367 // relative to the end. 1368 let positive = []; 1369 let negative = []; 1370 for (let result of suggestedIndexResults) { 1371 let results = result.suggestedIndex < 0 ? negative : positive; 1372 results.push(result); 1373 } 1374 1375 // Sort the positive results ascending so that results at the end of the 1376 // array don't end up offset by later insertions at the front. 1377 positive.sort((a, b) => { 1378 if (a.suggestedIndex !== b.suggestedIndex) { 1379 return a.suggestedIndex - b.suggestedIndex; 1380 } 1381 1382 if (a.providerName === b.providerName) { 1383 if (a.providerName === lazy.UrlbarProviderQuickSuggest.name) { 1384 // The important dates suggestion should be before the other suggestion. 1385 let aIsDate = 1386 a.payload.suggestionObject?.suggestionType === "important_dates"; 1387 let bIsDate = 1388 b.payload.suggestionObject?.suggestionType === "important_dates"; 1389 return Number(aIsDate) - Number(bIsDate); 1390 } 1391 1392 return 0; 1393 } 1394 1395 // If same suggestedIndex, change the displaying order along to following 1396 // provider priority. 1397 // GlobalActions == TabToSearch (legacy) > QuickSuggest > Other providers 1398 if ( 1399 a.providerName === "UrlbarProviderTabToSearch" || 1400 a.providerName === "UrlbarProviderGlobalActions" 1401 ) { 1402 return 1; 1403 } 1404 if ( 1405 b.providerName === "UrlbarProviderTabToSearch" || 1406 b.providerName === "UrlbarProviderGlobalActions" 1407 ) { 1408 return -1; 1409 } 1410 if (a.providerName === lazy.UrlbarProviderQuickSuggest.name) { 1411 return 1; 1412 } 1413 if (b.providerName === lazy.UrlbarProviderQuickSuggest.name) { 1414 return -1; 1415 } 1416 1417 return 0; 1418 }); 1419 1420 // Conversely, sort the negative results descending so that results at the 1421 // front of the array don't end up offset by later insertions at the end. 1422 negative.sort((a, b) => b.suggestedIndex - a.suggestedIndex); 1423 1424 // Insert the results. We start with the positive results because we have 1425 // tests that assume they're inserted first. In practice it shouldn't matter 1426 // because there's no good reason we would ever have a negative result come 1427 // before a positive result in the same query. Even if we did, we have to 1428 // insert one before the other, and there's no right or wrong order. 1429 for (let results of [positive, negative]) { 1430 let prevResult; 1431 let prevIndex; 1432 for (let result of results) { 1433 if (this._canAddResult(result, state)) { 1434 if (!this.#updateUsedLimits(result, limits, usedLimits, state)) { 1435 return usedLimits; 1436 } 1437 1438 let index; 1439 if ( 1440 prevResult && 1441 prevResult.suggestedIndex == result.suggestedIndex 1442 ) { 1443 index = prevIndex; 1444 } else { 1445 index = 1446 result.suggestedIndex >= 0 1447 ? Math.min(result.suggestedIndex, sortedResults.length) 1448 : Math.max(result.suggestedIndex + sortedResults.length + 1, 0); 1449 } 1450 prevResult = result; 1451 prevIndex = index; 1452 sortedResults.splice(index, 0, result); 1453 } 1454 } 1455 } 1456 1457 return usedLimits; 1458 } 1459 1460 /** 1461 * Checks whether adding a result would exceed the given limits. If the limits 1462 * would be exceeded, this returns false and does nothing else. If the limits 1463 * would not be exceeded, the given used limits and state are updated to 1464 * account for the result, true is returned, and the caller should then add 1465 * the result to its list of sorted results. 1466 * 1467 * @param {UrlbarResult} result 1468 * The result. 1469 * @param {object} limits 1470 * An object defining span and count limits. See `_fillGroup()`. 1471 * @param {object} usedLimits 1472 * An object with parallel properties to `limits` that describes how much of 1473 * the limits have been used. See `_addResults()`. 1474 * @param {object} state 1475 * The muxer state. 1476 * @returns {boolean} 1477 * True if the limits were updated and the result can be added and false 1478 * otherwise. 1479 */ 1480 #updateUsedLimits(result, limits, usedLimits, state) { 1481 let span = UrlbarUtils.getSpanForResult(result); 1482 let newUsedSpan = usedLimits.availableSpan + span; 1483 if (limits.availableSpan < newUsedSpan) { 1484 // Adding the result would exceed the available span. 1485 return false; 1486 } 1487 1488 usedLimits.availableSpan = newUsedSpan; 1489 if (span) { 1490 usedLimits.maxResultCount++; 1491 } 1492 1493 state.usedResultSpan += span; 1494 this._updateStatePostAdd(result, state); 1495 1496 return true; 1497 } 1498 1499 /** 1500 * Checks exposure eligibility and visibility for the given result. 1501 * If the result passes the exposure check, we set `result.exposureTelemetry` 1502 * to the appropriate `UrlbarUtils.EXPOSURE_TELEMETRY` value. 1503 * 1504 * @param {UrlbarResult} result 1505 * The result. 1506 */ 1507 #setExposureTelemetryProperty(result) { 1508 const exposureResults = lazy.UrlbarPrefs.get("exposureResults"); 1509 if (exposureResults.size) { 1510 const telemetryType = UrlbarUtils.searchEngagementTelemetryType(result); 1511 if (exposureResults.has(telemetryType)) { 1512 result.exposureTelemetry = lazy.UrlbarPrefs.get("showExposureResults") 1513 ? UrlbarUtils.EXPOSURE_TELEMETRY.SHOWN 1514 : UrlbarUtils.EXPOSURE_TELEMETRY.HIDDEN; 1515 } 1516 } 1517 } 1518 } 1519 1520 export var UrlbarMuxerStandard = new MuxerUnifiedComplete();