fune/browser/components/firefoxview/recently-closed-tabs.mjs

434 lines
12 KiB
JavaScript

/* 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/. */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
});
import {
formatURIForDisplay,
convertTimestamp,
getImageUrl,
onToggleContainer,
NOW_THRESHOLD_MS,
} from "./helpers.mjs";
import {
html,
ifDefined,
styleMap,
} from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
const { XPCOMUtils } = ChromeUtils.importESModule(
"resource://gre/modules/XPCOMUtils.sys.mjs"
);
const SS_NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed";
const SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush";
const UI_OPEN_STATE =
"browser.tabs.firefox-view.ui-state.recently-closed-tabs.open";
function getWindow() {
return window.browsingContext.embedderWindowGlobal.browsingContext.window;
}
class RecentlyClosedTabsList extends MozLitElement {
constructor() {
super();
this.maxTabsLength = 25;
this.recentlyClosedTabs = [];
this.lastFocusedIndex = -1;
// The recency timestamp update period is stored in a pref to allow tests to easily change it
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"timeMsPref",
"browser.tabs.firefox-view.updateTimeMs",
NOW_THRESHOLD_MS,
() => this.updateTime()
);
}
createRenderRoot() {
return this;
}
static queries = {
tabsList: "ol",
timeElements: { all: "span.closed-tab-li-time" },
};
get fluentStrings() {
if (!this._fluentStrings) {
this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true);
}
return this._fluentStrings;
}
connectedCallback() {
super.connectedCallback();
this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref);
}
disconnectedCallback() {
clearInterval(this.intervalID);
}
updateTime() {
for (let timeEl of this.timeElements) {
timeEl.textContent = convertTimestamp(
parseInt(timeEl.getAttribute("data-timestamp")),
this.fluentStrings,
lazy.timeMsPref
);
}
}
getTabStateValue(tab, key) {
let value = "";
const tabEntries = tab.state.entries;
const activeIndex = tab.state.index - 1;
if (activeIndex >= 0 && tabEntries[activeIndex]) {
value = tabEntries[activeIndex][key];
}
return value;
}
openTabAndUpdate(event) {
event.preventDefault();
if (event.type == "click" && event.altKey) {
return;
}
const item = event.target.closest(".closed-tab-li");
// only used for telemetry
const position = [...this.tabsList.children].indexOf(item) + 1;
const closedId = item.dataset.tabid;
lazy.SessionStore.undoCloseById(closedId);
// record telemetry
let tabClosedAt = parseInt(
item.querySelector(".closed-tab-li-time").getAttribute("data-timestamp")
);
let now = Date.now();
let deltaSeconds = (now - tabClosedAt) / 1000;
Services.telemetry.recordEvent(
"firefoxview",
"recently_closed",
"tabs",
null,
{
position: position.toString(),
delta: deltaSeconds.toString(),
}
);
}
dismissTabAndUpdate(event) {
event.preventDefault();
const item = event.target.closest(".closed-tab-li");
let recentlyClosedList = lazy.SessionStore.getClosedTabData(getWindow());
let closedTabIndex = recentlyClosedList.findIndex(closedTab => {
return closedTab.closedId === parseInt(item.dataset.tabid, 10);
});
if (closedTabIndex < 0) {
// Tab not found in recently closed list
return;
}
lazy.SessionStore.forgetClosedTab(getWindow(), closedTabIndex);
// record telemetry
let tabClosedAt = parseInt(
item.querySelector(".closed-tab-li-time").dataset.timestamp
);
let now = Date.now();
let deltaSeconds = (now - tabClosedAt) / 1000;
Services.telemetry.recordEvent(
"firefoxview",
"dismiss_closed_tab",
"tabs",
null,
{
delta: deltaSeconds.toString(),
}
);
}
updateRecentlyClosedTabs() {
let recentlyClosedTabsData = lazy.SessionStore.getClosedTabData(
getWindow()
);
this.recentlyClosedTabs = recentlyClosedTabsData.slice(
0,
this.maxTabsLength
);
this.requestUpdate();
}
render() {
let { recentlyClosedTabs } = this;
let closedTabsContainer = document.getElementById(
"recently-closed-tabs-container"
);
if (!recentlyClosedTabs.length) {
// Show empty message if no recently closed tabs
closedTabsContainer.toggleContainerStyleForEmptyMsg(true);
return html`
${this.emptyMessageTemplate()}
`;
}
closedTabsContainer.toggleContainerStyleForEmptyMsg(false);
return html`
<ol class="closed-tabs-list">
${recentlyClosedTabs.map((tab, i) =>
this.recentlyClosedTabTemplate(tab, !i)
)}
</ol>
`;
}
willUpdate() {
if (this.tabsList && this.tabsList.contains(document.activeElement)) {
let activeLi = document.activeElement.closest(".closed-tab-li");
this.lastFocusedIndex = [...this.tabsList.children].indexOf(activeLi);
} else {
this.lastFocusedIndex = -1;
}
}
updated() {
let focusRestored = false;
if (this.lastFocusedIndex >= 0) {
if (this.tabsList && this.tabsList.children.length) {
let items = [...this.tabsList.children];
let newFocusIndex = Math.max(
Math.min(items.length - 1, this.lastFocusedIndex - 1),
0
);
let newFocus = items[newFocusIndex];
if (newFocus) {
focusRestored = true;
newFocus.querySelector(".closed-tab-li-main").focus();
}
}
if (!focusRestored) {
document.getElementById("recently-closed-tabs-header-section").focus();
}
}
this.lastFocusedIndex = -1;
}
emptyMessageTemplate() {
return html`
<div
id="recently-closed-tabs-placeholder"
class="placeholder-content"
role="presentation"
>
<img
id="recently-closed-empty-image"
src="chrome://browser/content/recently-closed-empty.svg"
role="presentation"
alt=""
/>
<div class="placeholder-text">
<h4
data-l10n-id="firefoxview-closed-tabs-placeholder-header"
class="placeholder-header"
></h4>
<p
data-l10n-id="firefoxview-closed-tabs-placeholder-body"
class="placeholder-body"
></p>
</div>
</div>
`;
}
recentlyClosedTabTemplate(tab, primary) {
const targetURI = this.getTabStateValue(tab, "url");
const convertedTime = convertTimestamp(tab.closedAt, this.fluentStrings);
return html`
<li
class="closed-tab-li"
data-tabid=${tab.closedId}
data-targeturi=${targetURI}
tabindex=${ifDefined(primary ? null : "-1")}
>
<a
class="closed-tab-li-main tab-link"
tabindex="0"
href=${targetURI}
@click=${e => this.openTabAndUpdate(e)}
>
<div
class="favicon"
style=${styleMap({
backgroundImage: `url(${getImageUrl(tab.icon, targetURI)})`,
})}
></div>
<span class="closed-tab-li-title">
${tab.title}
</span>
<span
title=${targetURI}
class="closed-tab-li-url"
data-l10n-id="firefoxview-tabs-list-tab-button"
data-l10n-args=${JSON.stringify({ targetURI })}
>
${formatURIForDisplay(targetURI)}
</span>
<span class="closed-tab-li-time" data-timestamp=${tab.closedAt}>
${convertedTime}
</span>
</a>
<button
class="closed-tab-li-dismiss"
data-l10n-id="firefoxview-closed-tabs-dismiss-tab"
data-l10n-args=${JSON.stringify({ tabTitle: tab.title })}
@click=${e => this.dismissTabAndUpdate(e)}
></button>
</li>
`;
}
// Update the URL for a new or previously-populated list item.
// This is needed because when tabs get closed we don't necessarily
// have all the requisite information for them immediately.
updateURLForListItem(li, targetURI) {
li.dataset.targetURI = targetURI;
let urlElement = li.querySelector(".closed-tab-li-url");
document.l10n.setAttributes(
urlElement,
"firefoxview-tabs-list-tab-button",
{
targetURI,
}
);
if (targetURI) {
urlElement.textContent = formatURIForDisplay(targetURI);
urlElement.title = targetURI;
} else {
urlElement.textContent = urlElement.title = "";
}
}
}
customElements.define("recently-closed-tabs-list", RecentlyClosedTabsList);
class RecentlyClosedTabsContainer extends HTMLDetailsElement {
constructor() {
super();
this.observerAdded = false;
this.boundObserve = (...args) => this.observe(...args);
}
connectedCallback() {
this.noTabsElement = this.querySelector(
"#recently-closed-tabs-placeholder"
);
this.list = this.querySelector("recently-closed-tabs-list");
this.collapsibleContainer = this.querySelector(
"#collapsible-tabs-container"
);
this.addEventListener("toggle", this);
getWindow().gBrowser.tabContainer.addEventListener("TabSelect", this);
this.open = Services.prefs.getBoolPref(UI_OPEN_STATE, true);
}
cleanup() {
getWindow().gBrowser.tabContainer.removeEventListener("TabSelect", this);
this.removeObserversIfNeeded();
}
addObserversIfNeeded() {
if (!this.observerAdded) {
Services.obs.addObserver(
this.boundObserve,
SS_NOTIFY_CLOSED_OBJECTS_CHANGED
);
Services.obs.addObserver(
this.boundObserve,
SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
);
this.observerAdded = true;
}
}
removeObserversIfNeeded() {
if (this.observerAdded) {
Services.obs.removeObserver(
this.boundObserve,
SS_NOTIFY_CLOSED_OBJECTS_CHANGED
);
Services.obs.removeObserver(
this.boundObserve,
SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH
);
this.observerAdded = false;
}
}
// we observe when a tab closes but since this notification fires more frequently and on
// all windows, we remove the observer when another tab is selected; we check for changes
// to the session store once the user return to this tab.
handleObservers(contentDocument) {
if (contentDocument?.URL == "about:firefoxview") {
this.addObserversIfNeeded();
this.list.updateRecentlyClosedTabs();
} else {
this.removeObserversIfNeeded();
}
}
observe(subject, topic, data) {
if (
topic == SS_NOTIFY_CLOSED_OBJECTS_CHANGED ||
(topic == SS_NOTIFY_BROWSER_SHUTDOWN_FLUSH &&
subject.ownerGlobal == getWindow())
) {
this.list.updateRecentlyClosedTabs();
}
}
onLoad() {
this.list.updateRecentlyClosedTabs();
this.addObserversIfNeeded();
}
handleEvent(event) {
if (event.type == "toggle") {
onToggleContainer(this);
} else if (event.type == "TabSelect") {
this.handleObservers(event.target.linkedBrowser.contentDocument);
}
}
toggleContainerStyleForEmptyMsg(visible) {
this.collapsibleContainer.classList.toggle("empty-container", visible);
}
getClosedTabCount = () => {
try {
return lazy.SessionStore.getClosedTabCount(getWindow());
} catch (ex) {
return 0;
}
};
}
customElements.define(
"recently-closed-tabs-container",
RecentlyClosedTabsContainer,
{
extends: "details",
}
);