| // Copyright 2016 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'chrome://resources/cr_components/history_clusters/clusters.js'; |
| import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js'; |
| import 'chrome://resources/cr_elements/cr_shared_style.css.js'; |
| import 'chrome://resources/cr_elements/cr_shared_vars.css.js'; |
| import 'chrome://resources/cr_elements/cr_tabs/cr_tabs.js'; |
| import 'chrome://resources/polymer/v3_0/iron-media-query/iron-media-query.js'; |
| import 'chrome://resources/polymer/v3_0/iron-pages/iron-pages.js'; |
| import './history_list.js'; |
| import './history_toolbar.js'; |
| import './query_manager.js'; |
| import './shared_style.css.js'; |
| import './side_bar.js'; |
| import './strings.m.js'; |
| |
| import {CrDrawerElement} from 'chrome://resources/cr_elements/cr_drawer/cr_drawer.js'; |
| import {CrLazyRenderElement} from 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.js'; |
| import {FindShortcutMixin, FindShortcutMixinInterface} from 'chrome://resources/cr_elements/find_shortcut_mixin.js'; |
| import {WebUiListenerMixin, WebUiListenerMixinInterface} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js'; |
| import {assert} from 'chrome://resources/js/assert_ts.js'; |
| import {EventTracker} from 'chrome://resources/js/event_tracker.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.js'; |
| import {getTrustedScriptURL} from 'chrome://resources/js/static_types.js'; |
| import {hasKeyModifiers} from 'chrome://resources/js/util_ts.js'; |
| import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js'; |
| import {IronPagesElement} from 'chrome://resources/polymer/v3_0/iron-pages/iron-pages.js'; |
| import {IronScrollTargetBehavior} from 'chrome://resources/polymer/v3_0/iron-scroll-target-behavior/iron-scroll-target-behavior.js'; |
| import {mixinBehaviors, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {getTemplate} from './app.html.js'; |
| import {BrowserService, BrowserServiceImpl} from './browser_service.js'; |
| import {HistoryPageViewHistogram} from './constants.js'; |
| import {ForeignSession, QueryResult, QueryState} from './externs.js'; |
| import {HistoryListElement} from './history_list.js'; |
| import {HistoryToolbarElement} from './history_toolbar.js'; |
| import {Page, TABBED_PAGES} from './router.js'; |
| import {FooterInfo, HistorySideBarElement} from './side_bar.js'; |
| |
| let lazyLoadPromise: Promise<void>|null = null; |
| export function ensureLazyLoaded(): Promise<void> { |
| if (!lazyLoadPromise) { |
| const script = document.createElement('script'); |
| script.type = 'module'; |
| script.src = getTrustedScriptURL`./lazy_load.js` as unknown as string; |
| document.body.appendChild(script); |
| |
| lazyLoadPromise = Promise.all([ |
| customElements.whenDefined('history-synced-device-manager'), |
| customElements.whenDefined('cr-action-menu'), |
| customElements.whenDefined('cr-button'), |
| customElements.whenDefined('cr-checkbox'), |
| customElements.whenDefined('cr-dialog'), |
| customElements.whenDefined('cr-drawer'), |
| customElements.whenDefined('cr-icon-button'), |
| customElements.whenDefined('cr-toolbar-selection-overlay'), |
| ]) as unknown as Promise<void>; |
| } |
| return lazyLoadPromise; |
| } |
| |
| // Adds click/auxclick listeners for any link on the page. If the link points |
| // to a chrome: or file: url, then calls into the browser to do the |
| // navigation. Note: This method is *not* re-entrant. Every call to it, will |
| // re-add listeners on |document|. It's up to callers to ensure this is only |
| // called once. |
| export function listenForPrivilegedLinkClicks() { |
| ['click', 'auxclick'].forEach(function(eventName) { |
| document.addEventListener(eventName, function(evt: Event) { |
| const e = evt as MouseEvent; |
| // Ignore buttons other than left and middle. |
| if (e.button > 1 || e.defaultPrevented) { |
| return; |
| } |
| |
| const eventPath = e.composedPath() as HTMLElement[]; |
| let anchor: HTMLAnchorElement|null = null; |
| if (eventPath) { |
| for (let i = 0; i < eventPath.length; i++) { |
| const element = eventPath[i]; |
| if (element.tagName === 'A' && (element as HTMLAnchorElement).href) { |
| anchor = element as HTMLAnchorElement; |
| break; |
| } |
| } |
| } |
| |
| // Fallback if Event.path is not available. |
| let el = e.target as HTMLElement; |
| if (!anchor && el.nodeType === Node.ELEMENT_NODE && |
| el.webkitMatchesSelector('A, A *')) { |
| while (el.tagName !== 'A') { |
| el = el.parentElement as HTMLElement; |
| } |
| anchor = el as HTMLAnchorElement; |
| } |
| |
| if (!anchor) { |
| return; |
| } |
| |
| if ((anchor.protocol === 'file:' || anchor.protocol === 'about:') && |
| (e.button === 0 || e.button === 1)) { |
| BrowserServiceImpl.getInstance().navigateToUrl( |
| anchor.href, anchor.target, e); |
| e.preventDefault(); |
| } |
| }); |
| }); |
| } |
| |
| declare global { |
| interface Window { |
| // https://github.com/microsoft/TypeScript/issues/40807 |
| requestIdleCallback(callback: () => void): void; |
| } |
| } |
| |
| export interface HistoryAppElement { |
| $: { |
| 'content': IronPagesElement, |
| 'content-side-bar': HistorySideBarElement, |
| 'drawer': CrLazyRenderElement<CrDrawerElement>, |
| 'history': HistoryListElement, |
| 'tabs-container': Element, |
| 'tabs-content': IronPagesElement, |
| 'toolbar': HistoryToolbarElement, |
| }; |
| } |
| |
| const HistoryAppElementBase = |
| mixinBehaviors( |
| [IronScrollTargetBehavior], |
| FindShortcutMixin(WebUiListenerMixin(PolymerElement))) as { |
| new (): PolymerElement & FindShortcutMixinInterface & |
| IronScrollTargetBehavior & WebUiListenerMixinInterface, |
| }; |
| |
| export class HistoryAppElement extends HistoryAppElementBase { |
| static get is() { |
| return 'history-app'; |
| } |
| |
| static get template() { |
| return getTemplate(); |
| } |
| |
| static get properties() { |
| return { |
| // The id of the currently selected page. |
| selectedPage_: { |
| type: String, |
| observer: 'selectedPageChanged_', |
| }, |
| |
| queryResult_: Object, |
| |
| // Updated on synced-device-manager attach by chrome.sending |
| // 'otherDevicesInitialized'. |
| isUserSignedIn_: Boolean, |
| |
| pendingDelete_: Boolean, |
| |
| toolbarShadow_: { |
| type: Boolean, |
| reflectToAttribute: true, |
| notify: true, |
| }, |
| |
| queryState_: Object, |
| |
| // True if the window is narrow enough for the page to have a drawer. |
| hasDrawer_: { |
| type: Boolean, |
| observer: 'hasDrawerChanged_', |
| }, |
| |
| footerInfo: { |
| type: Object, |
| value() { |
| return { |
| managed: loadTimeData.getBoolean('isManaged'), |
| otherFormsOfHistory: false, |
| }; |
| }, |
| }, |
| |
| historyClustersEnabled_: { |
| type: Boolean, |
| value: () => loadTimeData.getBoolean('isHistoryClustersEnabled'), |
| }, |
| |
| historyClustersVisible_: { |
| type: Boolean, |
| value: () => loadTimeData.getBoolean('isHistoryClustersVisible'), |
| }, |
| |
| showHistoryClusters_: { |
| type: Boolean, |
| computed: |
| 'computeShowHistoryClusters_(historyClustersEnabled_, historyClustersVisible_)', |
| reflectToAttribute: true, |
| }, |
| |
| // The index of the currently selected tab. |
| selectedTab_: { |
| type: Number, |
| observer: 'selectedTabChanged_', |
| }, |
| |
| tabsIcons_: { |
| type: Array, |
| value: () => |
| ['images/list.svg', 'chrome://resources/images/icon_journeys.svg'], |
| }, |
| |
| tabsNames_: { |
| type: Array, |
| value: () => { |
| return [ |
| loadTimeData.getString('historyListTabLabel'), |
| loadTimeData.getString('historyClustersTabLabel'), |
| ]; |
| }, |
| }, |
| }; |
| } |
| |
| footerInfo: FooterInfo; |
| private browserService_: BrowserService|null = null; |
| private eventTracker_: EventTracker = new EventTracker(); |
| private hasDrawer_: boolean; |
| private historyClustersEnabled_: boolean; |
| private historyClustersVisible_: boolean; |
| private isUserSignedIn_: boolean = loadTimeData.getBoolean('isUserSignedIn'); |
| private pendingDelete_: boolean; |
| private queryResult_: QueryResult; |
| private queryState_: QueryState; |
| private selectedPage_: Page; |
| private selectedTab_: number; |
| private showHistoryClusters_: boolean; |
| private tabsIcons_: string[]; |
| private tabsNames_: string[]; |
| private toolbarShadow_: boolean; |
| private historyClustersViewStartTime_: Date|null = null; |
| |
| constructor() { |
| super(); |
| |
| this.queryResult_ = { |
| info: undefined, |
| results: undefined, |
| sessionList: undefined, |
| }; |
| |
| listenForPrivilegedLinkClicks(); |
| } |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| |
| this.eventTracker_.add( |
| document, 'keydown', (e: Event) => this.onKeyDown_(e as KeyboardEvent)); |
| this.eventTracker_.add( |
| document, 'visibilitychange', this.onVisibilityChange_.bind(this)); |
| this.addWebUiListener( |
| 'sign-in-state-changed', |
| (signedIn: boolean) => this.onSignInStateChanged_(signedIn)); |
| this.addWebUiListener( |
| 'has-other-forms-changed', |
| (hasOtherForms: boolean) => |
| this.onHasOtherFormsChanged_(hasOtherForms)); |
| this.addWebUiListener( |
| 'foreign-sessions-changed', |
| (sessionList: ForeignSession[]) => |
| this.setForeignSessions_(sessionList)); |
| this.browserService_ = BrowserServiceImpl.getInstance(); |
| this.shadowRoot!.querySelector('history-query-manager')!.initialize(); |
| this.browserService_!.getForeignSessions().then( |
| sessionList => this.setForeignSessions_(sessionList)); |
| } |
| |
| override ready() { |
| super.ready(); |
| |
| this.addEventListener('cr-toolbar-menu-tap', this.onCrToolbarMenuClick_); |
| this.addEventListener('delete-selected', this.deleteSelected); |
| this.addEventListener('history-checkbox-select', this.checkboxSelected); |
| this.addEventListener('history-close-drawer', this.closeDrawer_); |
| this.addEventListener('history-view-changed', this.historyViewChanged_); |
| this.addEventListener('unselect-all', this.unselectAll); |
| } |
| |
| override disconnectedCallback() { |
| super.disconnectedCallback(); |
| this.eventTracker_.removeAll(); |
| } |
| |
| private fire_(eventName: string, detail?: any) { |
| this.dispatchEvent( |
| new CustomEvent(eventName, {bubbles: true, composed: true, detail})); |
| } |
| |
| private computeShowHistoryClusters_(): boolean { |
| return this.historyClustersEnabled_ && this.historyClustersVisible_; |
| } |
| |
| private historyClustersSelected_( |
| _selectedPage: Page, _showHistoryClusters: boolean): boolean { |
| return this.selectedPage_ === Page.HISTORY_CLUSTERS && |
| this.showHistoryClusters_; |
| } |
| |
| private onFirstRender_() { |
| setTimeout(() => { |
| this.browserService_!.recordTime( |
| 'History.ResultsRenderedTime', window.performance.now()); |
| }); |
| |
| // Focus the search field on load. Done here to ensure the history page |
| // is rendered before we try to take focus. |
| const searchField = this.$.toolbar.searchField; |
| if (!searchField.narrow) { |
| searchField.getSearchInput().focus(); |
| } |
| |
| // Lazily load the remainder of the UI. |
| ensureLazyLoaded().then(function() { |
| window.requestIdleCallback(function() { |
| // https://github.com/microsoft/TypeScript/issues/13569 |
| (document as any).fonts.load('bold 12px Roboto'); |
| }); |
| }); |
| } |
| |
| /** Overridden from IronScrollTargetBehavior */ |
| /* eslint-disable-next-line @typescript-eslint/naming-convention */ |
| override _scrollHandler() { |
| if (this.scrollTarget) { |
| // When the tabs are visible, show the toolbar shadow for the synced |
| // devices page only. |
| this.toolbarShadow_ = this.scrollTarget.scrollTop !== 0 && |
| (!this.showHistoryClusters_ || |
| this.syncedTabsSelected_(this.selectedPage_!)); |
| } |
| } |
| |
| private onCrToolbarMenuClick_() { |
| this.$.drawer.get().toggle(); |
| } |
| |
| /** |
| * Listens for history-item being selected or deselected (through checkbox) |
| * and changes the view of the top toolbar. |
| */ |
| checkboxSelected() { |
| this.$.toolbar.count = this.$.history.getSelectedItemCount(); |
| } |
| |
| selectOrUnselectAll() { |
| this.$.history.selectOrUnselectAll(); |
| this.$.toolbar.count = this.$.history.getSelectedItemCount(); |
| } |
| |
| /** |
| * Listens for call to cancel selection and loops through all items to set |
| * checkbox to be unselected. |
| */ |
| private unselectAll() { |
| this.$.history.unselectAllItems(); |
| this.$.toolbar.count = 0; |
| } |
| |
| deleteSelected() { |
| this.$.history.deleteSelectedWithPrompt(); |
| } |
| |
| private onQueryFinished_() { |
| this.$.history.historyResult( |
| this.queryResult_.info!, this.queryResult_.results!); |
| if (document.body.classList.contains('loading')) { |
| document.body.classList.remove('loading'); |
| this.onFirstRender_(); |
| } |
| } |
| |
| private onKeyDown_(e: KeyboardEvent) { |
| if ((e.key === 'Delete' || e.key === 'Backspace') && !hasKeyModifiers(e)) { |
| this.onDeleteCommand_(); |
| return; |
| } |
| |
| if (e.key === 'a' && !e.altKey && !e.shiftKey) { |
| let hasTriggerModifier = e.ctrlKey && !e.metaKey; |
| // <if expr="is_macosx"> |
| hasTriggerModifier = !e.ctrlKey && e.metaKey; |
| // </if> |
| if (hasTriggerModifier && this.onSelectAllCommand_()) { |
| e.preventDefault(); |
| } |
| } |
| |
| if (e.key === 'Escape') { |
| this.unselectAll(); |
| IronA11yAnnouncer.requestAvailability(); |
| this.fire_( |
| 'iron-announce', {text: loadTimeData.getString('itemsUnselected')}); |
| e.preventDefault(); |
| } |
| } |
| |
| private onVisibilityChange_() { |
| if (this.selectedPage_ !== Page.HISTORY_CLUSTERS) { |
| return; |
| } |
| |
| if (document.visibilityState === 'hidden') { |
| this.recordHistoryClustersDuration_(); |
| } else if ( |
| document.visibilityState === 'visible' && |
| this.historyClustersViewStartTime_ === null) { |
| // Restart the timer if the user switches back to the History tab. |
| this.historyClustersViewStartTime_ = new Date(); |
| } |
| } |
| |
| private onDeleteCommand_() { |
| if (this.$.toolbar.count === 0 || this.pendingDelete_) { |
| return; |
| } |
| this.deleteSelected(); |
| } |
| |
| /** |
| * @return Whether the command was actually triggered. |
| */ |
| private onSelectAllCommand_(): boolean { |
| if (this.$.toolbar.searchField.isSearchFocused() || |
| this.syncedTabsSelected_(this.selectedPage_!) || |
| this.historyClustersSelected_( |
| this.selectedPage_!, this.showHistoryClusters_)) { |
| return false; |
| } |
| this.selectOrUnselectAll(); |
| return true; |
| } |
| |
| /** |
| * @param sessionList Array of objects describing the sessions from other |
| * devices. |
| */ |
| private setForeignSessions_(sessionList: ForeignSession[]) { |
| this.set('queryResult_.sessionList', sessionList); |
| } |
| |
| /** |
| * Update sign in state of synced device manager after user logs in or out. |
| */ |
| private onSignInStateChanged_(isUserSignedIn: boolean) { |
| this.isUserSignedIn_ = isUserSignedIn; |
| } |
| |
| /** |
| * Update sign in state of synced device manager after user logs in or out. |
| */ |
| private onHasOtherFormsChanged_(hasOtherForms: boolean) { |
| this.set('footerInfo.otherFormsOfHistory', hasOtherForms); |
| } |
| |
| private syncedTabsSelected_(_selectedPage: Page): boolean { |
| return this.selectedPage_ === Page.SYNCED_TABS; |
| } |
| |
| /** |
| * @return Whether a loading spinner should be shown (implies the |
| * backend is querying a new search term). |
| */ |
| private shouldShowSpinner_( |
| querying: boolean, incremental: boolean, searchTerm: string): boolean { |
| return querying && !incremental && searchTerm !== ''; |
| } |
| |
| private selectedPageChanged_(newPage: Page, oldPage: Page) { |
| this.unselectAll(); |
| this.historyViewChanged_(); |
| this.maybeUpdateSelectedHistoryTab_(); |
| |
| if (oldPage === Page.HISTORY_CLUSTERS && |
| newPage !== Page.HISTORY_CLUSTERS) { |
| this.recordHistoryClustersDuration_(); |
| } |
| if (newPage === Page.HISTORY_CLUSTERS) { |
| this.historyClustersViewStartTime_ = new Date(); |
| } |
| } |
| |
| private updateScrollTarget_() { |
| const topLevelIronPages = this.$['content']; |
| const lowerLevelIronPages = this.$['tabs-content']; |
| |
| const topLevelHistoryPage = this.$['tabs-container']; |
| if (topLevelIronPages.selectedItem && |
| topLevelIronPages.selectedItem === topLevelHistoryPage) { |
| // The top-level History page has another inner IronPages element that |
| // can toggle between different pages. If this is the case, set the |
| // scroll target to the currently selected inner tab. |
| this.scrollTarget = lowerLevelIronPages.selectedItem as HTMLElement; |
| } else if (topLevelIronPages.selectedItem) { |
| this.scrollTarget = topLevelIronPages.selectedItem as HTMLElement; |
| } else { |
| this.scrollTarget = null; |
| } |
| } |
| |
| private selectedTabChanged_() { |
| // Change in the currently selected tab requires change in the currently |
| // selected page. |
| this.selectedPage_ = TABBED_PAGES[this.selectedTab_]; |
| } |
| |
| private maybeUpdateSelectedHistoryTab_() { |
| // Change in the currently selected page may require change in the currently |
| // selected tab. |
| if (TABBED_PAGES.includes(this.selectedPage_)) { |
| this.selectedTab_ = TABBED_PAGES.indexOf(this.selectedPage_); |
| } |
| } |
| |
| private historyViewChanged_() { |
| // This allows the synced-device-manager to render so that it can be set |
| // as the scroll target. |
| requestAnimationFrame(() => { |
| this._scrollHandler(); |
| }); |
| this.recordHistoryPageView_(); |
| } |
| |
| // Records the history clusters page duration. |
| private recordHistoryClustersDuration_() { |
| assert(this.historyClustersViewStartTime_ !== null); |
| |
| const duration = |
| new Date().getTime() - this.historyClustersViewStartTime_.getTime(); |
| this.browserService_!.recordLongTime( |
| 'History.Clusters.WebUISessionDuration', duration); |
| |
| this.historyClustersViewStartTime_ = null; |
| } |
| |
| private hasDrawerChanged_() { |
| const drawer = this.$.drawer.getIfExists(); |
| if (!this.hasDrawer_ && drawer && drawer.open) { |
| drawer.cancel(); |
| } |
| } |
| |
| /** |
| * This computed binding is needed to make the iron-pages selector update |
| * when <synced-device-manager> or <history-clusters> is instantiated for the |
| * first time. Otherwise the fallback selection will continue to be used after |
| * the corresponding item is added as a child of iron-pages. |
| */ |
| private getSelectedPage_(selectedPage: string, _items: any[]): string { |
| return selectedPage; |
| } |
| |
| private closeDrawer_() { |
| const drawer = this.$.drawer.get() as CrDrawerElement; |
| if (drawer && drawer.open) { |
| drawer.close(); |
| } |
| } |
| |
| private recordHistoryPageView_() { |
| let histogramValue = HistoryPageViewHistogram.END; |
| switch (this.selectedPage_) { |
| case Page.HISTORY_CLUSTERS: |
| histogramValue = HistoryPageViewHistogram.JOURNEYS; |
| break; |
| case Page.SYNCED_TABS: |
| histogramValue = this.isUserSignedIn_ ? |
| HistoryPageViewHistogram.SYNCED_TABS : |
| HistoryPageViewHistogram.SIGNIN_PROMO; |
| break; |
| default: |
| histogramValue = HistoryPageViewHistogram.HISTORY; |
| break; |
| } |
| |
| this.browserService_!.recordHistogram( |
| 'History.HistoryPageView', histogramValue, |
| HistoryPageViewHistogram.END); |
| } |
| |
| // Override FindShortcutMixin methods. |
| override handleFindShortcut(modalContextOpen: boolean): boolean { |
| if (modalContextOpen) { |
| return false; |
| } |
| this.$.toolbar.searchField.showAndFocus(); |
| return true; |
| } |
| |
| // Override FindShortcutMixin methods. |
| override searchInputHasFocus(): boolean { |
| return this.$.toolbar.searchField.isSearchFocused(); |
| } |
| |
| setHasDrawerForTesting(enabled: boolean) { |
| this.hasDrawer_ = enabled; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'history-app': HistoryAppElement; |
| } |
| } |
| |
| customElements.define(HistoryAppElement.is, HistoryAppElement); |