diff options
Diffstat (limited to 'browser/components/firefoxview/OpenTabs.sys.mjs')
-rw-r--r-- | browser/components/firefoxview/OpenTabs.sys.mjs | 410 |
1 files changed, 410 insertions, 0 deletions
diff --git a/browser/components/firefoxview/OpenTabs.sys.mjs b/browser/components/firefoxview/OpenTabs.sys.mjs new file mode 100644 index 0000000000..ac247f5e8f --- /dev/null +++ b/browser/components/firefoxview/OpenTabs.sys.mjs @@ -0,0 +1,410 @@ +/* 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/. */ + +/** + * This module provides the means to monitor and query for tab collections against open + * browser windows and allow listeners to be notified of changes to those collections. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + EveryWindow: "resource:///modules/EveryWindow.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const TAB_ATTRS_TO_WATCH = Object.freeze([ + "attention", + "image", + "label", + "muted", + "soundplaying", + "titlechanged", +]); +const TAB_CHANGE_EVENTS = Object.freeze([ + "TabAttrModified", + "TabClose", + "TabMove", + "TabOpen", + "TabPinned", + "TabUnpinned", +]); +const TAB_RECENCY_CHANGE_EVENTS = Object.freeze([ + "activate", + "TabAttrModified", + "TabClose", + "TabOpen", + "TabSelect", + "TabAttrModified", +]); + +// Debounce tab/tab recency changes and dispatch max once per frame at 60fps +const CHANGES_DEBOUNCE_MS = 1000 / 60; + +/** + * A sort function used to order tabs by most-recently seen and active. + */ +export function lastSeenActiveSort(a, b) { + let dt = b.lastSeenActive - a.lastSeenActive; + if (dt) { + return dt; + } + // try to break a deadlock by sorting the selected tab higher + if (!(a.selected || b.selected)) { + return 0; + } + return a.selected ? -1 : 1; +} + +/** + * Provides a object capable of monitoring and accessing tab collections for either + * private or non-private browser windows. As the class extends EventTarget, consumers + * should add event listeners for the change events. + * + * @param {boolean} options.usePrivateWindows + Constrain to only windows that match this privateness. Defaults to false. + * @param {Window | null} options.exclusiveWindow + * Constrain to only a specific window. + */ +class OpenTabsTarget extends EventTarget { + #changedWindowsByType = { + TabChange: new Set(), + TabRecencyChange: new Set(), + }; + #dispatchChangesTask; + #started = false; + #watchedWindows = new Set(); + + #exclusiveWindowWeakRef = null; + usePrivateWindows = false; + + constructor(options = {}) { + super(); + this.usePrivateWindows = !!options.usePrivateWindows; + + if (options.exclusiveWindow) { + this.exclusiveWindow = options.exclusiveWindow; + this.everyWindowCallbackId = `opentabs-${this.exclusiveWindow.windowGlobalChild.innerWindowId}`; + } else { + this.everyWindowCallbackId = `opentabs-${ + this.usePrivateWindows ? "private" : "non-private" + }`; + } + } + + get exclusiveWindow() { + return this.#exclusiveWindowWeakRef?.get(); + } + set exclusiveWindow(newValue) { + if (newValue) { + this.#exclusiveWindowWeakRef = Cu.getWeakReference(newValue); + } else { + this.#exclusiveWindowWeakRef = null; + } + } + + includeWindowFilter(win) { + if (this.#exclusiveWindowWeakRef) { + return win == this.exclusiveWindow; + } + return ( + win.gBrowser && + !win.closed && + this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ); + } + + get currentWindows() { + return lazy.EveryWindow.readyWindows.filter(win => + this.includeWindowFilter(win) + ); + } + + /** + * A promise that resolves to all matched windows once their delayedStartupPromise resolves + */ + get readyWindowsPromise() { + let windowList = Array.from( + Services.wm.getEnumerator("navigator:browser") + ).filter(win => { + // avoid waiting for windows we definitely don't care about + if (this.#exclusiveWindowWeakRef) { + return this.exclusiveWindow == win; + } + return ( + this.usePrivateWindows == lazy.PrivateBrowsingUtils.isWindowPrivate(win) + ); + }); + return Promise.allSettled( + windowList.map(win => win.delayedStartupPromise) + ).then(() => { + // re-filter the list as properties might have changed in the interim + return windowList.filter(win => this.includeWindowFilter); + }); + } + + haveListenersForEvent(eventType) { + switch (eventType) { + case "TabChange": + return Services.els.hasListenersFor(this, "TabChange"); + case "TabRecencyChange": + return Services.els.hasListenersFor(this, "TabRecencyChange"); + default: + return false; + } + } + + get haveAnyListeners() { + return ( + this.haveListenersForEvent("TabChange") || + this.haveListenersForEvent("TabRecencyChange") + ); + } + + /* + * @param {string} type + * Either "TabChange" or "TabRecencyChange" + * @param {Object|Function} listener + * @param {Object} [options] + */ + addEventListener(type, listener, options) { + let hadListeners = this.haveAnyListeners; + super.addEventListener(type, listener, options); + + // if this is the first listener, start up all the window & tab monitoring + if (!hadListeners && this.haveAnyListeners) { + this.start(); + } + } + + /* + * @param {string} type + * Either "TabChange" or "TabRecencyChange" + * @param {Object|Function} listener + */ + removeEventListener(type, listener) { + let hadListeners = this.haveAnyListeners; + super.removeEventListener(type, listener); + + // if this was the last listener, we can stop all the window & tab monitoring + if (hadListeners && !this.haveAnyListeners) { + this.stop(); + } + } + + /** + * Begin watching for tab-related events from all browser windows matching the instance's private property + */ + start() { + if (this.#started) { + return; + } + // EveryWindow will call #watchWindow for each open window once its delayedStartupPromise resolves. + lazy.EveryWindow.registerCallback( + this.everyWindowCallbackId, + win => this.#watchWindow(win), + win => this.#unwatchWindow(win) + ); + this.#started = true; + } + + /** + * Stop watching for tab-related events from all browser windows and clean up. + */ + stop() { + if (this.#started) { + lazy.EveryWindow.unregisterCallback(this.everyWindowCallbackId); + this.#started = false; + } + for (let changedWindows of Object.values(this.#changedWindowsByType)) { + changedWindows.clear(); + } + this.#watchedWindows.clear(); + this.#dispatchChangesTask?.disarm(); + } + + /** + * Add listeners for tab-related events from the given window. The consumer's + * listeners will always be notified at least once for newly-watched window. + */ + #watchWindow(win) { + if (!this.includeWindowFilter(win)) { + return; + } + this.#watchedWindows.add(win); + const { tabContainer } = win.gBrowser; + tabContainer.addEventListener("TabAttrModified", this); + tabContainer.addEventListener("TabClose", this); + tabContainer.addEventListener("TabMove", this); + tabContainer.addEventListener("TabOpen", this); + tabContainer.addEventListener("TabPinned", this); + tabContainer.addEventListener("TabUnpinned", this); + tabContainer.addEventListener("TabSelect", this); + win.addEventListener("activate", this); + + this.#scheduleEventDispatch("TabChange", {}); + this.#scheduleEventDispatch("TabRecencyChange", {}); + } + + /** + * Remove all listeners for tab-related events from the given window. + * Consumers will always be notified at least once for unwatched window. + */ + #unwatchWindow(win) { + // We check the window is in our watchedWindows collection rather than currentWindows + // as the unwatched window may not match the criteria we used to watch it anymore, + // and we need to unhook our event listeners regardless. + if (this.#watchedWindows.has(win)) { + this.#watchedWindows.delete(win); + + const { tabContainer } = win.gBrowser; + tabContainer.removeEventListener("TabAttrModified", this); + tabContainer.removeEventListener("TabClose", this); + tabContainer.removeEventListener("TabMove", this); + tabContainer.removeEventListener("TabOpen", this); + tabContainer.removeEventListener("TabPinned", this); + tabContainer.removeEventListener("TabSelect", this); + tabContainer.removeEventListener("TabUnpinned", this); + win.removeEventListener("activate", this); + + this.#scheduleEventDispatch("TabChange", {}); + this.#scheduleEventDispatch("TabRecencyChange", {}); + } + } + + /** + * Flag the need to notify all our consumers of a change to open tabs. + * Repeated calls within approx 16ms will be consolidated + * into one event dispatch. + */ + #scheduleEventDispatch(eventType, { sourceWindowId } = {}) { + if (!this.haveListenersForEvent(eventType)) { + return; + } + + this.#changedWindowsByType[eventType].add(sourceWindowId); + // Queue up an event dispatch - we use a deferred task to make this less noisy by + // consolidating multiple change events into one. + if (!this.#dispatchChangesTask) { + this.#dispatchChangesTask = new lazy.DeferredTask(() => { + this.#dispatchChanges(); + }, CHANGES_DEBOUNCE_MS); + } + this.#dispatchChangesTask.arm(); + } + + #dispatchChanges() { + this.#dispatchChangesTask?.disarm(); + for (let [eventType, changedWindowIds] of Object.entries( + this.#changedWindowsByType + )) { + if (this.haveListenersForEvent(eventType) && changedWindowIds.size) { + this.dispatchEvent( + new CustomEvent(eventType, { + detail: { + windowIds: [...changedWindowIds], + }, + }) + ); + changedWindowIds.clear(); + } + } + } + + /* + * @param {Window} win + * @param {boolean} sortByRecency + * @returns {Array<Tab>} + * The list of visible tabs for the browser window + */ + getTabsForWindow(win, sortByRecency = false) { + if (this.currentWindows.includes(win)) { + const { visibleTabs } = win.gBrowser; + return sortByRecency + ? visibleTabs.toSorted(lastSeenActiveSort) + : [...visibleTabs]; + } + return []; + } + + /* + * @returns {Array<Tab>} + * A by-recency-sorted, aggregated list of tabs from all the same-privateness browser windows. + */ + getRecentTabs() { + const tabs = []; + for (let win of this.currentWindows) { + tabs.push(...this.getTabsForWindow(win)); + } + tabs.sort(lastSeenActiveSort); + return tabs; + } + + handleEvent({ detail, target, type }) { + const win = target.ownerGlobal; + // NOTE: we already filtered on privateness by not listening for those events + // from private/not-private windows + if ( + type == "TabAttrModified" && + !detail.changed.some(attr => TAB_ATTRS_TO_WATCH.includes(attr)) + ) { + return; + } + + if (TAB_RECENCY_CHANGE_EVENTS.includes(type)) { + this.#scheduleEventDispatch("TabRecencyChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + }); + } + if (TAB_CHANGE_EVENTS.includes(type)) { + this.#scheduleEventDispatch("TabChange", { + sourceWindowId: win.windowGlobalChild.innerWindowId, + }); + } + } +} + +const gExclusiveWindows = new (class { + perWindowInstances = new WeakMap(); + constructor() { + Services.obs.addObserver(this, "domwindowclosed"); + } + observe(subject, topic, data) { + let win = subject; + let winTarget = this.perWindowInstances.get(win); + if (winTarget) { + winTarget.stop(); + this.perWindowInstances.delete(win); + } + } +})(); + +/** + * Get an OpenTabsTarget instance constrained to a specific window. + * + * @param {Window} exclusiveWindow + * @returns {OpenTabsTarget} + */ +const getTabsTargetForWindow = function (exclusiveWindow) { + let instance = gExclusiveWindows.perWindowInstances.get(exclusiveWindow); + if (instance) { + return instance; + } + instance = new OpenTabsTarget({ + exclusiveWindow, + }); + gExclusiveWindows.perWindowInstances.set(exclusiveWindow, instance); + return instance; +}; + +const NonPrivateTabs = new OpenTabsTarget({ + usePrivateWindows: false, +}); + +const PrivateTabs = new OpenTabsTarget({ + usePrivateWindows: true, +}); + +export { NonPrivateTabs, PrivateTabs, getTabsTargetForWindow }; |