445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
/* ***** BEGIN LICENSE BLOCK *****
|
|
* Version: MPL 1.1
|
|
*
|
|
* The contents of this file are subject to the Mozilla Public License Version
|
|
* 1.1 (the "License"); you may not use this file except in compliance with
|
|
* the License. You may obtain a copy of the License at
|
|
* http://www.mozilla.org/MPL/
|
|
*
|
|
* Software distributed under the License is distributed on an "AS IS" basis,
|
|
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
|
|
* for the specific language governing rights and limitations under the
|
|
* License.
|
|
*
|
|
* The Original Code is the Tree Style Tab.
|
|
*
|
|
* The Initial Developer of the Original Code is YUKI "Piro" Hiroshi.
|
|
* Portions created by the Initial Developer are Copyright (C) 2011-2025
|
|
* the Initial Developer. All Rights Reserved.
|
|
*
|
|
* Contributor(s): YUKI "Piro" Hiroshi <piro.outsider.reflex@gmail.com>
|
|
* wanabe <https://github.com/wanabe>
|
|
* Tetsuharu OHZEKI <https://github.com/saneyuki>
|
|
* Xidorn Quan <https://github.com/upsuper> (Firefox 40+ support)
|
|
* lv7777 (https://github.com/lv7777)
|
|
*
|
|
* ***** END LICENSE BLOCK ******/
|
|
'use strict';
|
|
|
|
import {
|
|
log as internalLogger,
|
|
dumpTab,
|
|
wait,
|
|
mapAndFilter
|
|
} from './common.js';
|
|
|
|
import * as Constants from './constants.js';
|
|
import * as ContextualIdentities from './contextual-identities.js';
|
|
import * as SidebarConnection from './sidebar-connection.js';
|
|
import * as TabsStore from './tabs-store.js';
|
|
|
|
import { Tab } from './TreeItem.js';
|
|
|
|
function log(...args) {
|
|
internalLogger('common/tabs-update', ...args);
|
|
}
|
|
|
|
|
|
const mBufferedUpdates = new Map();
|
|
|
|
function getBufferedUpdate(tab) {
|
|
const update = mBufferedUpdates.get(tab.id) || {
|
|
windowId: tab.windowId,
|
|
tabId: tab.id,
|
|
attributes: {
|
|
updated: {},
|
|
added: {},
|
|
removed: new Set(),
|
|
},
|
|
isGroupTab: false,
|
|
updatedTitle: undefined,
|
|
updatedLabel: undefined,
|
|
favIconUrl: undefined,
|
|
loadingState: undefined,
|
|
loadingStateReallyChanged: undefined,
|
|
pinned: undefined,
|
|
hidden: undefined,
|
|
groupId: undefined,
|
|
soundStateChanged: false,
|
|
};
|
|
mBufferedUpdates.set(tab.id, update);
|
|
return update;
|
|
}
|
|
|
|
function flushBufferedUpdates() {
|
|
if (!Constants.IS_BACKGROUND) {
|
|
mBufferedUpdates.clear();
|
|
return;
|
|
}
|
|
|
|
const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`;
|
|
flushBufferedUpdates.triedAt = triedAt;
|
|
(Constants.IS_BACKGROUND ?
|
|
setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document.
|
|
window.requestAnimationFrame)(() => {
|
|
if (flushBufferedUpdates.triedAt != triedAt)
|
|
return;
|
|
for (const update of mBufferedUpdates.values()) {
|
|
// no need to notify attributes broadcasted via Tab.broadcastState()
|
|
delete update.attributes.updated.highlighted;
|
|
delete update.attributes.updated.hidden;
|
|
delete update.attributes.updated.pinned;
|
|
delete update.attributes.updated.audible;
|
|
delete update.attributes.updated.mutedInfo;
|
|
delete update.attributes.updated.sharingState;
|
|
delete update.attributes.updated.incognito;
|
|
delete update.attributes.updated.attention;
|
|
delete update.attributes.updated.discarded;
|
|
if (Object.keys(update.attributes.updated).length > 0 ||
|
|
Object.keys(update.attributes.added).length > 0 ||
|
|
update.attributes.removed.size > 0 ||
|
|
update.soundStateChanged ||
|
|
update.sharingStateChanged)
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_UPDATED,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
updatedProperties: update.attributes.updated,
|
|
addedAttributes: update.attributes.added,
|
|
removedAttributes: [...update.attributes.removed],
|
|
soundStateChanged: update.soundStateChanged,
|
|
sharingStateChanged: update.sharingStateChanged,
|
|
});
|
|
|
|
// SidebarConnection.sendMessage() has its own bulk-send mechanism,
|
|
// so we don't need to bundle them like an array.
|
|
if (update.isGroupTab)
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_GROUP_TAB_DETECTED,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
});
|
|
if (update.updatedTitle !== undefined)
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TREE_ITEM_LABEL_UPDATED,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
title: update.updatedTitle,
|
|
label: update.updatedLabel,
|
|
});
|
|
if (update.favIconUrl !== undefined)
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_NOTIFY_TAB_FAVICON_UPDATED,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
favIconUrl: update.favIconUrl,
|
|
});
|
|
if (update.loadingState !== undefined)
|
|
SidebarConnection.sendMessage({
|
|
type: Constants.kCOMMAND_UPDATE_LOADING_STATE,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
status: update.loadingState,
|
|
reallyChanged: update.loadingStateReallyChanged,
|
|
});
|
|
if (update.pinned !== undefined)
|
|
SidebarConnection.sendMessage({
|
|
type: update.pinned ? Constants.kCOMMAND_NOTIFY_TAB_PINNED : Constants.kCOMMAND_NOTIFY_TAB_UNPINNED,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
});
|
|
if (update.hidden !== undefined)
|
|
SidebarConnection.sendMessage({
|
|
type: update.hidden ? Constants.kCOMMAND_NOTIFY_TAB_HIDDEN : Constants.kCOMMAND_NOTIFY_TAB_SHOWN,
|
|
windowId: update.windowId,
|
|
tabId: update.tabId,
|
|
});
|
|
}
|
|
mBufferedUpdates.clear();
|
|
}, 0);
|
|
}
|
|
|
|
export function updateTab(tab, newState = {}, options = {}) {
|
|
const update = getBufferedUpdate(tab);
|
|
const oldState = options.old || {};
|
|
|
|
if ('url' in newState) {
|
|
tab.$TST.setAttribute(Constants.kCURRENT_URI, update.attributes.added[Constants.kCURRENT_URI] = newState.url);
|
|
update.attributes.removed.delete(Constants.kCURRENT_URI);
|
|
}
|
|
|
|
if ('url' in newState &&
|
|
newState.url.indexOf(Constants.kGROUP_TAB_URI) == 0) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true });
|
|
update.isGroupTab = true;
|
|
Tab.onGroupTabDetected.dispatch(tab);
|
|
}
|
|
else if (tab.$TST.states.has(Constants.kTAB_STATE_GROUP_TAB) &&
|
|
!tab.$TST.hasGroupTabURL) {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true });
|
|
update.isGroupTab = false;
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
('title' in newState &&
|
|
newState.title != oldState.title)) {
|
|
if (options.forceApply) {
|
|
tab.$TST.getPermanentStates().then(states => {
|
|
tab.$TST.toggleState(
|
|
Constants.kTAB_STATE_UNREAD,
|
|
(states.includes(Constants.kTAB_STATE_UNREAD) &&
|
|
!tab.$TST.isGroupTab),
|
|
{ permanently: true }
|
|
);
|
|
});
|
|
}
|
|
else if (tab.$TST.isGroupTab) {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_UNREAD, { permanently: true });
|
|
}
|
|
else if (!tab.active) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_UNREAD, { permanently: true });
|
|
}
|
|
tab.$TST.label = newState.title;
|
|
Tab.onLabelUpdated.dispatch(tab);
|
|
update.updatedTitle = tab.title;
|
|
update.updatedLabel = tab.$TST.label;
|
|
}
|
|
|
|
const openerOfGroupTab = tab.$TST.isGroupTab && Tab.getOpenerFromGroupTab(tab);
|
|
if (openerOfGroupTab &&
|
|
openerOfGroupTab.favIconUrl) {
|
|
update.favIconUrl = openerOfGroupTab.favIconUrl;
|
|
}
|
|
else if (options.forceApply ||
|
|
'favIconUrl' in newState) {
|
|
tab.$TST.setAttribute(Constants.kCURRENT_FAVICON_URI, update.attributes.added[Constants.kCURRENT_FAVICON_URI] = tab.favIconUrl);
|
|
update.attributes.removed.delete(Constants.kCURRENT_FAVICON_URI);
|
|
// "favIconUrl" will be "undefined" if the website has no favicon.
|
|
// Keys with "undefined" value will be removed from JSON-stringified result,
|
|
// so we need to use "null" instead of it.
|
|
// See also: https://github.com/piroor/treestyletab/issues/3515
|
|
update.favIconUrl = tab.favIconUrl || null;
|
|
}
|
|
else if (tab.$TST.isGroupTab) {
|
|
// "about:ws-group" can set error icon for the favicon and
|
|
// reloading doesn't cloear that, so we need to clear favIconUrl manually.
|
|
tab.favIconUrl = null;
|
|
delete update.attributes.added[Constants.kCURRENT_FAVICON_URI];
|
|
update.attributes.removed.add(Constants.kCURRENT_URI);
|
|
tab.$TST.removeAttribute(Constants.kCURRENT_FAVICON_URI);
|
|
update.favIconUrl = null;
|
|
}
|
|
|
|
if ('status' in newState) {
|
|
const reallyChanged = !tab.$TST.states.has(newState.status);
|
|
const removed = newState.status == 'loading' ? 'complete' : 'loading';
|
|
tab.$TST.removeState(removed);
|
|
tab.$TST.addState(newState.status);
|
|
if (!options.forceApply) {
|
|
update.loadingState = tab.status;
|
|
update.loadingStateReallyChanged = reallyChanged;
|
|
}
|
|
}
|
|
|
|
if ((options.forceApply ||
|
|
'pinned' in newState) &&
|
|
newState.pinned != tab.$TST.states.has(Constants.kTAB_STATE_PINNED)) {
|
|
if (newState.pinned) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_PINNED);
|
|
tab.$TST.removeAttribute(Constants.kLEVEL); // don't indent pinned tabs!
|
|
delete update.attributes.added[Constants.kLEVEL];
|
|
update.attributes.removed.add(Constants.kLEVEL);
|
|
Tab.onPinned.dispatch(tab);
|
|
update.pinned = true;
|
|
}
|
|
else {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_PINNED);
|
|
Tab.onUnpinned.dispatch(tab);
|
|
update.pinned = false;
|
|
}
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
'audible' in newState)
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_AUDIBLE, newState.audible);
|
|
|
|
let soundStateChanged = false;
|
|
|
|
if (options.forceApply ||
|
|
'mutedInfo' in newState) {
|
|
soundStateChanged = true;
|
|
const muted = newState.mutedInfo?.muted;
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_MUTED, muted, newState.audible);
|
|
Tab.onMutedStateChanged.dispatch(tab, muted);
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
soundStateChanged ||
|
|
'audible' in newState) {
|
|
soundStateChanged = true;
|
|
tab.$TST.toggleState(
|
|
Constants.kTAB_STATE_SOUND_PLAYING,
|
|
(tab.audible &&
|
|
!tab.mutedInfo.muted)
|
|
);
|
|
}
|
|
|
|
if (soundStateChanged) {
|
|
const parent = tab.$TST.parent;
|
|
if (parent)
|
|
parent.$TST.inheritSoundStateFromChildren();
|
|
}
|
|
|
|
let sharingStateChanged = false;
|
|
if (options.forceApply ||
|
|
'sharingState' in newState) {
|
|
sharingStateChanged = true;
|
|
const sharingCamera = !!newState.sharingState?.camera;
|
|
const sharingMicrophone = !!newState.sharingState?.microphone;
|
|
const sharingScreen = !!newState.sharingState?.screen;
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_SHARING_CAMERA, sharingCamera);
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_SHARING_MICROPHONE, sharingMicrophone);
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_SHARING_SCREEN, sharingScreen);
|
|
Tab.onSharingStateChanged.dispatch(tab, {
|
|
camera: sharingCamera,
|
|
microphone: sharingMicrophone,
|
|
screen: sharingScreen,
|
|
});
|
|
const parent = tab.$TST.parent;
|
|
if (parent)
|
|
parent.$TST.inheritSharingStateFromChildren();
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
'cookieStoreId' in newState) {
|
|
for (const state of tab.$TST.states) {
|
|
if (String(state).startsWith('contextual-identity-'))
|
|
tab.$TST.removeState(state);
|
|
}
|
|
if (newState.cookieStoreId) {
|
|
const state = `contextual-identity-${newState.cookieStoreId}`;
|
|
tab.$TST.addState(state);
|
|
const identity = ContextualIdentities.get(newState.cookieStoreId);
|
|
if (identity)
|
|
tab.$TST.setAttribute(Constants.kCONTEXTUAL_IDENTITY_NAME, identity.name);
|
|
else
|
|
tab.$TST.removeAttribute(Constants.kCONTEXTUAL_IDENTITY_NAME);
|
|
}
|
|
else {
|
|
tab.$TST.removeAttribute(Constants.kCONTEXTUAL_IDENTITY_NAME);
|
|
}
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
'incognito' in newState)
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_PRIVATE_BROWSING, newState.incognito);
|
|
|
|
if (options.forceApply ||
|
|
'hidden' in newState) {
|
|
if (newState.hidden) {
|
|
if (!tab.$TST.states.has(Constants.kTAB_STATE_HIDDEN)) {
|
|
tab.$TST.addState(Constants.kTAB_STATE_HIDDEN);
|
|
Tab.onHidden.dispatch(tab);
|
|
update.hidden = true;
|
|
}
|
|
}
|
|
else if (tab.$TST.states.has(Constants.kTAB_STATE_HIDDEN)) {
|
|
tab.$TST.removeState(Constants.kTAB_STATE_HIDDEN);
|
|
Tab.onShown.dispatch(tab);
|
|
update.hidden = false;
|
|
}
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
'highlighted' in newState)
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_HIGHLIGHTED, newState.highlighted);
|
|
|
|
if (options.forceApply ||
|
|
'attention' in newState)
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_ATTENTION, newState.attention);
|
|
|
|
if (options.forceApply ||
|
|
'discarded' in newState) {
|
|
wait(0).then(() => {
|
|
// Don't set this class immediately, because we need to know
|
|
// the newly active tab *was* discarded on onTabClosed handler.
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_DISCARDED, newState.discarded);
|
|
});
|
|
}
|
|
|
|
if (options.forceApply ||
|
|
'groupId' in newState) {
|
|
tab.$TST.onNativeGroupModified(oldState.groupId);
|
|
update.attributes.added[Constants.kGROUP_ID] = newState.groupId;
|
|
}
|
|
|
|
update.soundStateChanged = update.soundStateChanged || soundStateChanged;
|
|
update.sharingStateChanged = update.sharingStateChanged || sharingStateChanged;
|
|
update.attributes.updated = {
|
|
...update.attributes.updated,
|
|
...(newState?.$TST?.sanitized || newState),
|
|
};
|
|
flushBufferedUpdates();
|
|
|
|
tab.$TST.invalidateCache();
|
|
}
|
|
|
|
export async function updateTabsHighlighted(highlightInfo) {
|
|
if (Tab.needToWaitTracked(highlightInfo.windowId))
|
|
await Tab.waitUntilTrackedAll(highlightInfo.windowId);
|
|
const win = TabsStore.windows.get(highlightInfo.windowId);
|
|
if (!win)
|
|
return;
|
|
|
|
//const startAt = Date.now();
|
|
|
|
const tabIds = highlightInfo.tabIds; // new Set(highlightInfo.tabIds);
|
|
const toBeUnhighlightedTabs = Tab.getHighlightedTabs(highlightInfo.windowId, {
|
|
ordered: false,
|
|
'!id': tabIds
|
|
});
|
|
const alreadyHighlightedTabs = TabsStore.highlightedTabsInWindow.get(highlightInfo.windowId);
|
|
const toBeHighlightedTabs = mapAndFilter(tabIds, id => {
|
|
const tab = win.tabs.get(id);
|
|
return tab && !alreadyHighlightedTabs.has(tab.id) && tab || undefined;
|
|
});
|
|
|
|
//console.log(`updateTabsHighlighted: ${Date.now() - startAt}ms`, { toBeHighlightedTabs, toBeUnhighlightedTabs});
|
|
|
|
const inheritToCollapsedDescendants = !!highlightInfo.inheritToCollapsedDescendants;
|
|
//log('updateTabsHighlighted ', { toBeHighlightedTabs, toBeUnhighlightedTabs});
|
|
for (const tab of toBeUnhighlightedTabs) {
|
|
TabsStore.removeHighlightedTab(tab);
|
|
updateTabHighlighted(tab, false, { inheritToCollapsedDescendants });
|
|
}
|
|
for (const tab of toBeHighlightedTabs) {
|
|
TabsStore.addHighlightedTab(tab);
|
|
updateTabHighlighted(tab, true, { inheritToCollapsedDescendants });
|
|
}
|
|
}
|
|
async function updateTabHighlighted(tab, highlighted, { inheritToCollapsedDescendants } = {}) {
|
|
log(`highlighted status of ${dumpTab(tab)}: `, { old: tab.highlighted, new: highlighted });
|
|
//if (tab.highlighted == highlighted)
|
|
// return false;
|
|
tab.$TST.toggleState(Constants.kTAB_STATE_HIGHLIGHTED, highlighted);
|
|
tab.highlighted = highlighted;
|
|
const win = TabsStore.windows.get(tab.windowId);
|
|
const inheritHighlighted = !win.tabsToBeHighlightedAlone.has(tab.id);
|
|
if (!inheritHighlighted)
|
|
win.tabsToBeHighlightedAlone.delete(tab.id);
|
|
updateTab(tab, { highlighted });
|
|
Tab.onUpdated.dispatch(tab, { highlighted }, {
|
|
inheritHighlighted: inheritToCollapsedDescendants && inheritHighlighted,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
|
|
export async function completeLoadingTabs(windowId) {
|
|
const completedTabs = new Set((await browser.tabs.query({ windowId, status: 'complete' })).map(tab => tab.id));
|
|
for (const tab of Tab.getLoadingTabs(windowId, { ordered: false, iterator: true })) {
|
|
if (completedTabs.has(tab.id))
|
|
updateTab(tab, { status: 'complete' });
|
|
}
|
|
}
|