mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-02-17 14:25:49 +00:00
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:
parent
8492bcb829
commit
3645ff1757
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
71
browser/components/firefoxview/fxview-search-textbox.css
Normal file
71
browser/components/firefoxview/fxview-search-textbox.css
Normal 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;
|
||||
}
|
76
browser/components/firefoxview/fxview-search-textbox.mjs
Normal file
76
browser/components/firefoxview/fxview-search-textbox.mjs
Normal 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);
|
@ -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);
|
||||
|
@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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")) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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";
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user