Bug 1826328 - Add search capability to view in history r=fxview-reviewers,places-reviewers,fluent-reviewers,flod,mak,sclements

Leverages `AUTOCOMPLETE_MATCH` in order to add search functionality to Fx View History.

https://treeherder.mozilla.org/jobs?repo=try&revision=a45a29326ba22252756cd9d142e9199508b6693b

Differential Revision: https://phabricator.services.mozilla.com/D190844
This commit is contained in:
Jonathan Sudiaman 2023-11-08 02:17:13 +00:00
parent 8492bcb829
commit 3645ff1757
17 changed files with 635 additions and 63 deletions

View File

@ -2825,6 +2825,8 @@ pref("browser.firefox-view.feature-tour", "{\"screen\":\"FIREFOX_VIEW_SPOTLIGHT\
pref("browser.firefox-view.view-count", 0);
// Maximum number of rows to show on the "History" page.
pref("browser.firefox-view.max-history-rows", 300);
// Enables search functionality in Firefox View.
pref("browser.firefox-view.search.enabled", false);
// If the user has seen the pdf.js feature tour this value reflects the tour
// message id, the id of the last screen they saw, and whether they completed the tour

View File

@ -36,6 +36,10 @@
display: none;
}
.card-container-header[toggleDisabled] {
cursor: auto;
}
.view-all-link {
color: var(--fxview-primary-action-background);
float: inline-end;
@ -103,13 +107,24 @@
display: none;
}
::slotted([slot=header]) {
display: flex;
align-items: center;
::slotted([slot=header]),
::slotted([slot=secondary-header]) {
align-self: center;
margin: 0;
font-size: 1.13em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
white-space: nowrap;
}
::slotted([slot=header]) {
flex: 1;
}
::slotted([slot=secondary-header]) {
padding-inline-end: 1em;
}
.card-container-footer {

View File

@ -6,6 +6,7 @@ import {
classMap,
html,
ifDefined,
when,
} from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
@ -19,6 +20,7 @@ import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
* @property {boolean} preserveCollapseState - Whether or not the expanded/collapsed state should persist
* @property {string} shortPageName - Page name that the 'View all' link will navigate to and the preserveCollapseState pref will use
* @property {boolean} showViewAll - True if you need to display a 'View all' header link to navigate
* @property {boolean} toggleDisabled - Optional property given if the card container should not be collapsible
*/
class CardContainer extends MozLitElement {
initiallyExpanded = true;
@ -32,6 +34,7 @@ class CardContainer extends MozLitElement {
preserveCollapseState: { type: Boolean },
shortPageName: { type: String },
showViewAll: { type: Boolean },
toggleDisabled: { type: Boolean },
};
static queries = {
@ -108,40 +111,70 @@ class CardContainer extends MozLitElement {
aria-labelledby="header"
aria-label=${ifDefined(this.sectionLabel)}
>
<details
class=${classMap({
"card-container": true,
inner: this.isInnerCard,
"empty-state": this.isEmptyState && !this.isInnerCard,
})}
?open=${this.isExpanded}
@toggle=${this.onToggleContainer}
>
<summary
id="header"
class="card-container-header"
?hidden=${ifDefined(this.hideHeader)}
?withViewAll=${this.showViewAll}
${when(
this.toggleDisabled,
() => html`<div
class=${classMap({
"card-container": true,
inner: this.isInnerCard,
"empty-state": this.isEmptyState && !this.isInnerCard,
})}
>
<span
class="icon chevron-icon"
aria-role="presentation"
data-l10n-id="firefoxview-collapse-button-${this.isExpanded
? "hide"
: "show"}"
></span>
<slot name="header"></slot>
</summary>
<a
href="about:firefoxview-next#${this.shortPageName}"
@click=${this.viewAllClicked}
class="view-all-link"
data-l10n-id="firefoxview-view-all-link"
?hidden=${!this.showViewAll}
></a>
<slot name="main"></slot>
<slot name="footer" class="card-container-footer"></slot>
</details>
id="header"
class="card-container-header"
?hidden=${ifDefined(this.hideHeader)}
toggleDisabled
?withViewAll=${this.showViewAll}
>
<slot name="header"></slot>
<slot name="secondary-header"></slot>
</span>
<a
href="about:firefoxview-next#${this.shortPageName}"
@click=${this.viewAllClicked}
class="view-all-link"
data-l10n-id="firefoxview-view-all-link"
?hidden=${!this.showViewAll}
></a>
<slot name="main"></slot>
<slot name="footer" class="card-container-footer"></slot>
</div>`,
() => html`<details
class=${classMap({
"card-container": true,
inner: this.isInnerCard,
"empty-state": this.isEmptyState && !this.isInnerCard,
})}
?open=${this.isExpanded}
@toggle=${this.onToggleContainer}
>
<summary
id="header"
class="card-container-header"
?hidden=${ifDefined(this.hideHeader)}
?withViewAll=${this.showViewAll}
>
<span
class="icon chevron-icon"
aria-role="presentation"
data-l10n-id="firefoxview-collapse-button-${this.isExpanded
? "hide"
: "show"}"
></span>
<slot name="header"></slot>
</summary>
<a
href="about:firefoxview-next#${this.shortPageName}"
@click=${this.viewAllClicked}
class="view-all-link"
data-l10n-id="firefoxview-view-all-link"
?hidden=${!this.showViewAll}
></a>
<slot name="main"></slot>
<slot name="footer" class="card-container-footer"></slot>
</details>`
)}
</section>
`;
}

View File

@ -86,14 +86,16 @@ export class FirefoxViewPlacesQuery extends PlacesQuery {
return visitsPerMonth;
}
appendToCache(visit) {
super.appendToCache(visit);
formatRowAsVisit(row) {
const visit = super.formatRowAsVisit(row);
this.#normalizeVisit(visit);
return visit;
}
insertSortedIntoCache(visit) {
super.insertSortedIntoCache(visit);
formatEventAsVisit(event) {
const visit = super.formatEventAsVisit(event);
this.#normalizeVisit(visit);
return visit;
}
/**

View File

@ -20,6 +20,16 @@
justify-content: center;
}
[slot="main"].imageHidden .image-container {
display: none;
}
[slot="main"].imageHidden .main {
display: flex;
flex: 1;
justify-content: center;
}
.image-container {
min-width: 150px;
text-align: center;

View File

@ -15,9 +15,10 @@ import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
*
* @property {string} headerIconUrl - (Optional) The chrome:// url for an icon to be displayed within the header
* @property {string} headerLabel - (Optional) The l10n id for the header text for the empty/error state
* @property {object} headerArgs - (Optional) The l10n args for the header text for the empty/error state
* @property {string} isInnerCard - (Optional) True if the card is displayed within another card and needs a border instead of box shadow
* @property {boolean} isSelectedTab - (Optional) True if the component is the selected navigation tab - defaults to false
* @property {Array} descriptionLabels - (Required) An array of l10n ids for the secondary description text for the empty/error state
* @property {Array} descriptionLabels - (Optional) An array of l10n ids for the secondary description text for the empty/error state
* @property {object} descriptionLink - (Optional) An object describing the l10n name and url needed within a description label
* @property {string} mainImageUrl - (Optional) The chrome:// url for the main image of the empty/error state
* @property {string} errorGrayscale - (Optional) The image should be shown in gray scale
@ -26,10 +27,13 @@ class FxviewEmptyState extends MozLitElement {
constructor() {
super();
this.isSelectedTab = false;
this.descriptionLabels = [];
this.headerArgs = {};
}
static properties = {
headerLabel: { type: String },
headerArgs: { type: Object },
headerIconUrl: { type: String },
isInnerCard: { type: Boolean },
isSelectedTab: { type: Boolean },
@ -65,7 +69,10 @@ class FxviewEmptyState extends MozLitElement {
<card-container hideHeader="true" exportparts="image" ?isInnerCard="${
this.isInnerCard
}" id="card-container" isEmptyState="true">
<div slot="main" class=${this.isSelectedTab ? "selectedTab" : null}>
<div slot="main" class=${classMap({
selectedTab: this.isSelectedTab,
imageHidden: !this.mainImageUrl,
})}>
<div class="image-container">
<img class=${classMap({
image: true,
@ -84,7 +91,10 @@ class FxviewEmptyState extends MozLitElement {
>
<img class="icon info" ?hidden=${!this
.headerIconUrl} src=${ifDefined(this.headerIconUrl)}></img>
<span data-l10n-id="${this.headerLabel}"></span>
<span
data-l10n-id="${this.headerLabel}"
data-l10n-args="${JSON.stringify(this.headerArgs)}">
</span>
</h2>
${repeat(
this.descriptionLabels,

View File

@ -0,0 +1,71 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
.search-container {
border: 1px solid var(--fxview-border);
color: var(--fxview-text-primary-color);
position: relative;
}
.search-icon {
background-image: url(chrome://global/skin/icons/search-textbox.svg);
background-position: center;
background-repeat: no-repeat;
background-size: 16px;
fill: currentColor;
-moz-context-properties: fill;
height: 16px;
width: 16px;
position: absolute;
top: 0;
bottom: 0;
margin: auto 0;
padding: 2px;
}
.search-icon:dir(ltr) {
left: 8px;
}
.search-icon:dir(rtl) {
right: 8px;
}
input {
border: none;
padding-block-start: 8px;
padding-block-end: 8px;
padding-inline-start: 32px;
padding-inline-end: 32px;
}
.clear-icon {
background-image: url(chrome://global/skin/icons/close-12.svg);
background-position: center;
background-repeat: no-repeat;
background-size: 16px;
fill: currentColor;
-moz-context-properties: fill;
cursor: pointer;
height: 16px;
width: 16px;
position: absolute;
top: 0;
bottom: 0;
margin: auto 0;
padding: 2px;
}
.clear-icon:hover {
background-color: var(--fxview-element-background-hover);
color: var(--fxview-text-color-hover);
}
.clear-icon:dir(ltr) {
right: 8px;
}
.clear-icon:dir(rtl) {
left: 8px;
}

View File

@ -0,0 +1,76 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
/**
* A search box that displays a search icon and is clearable. Updates to the
* search query trigger a `fxview-search-textbox-query` event with the current
* query value.
*
* @property {string} placeholder
* The placeholder text for the search box.
* @property {string} query
* The query that is currently in the search box.
*/
export default class FxviewSearchTextbox extends MozLitElement {
static properties = {
placeholder: { type: String },
query: { type: String },
};
static queries = {
clearButton: ".clear-icon",
};
constructor() {
super();
this.query = "";
}
onInput(event) {
this.query = event.target.value.trim();
event.preventDefault();
this.#dispatchQueryEvent();
}
clear(event) {
this.query = "";
event.preventDefault();
this.#dispatchQueryEvent();
}
#dispatchQueryEvent() {
this.dispatchEvent(
new CustomEvent("fxview-search-textbox-query", {
bubbles: true,
composed: true,
detail: { query: this.query },
})
);
}
render() {
return html`
<link rel="stylesheet" href="chrome://browser/content/firefoxview/fxview-search-textbox.css" />
<div class="search-container">
<div class="search-icon"></div>
<input
type="search"
.placeholder=${ifDefined(this.placeholder)}
.value=${this.query}
@input=${this.onInput}
></input>
<div
class="clear-icon"
?hidden=${!this.query}
@click=${this.clear}
data-l10n-id="firefoxview-search-text-box-clear-button"
></div>
</div>`;
}
}
customElements.define("fxview-search-textbox", FxviewSearchTextbox);

View File

@ -37,6 +37,7 @@ if (!window.IS_STORYBOOK) {
* @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required
* @property {number} maxTabsLength - The max number of tabs for the list
* @property {Array} tabItems - Items to show in the tab list
* @property {string} searchQuery - The query string to highlight, if provided.
*/
export default class FxviewTabList extends MozLitElement {
constructor() {
@ -63,6 +64,7 @@ export default class FxviewTabList extends MozLitElement {
maxTabsLength: { type: Number },
tabItems: { type: Array },
visible: { type: Boolean },
searchQuery: { type: String },
};
static queries = {
@ -227,6 +229,9 @@ export default class FxviewTabList extends MozLitElement {
// Can set maxTabsLength to -1 to have no max
this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
}
if (this.searchQuery && this.tabItems.length === 0) {
return this.#emptySearchResultsTemplate();
}
const {
activeIndex,
currentActiveElementId,
@ -279,6 +284,7 @@ export default class FxviewTabList extends MozLitElement {
.timeMsPref=${ifDefined(this.timeMsPref)}
.title=${tabItem.title}
.url=${ifDefined(tabItem.url)}
.searchQuery=${ifDefined(this.searchQuery)}
>
</fxview-tab-row>
`;
@ -287,6 +293,15 @@ export default class FxviewTabList extends MozLitElement {
<slot name="menu"></slot>
`;
}
#emptySearchResultsTemplate() {
return html` <fxview-empty-state
headerLabel="firefoxview-search-results-empty"
.headerArgs=${{ query: this.searchQuery }}
isInnerCard
>
</fxview-empty-state>`;
}
}
customElements.define("fxview-tab-list", FxviewTabList);
@ -311,6 +326,7 @@ customElements.define("fxview-tab-list", FxviewTabList);
* @property {string} title - The title for the tab item.
* @property {string} url - The url for the tab item.
* @property {number} timeMsPref - The frequency in milliseconds of updates to relative time
* @property {string} searchQuery - The query string to highlight, if provided.
*/
export class FxviewTabRow extends MozLitElement {
constructor() {
@ -338,6 +354,7 @@ export class FxviewTabRow extends MozLitElement {
title: { type: String },
timeMsPref: { type: Number },
url: { type: String },
searchQuery: { type: String },
};
static queries = {
@ -520,7 +537,11 @@ export class FxviewTabRow extends MozLitElement {
id="fxview-tab-row-title"
dir="auto"
>
${title}
${when(
this.searchQuery,
() => this.#highlightSearchMatches(this.searchQuery, title),
() => title
)}
</span>
<span
class="fxview-tab-row-url text-truncated-ellipsis"
@ -569,6 +590,35 @@ export class FxviewTabRow extends MozLitElement {
)}
`;
}
/**
* Find all matches of query within the given string, and compute the result
* to be rendered.
*
* @param {string} query
* @param {string} string
*/
#highlightSearchMatches(query, string) {
const fragments = [];
const regex = RegExp(this.#escapeRegExp(query), "dgi");
let prevIndexEnd = 0;
let result;
while ((result = regex.exec(string)) !== null) {
const [indexStart, indexEnd] = result.indices[0];
fragments.push(string.substring(prevIndexEnd, indexStart));
fragments.push(
html`<strong>${string.substring(indexStart, indexEnd)}</strong>`
);
prevIndexEnd = regex.lastIndex;
}
fragments.push(string.substring(prevIndexEnd));
return fragments;
}
// from MDN...
#escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
}
customElements.define("fxview-tab-row", FxviewTabRow);

View File

@ -2,7 +2,11 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
import {
html,
ifDefined,
when,
} from "chrome://global/content/vendor/lit.all.mjs";
import { ViewPage } from "./viewpage.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/migration/migration-wizard.mjs";
@ -11,6 +15,7 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
FirefoxViewPlacesQuery:
"resource:///modules/firefox-view-places-query.sys.mjs",
PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
@ -21,11 +26,26 @@ let XPCOMUtils = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
).XPCOMUtils;
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"searchEnabledPref",
"browser.firefox-view.search.enabled"
);
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"maxRowsPref",
"browser.firefox-view.max-history-rows",
-1
);
const NEVER_REMEMBER_HISTORY_PREF = "browser.privatebrowsing.autostart";
const HAS_IMPORTED_HISTORY_PREF = "browser.migrate.interactions.history";
const IMPORT_HISTORY_DISMISSED_PREF =
"browser.tabs.firefox-view.importHistory.dismissed";
const SEARCH_DEBOUNCE_RATE_MS = 500;
const SEARCH_DEBOUNCE_TIMEOUT_MS = 1000;
class HistoryInView extends ViewPage {
constructor() {
super();
@ -35,6 +55,8 @@ class HistoryInView extends ViewPage {
// Setting maxTabsLength to -1 for no max
this.maxTabsLength = -1;
this.placesQuery = new lazy.FirefoxViewPlacesQuery();
this.searchQuery = "";
this.searchResults = null;
this.sortOption = "date";
this.profileAge = 8;
this.fullyUpdated = false;
@ -70,6 +92,11 @@ class HistoryInView extends ViewPage {
// Convert milliseconds to days
this.profileAge = profileAge / 1000 / 60 / 60 / 24;
}
this.searchTask = new lazy.DeferredTask(
() => this.#updateSearchResults(),
SEARCH_DEBOUNCE_RATE_MS,
SEARCH_DEBOUNCE_TIMEOUT_MS
);
}
disconnectedCallback() {
@ -79,6 +106,9 @@ class HistoryInView extends ViewPage {
"MigrationWizard:Close",
this.migrationWizardDialog
);
if (!this.searchTask.isFinalized) {
this.searchTask.finalize();
}
}
async #updateAllHistoryItems(allHistoryItems) {
@ -89,6 +119,22 @@ class HistoryInView extends ViewPage {
}
this.resetHistoryMaps();
this.lists.forEach(list => list.requestUpdate());
await this.#updateSearchResults();
}
async #updateSearchResults() {
if (this.searchQuery) {
try {
this.searchResults = await this.placesQuery.searchHistory(
this.searchQuery,
lazy.maxRowsPref
);
} catch (e) {
// Connection interrupted, ignore.
}
} else {
this.searchResults = null;
}
}
viewTabVisibleCallback() {
@ -106,6 +152,7 @@ class HistoryInView extends ViewPage {
emptyState: "fxview-empty-state",
lists: { all: "fxview-tab-list" },
showAllHistoryBtn: ".show-all-history-button",
searchTextbox: "fxview-search-textbox",
sortInputs: { all: "input[name=history-sort-option]" },
panelList: "panel-list",
};
@ -117,6 +164,7 @@ class HistoryInView extends ViewPage {
historyMapBySite: { type: Array },
// Making profileAge a reactive property for testing
profileAge: { type: Number },
searchResults: { type: Array },
sortOption: { type: String },
};
@ -128,10 +176,7 @@ class HistoryInView extends ViewPage {
async updateHistoryData() {
this.allHistoryItems = await this.placesQuery.getHistory({
daysOld: 60,
limit: Services.prefs.getIntPref(
"browser.firefox-view.max-history-rows",
-1
),
limit: lazy.maxRowsPref,
sortBy: this.sortOption,
});
}
@ -237,6 +282,7 @@ class HistoryInView extends ViewPage {
}
);
await this.updateHistoryData();
await this.#updateSearchResults();
}
showAllHistory() {
@ -326,7 +372,19 @@ class HistoryInView extends ViewPage {
`;
}
historyCardsTemplate() {
/**
* The template to use for cards-container.
*/
get cardsTemplate() {
if (this.searchResults) {
return this.#searchResultsTemplate();
} else if (this.allHistoryItems.size) {
return this.#historyCardsTemplate();
}
return this.#emptyMessageTemplate();
}
#historyCardsTemplate() {
let cardsTemplate = [];
if (this.sortOption === "date" && this.historyMapByDate.length) {
this.historyMapByDate.forEach(historyItem => {
@ -381,7 +439,7 @@ class HistoryInView extends ViewPage {
return cardsTemplate;
}
emptyMessageTemplate() {
#emptyMessageTemplate() {
let descriptionHeader;
let descriptionLabels;
let descriptionLink;
@ -420,6 +478,51 @@ class HistoryInView extends ViewPage {
`;
}
#searchResultsTemplate() {
return html` <card-container toggleDisabled>
<h3
slot="header"
data-l10n-id="firefoxview-search-results-header"
data-l10n-args=${JSON.stringify({
query: this.#escapeHtmlEntities(this.searchQuery),
})}
></h3>
${when(
this.searchResults.length,
() =>
html`<h3
slot="secondary-header"
data-l10n-id="firefoxview-search-results-count"
data-l10n-args="${JSON.stringify({
count: this.searchResults.length,
})}"
></h3>`
)}
<fxview-tab-list
slot="main"
class="with-context-menu"
dateTimeFormat="dateTime"
hasPopup="menu"
maxTabsLength="-1"
.searchQuery=${this.searchQuery}
.tabItems=${this.searchResults}
@fxview-tab-list-primary-action=${this.onPrimaryAction}
@fxview-tab-list-secondary-action=${this.onSecondaryAction}
>
${this.panelListTemplate()}
</fxview-tab-list>
</card-container>`;
}
#escapeHtmlEntities(text) {
return (text || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
render() {
if (!this.selectedTab) {
return null;
@ -440,6 +543,17 @@ class HistoryInView extends ViewPage {
data-l10n-id="firefoxview-history-header"
></h2>
<div class="history-sort-options">
${when(
lazy.searchEnabledPref,
() => html` <div class="history-sort-option">
<fxview-search-textbox
.query=${this.searchQuery}
data-l10n-id="firefoxview-search-text-box-history"
data-l10n-attrs="placeholder"
@fxview-search-textbox-query=${this.onSearchQuery}
></fxview-search-textbox>
</div>`
)}
<div class="history-sort-option">
<input
type="radio"
@ -497,9 +611,7 @@ class HistoryInView extends ViewPage {
</div>
</div>
</card-container>
${!this.allHistoryItems.size
? this.emptyMessageTemplate()
: this.historyCardsTemplate()}
${this.cardsTemplate}
</div>
<div
class="show-all-history-footer"
@ -509,11 +621,17 @@ class HistoryInView extends ViewPage {
class="show-all-history-button"
data-l10n-id="firefoxview-show-all-history"
@click=${this.showAllHistory}
?hidden=${this.searchResults}
></button>
</div>
`;
}
async onSearchQuery(e) {
this.searchQuery = e.detail.query;
this.searchTask.arm();
}
willUpdate(changedProperties) {
this.fullyUpdated = false;
if (this.allHistoryItems.size && !changedProperties.has("sortOption")) {

View File

@ -24,6 +24,8 @@ browser.jar:
content/browser/firefoxview/fxview-empty-state.css
content/browser/firefoxview/fxview-empty-state.mjs
content/browser/firefoxview/helpers.mjs
content/browser/firefoxview/fxview-search-textbox.css
content/browser/firefoxview/fxview-search-textbox.mjs
content/browser/firefoxview/fxview-tab-list.css
content/browser/firefoxview/fxview-tab-list.mjs
content/browser/firefoxview/fxview-tab-row.css

View File

@ -153,7 +153,12 @@ async function addHistoryItems(dateAdded) {
}
add_setup(async () => {
await SpecialPowers.pushPrefEnv({ set: [[FXVIEW_NEXT_ENABLED_PREF, true]] });
await SpecialPowers.pushPrefEnv({
set: [
[FXVIEW_NEXT_ENABLED_PREF, true],
["browser.firefox-view.search.enabled", true],
],
});
registerCleanupFunction(async () => {
await SpecialPowers.popPrefEnv();
await PlacesUtils.history.clear();
@ -415,3 +420,52 @@ add_task(async function test_show_all_history_telemetry() {
gBrowser.removeTab(gBrowser.selectedTab);
});
});
add_task(async function test_search_history() {
await withFirefoxView({}, async browser => {
const { document } = browser.contentWindow;
navigateToCategory(document, "history");
const historyComponent = document.querySelector("view-history");
historyComponent.profileAge = 8;
await historyComponentReady(historyComponent);
const searchTextbox = await TestUtils.waitForCondition(
() => historyComponent.searchTextbox,
"The search textbox is displayed."
);
info("Input a search query.");
EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
EventUtils.sendString("Example Domain 1", content);
await BrowserTestUtils.waitForMutationCondition(
historyComponent.shadowRoot,
{ childList: true, subtree: true },
() =>
historyComponent.cards.length === 1 &&
document.l10n.getAttributes(
historyComponent.cards[0].querySelector("[slot=header]")
).id === "firefoxview-search-results-header"
);
await TestUtils.waitForCondition(() => {
const { rowEls } = historyComponent.lists[0];
return rowEls.length === 1 && rowEls[0].mainEl.href === URLs[0];
}, "There is one matching search result.");
info("Input a bogus search query.");
EventUtils.synthesizeMouseAtCenter(searchTextbox, {}, content);
EventUtils.sendString("Bogus Query", content);
await TestUtils.waitForCondition(() => {
const tabList = historyComponent.lists[0];
return tabList?.shadowRoot.querySelector("fxview-empty-state");
}, "There are no matching search results.");
info("Clear the search query.");
EventUtils.synthesizeMouseAtCenter(searchTextbox.clearButton, {}, content);
await BrowserTestUtils.waitForMutationCondition(
historyComponent.shadowRoot,
{ childList: true, subtree: true },
() =>
historyComponent.cards.length ===
historyComponent.historyMapByDate.length
);
});
});

View File

@ -9,6 +9,8 @@ import "chrome://browser/content/firefoxview/card-container.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/firefoxview/fxview-empty-state.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/firefoxview/fxview-search-textbox.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/firefoxview/fxview-tab-list.mjs";
import { placeLinkOnClipboard } from "./helpers.mjs";

View File

@ -88,6 +88,7 @@ You'll need to pass along some of the following properties:
* `time` (**Optional**) - The time in milliseconds for expected last interaction with the tab (Ex: `lastUsed` for SyncedTabs tabs, `closedAt` for RecentlyClosed tabs, etc.)
* `title` (**Required**) - The title for the tab
* `url` (**Optional**) - The full URL for the tab
* `searchQuery` (**Optional**) - Highlights matches of the query string for titles of each row.
### Notes

View File

@ -178,6 +178,30 @@ firefoxview-opentabs-focus-tab =
firefoxview-show-more = Show more
firefoxview-show-less = Show less
firefoxview-search-text-box-clear-button =
.title = Clear
# Placeholder for the input field to search in history ("search" is a verb).
firefoxview-search-text-box-history =
.placeholder = Search history
# "Search" is a noun (as in "Results of the search for")
# Variables:
# $query (String) - The search query used for searching through browser history.
firefoxview-search-results-header = Search results for “{ $query }”
# Variables:
# $count (Number) - The number of visits matching the search query.
firefoxview-search-results-count = { $count ->
[one] { $count } site
*[other] { $count } sites
}
# Message displayed when a search is performed and no matching results were found.
# Variables:
# $query (String) - The search query.
firefoxview-search-results-empty = No results for “{ $query }”
firefoxview-sort-history-by-date-label = Sort by date
firefoxview-sort-history-by-site-label = Sort by site

View File

@ -58,6 +58,7 @@ export class PlacesQuery {
#historyListenerCallback = null;
/** @type {DeferredTask} */
#historyObserverTask = null;
#searchInProgress = false;
/**
* Get a snapshot of history visits at this moment.
@ -130,11 +131,57 @@ export class PlacesQuery {
LIMIT ${limit > 0 ? limit : -1}`;
const rows = await db.executeCached(sql);
for (const row of rows) {
this.appendToCache({
date: lazy.PlacesUtils.toDate(row.getResultByName("visit_date")),
title: row.getResultByName("title"),
url: row.getResultByName("url"),
const visit = this.formatRowAsVisit(row);
this.appendToCache(visit);
}
}
/**
* Search the database for visits matching a search query. This does not
* affect internal caches, and observers will not be notified of search
* results obtained from this query.
*
* @param {string} query
* The search query.
* @param {number} [limit]
* The maximum number of visits to return.
* @returns {HistoryVisit[]}
* The matching visits.
*/
async searchHistory(query, limit) {
const { sortBy } = this.cachedHistoryOptions;
const db = await lazy.PlacesUtils.promiseLargeCacheDBConnection();
let orderBy;
switch (sortBy) {
case "date":
orderBy = "visit_date DESC";
break;
case "site":
orderBy = "url";
break;
}
const sql = `SELECT MAX(visit_date) as visit_date, title, url
FROM moz_historyvisits v
JOIN moz_places h
ON v.place_id = h.id
WHERE AUTOCOMPLETE_MATCH(:query, url, title, NULL, 1, 1, 1, 1, :matchBehavior, :searchBehavior, NULL)
AND hidden = 0
GROUP BY url
ORDER BY ${orderBy}
LIMIT ${limit > 0 ? limit : -1}`;
if (this.#searchInProgress) {
db.interrupt();
}
try {
this.#searchInProgress = true;
const rows = await db.executeCached(sql, {
query,
matchBehavior: Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED,
searchBehavior: Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY,
});
return rows.map(row => this.formatRowAsVisit(row));
} finally {
this.#searchInProgress = false;
}
}
@ -326,11 +373,7 @@ export class PlacesQuery {
if (event.hidden) {
return null;
}
const visit = {
date: new Date(event.visitTime),
title: event.lastKnownTitle,
url: event.url,
};
const visit = this.formatEventAsVisit(event);
this.insertSortedIntoCache(visit);
return visit;
}
@ -380,4 +423,36 @@ export class PlacesQuery {
getStartOfMonthTimestamp(date) {
return new Date(date.getFullYear(), date.getMonth()).getTime();
}
/**
* Format a database row as a history visit.
*
* @param {mozIStorageRow} row
* The row to format.
* @returns {HistoryVisit}
* The resulting history visit.
*/
formatRowAsVisit(row) {
return {
date: lazy.PlacesUtils.toDate(row.getResultByName("visit_date")),
title: row.getResultByName("title"),
url: row.getResultByName("url"),
};
}
/**
* Format a page visited event as a history visit.
*
* @param {PlacesEvent} event
* The event to format.
* @returns {HistoryVisit}
* The resulting history visit.
*/
formatEventAsVisit(event) {
return {
date: new Date(event.visitTime),
title: event.lastKnownTitle,
url: event.url,
};
}
}

View File

@ -216,3 +216,30 @@ add_task(async function test_dedupe_visits_by_url() {
await PlacesUtils.history.clear();
});
add_task(async function test_search_visits() {
const now = new Date();
await PlacesUtils.history.insertMany([
{
url: "https://www.example.com/",
title: "First Visit",
visits: [{ date: now }],
},
{
url: "https://example.net/",
title: "Second Visit",
visits: [{ date: now }],
},
]);
let results = await placesQuery.searchHistory("Visit");
Assert.equal(results.length, 2, "Both visits match the search query.");
results = await placesQuery.searchHistory("First Visit");
Assert.equal(results.length, 1, "One visit matches the search query.");
results = await placesQuery.searchHistory("Bogus");
Assert.equal(results.length, 0, "Neither visit matches the search query.");
await PlacesUtils.history.clear();
});