mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-12-02 18:08:58 +00:00
Backed out changeset 649252a67839 (bug 1809661) for causing failures complaining about _createRecentTabsList . CLOSED TREE
This commit is contained in:
parent
1956495c03
commit
fdef81b168
@ -169,7 +169,7 @@ export const TabsSetupFlowManager = new (class {
|
||||
this._currentSetupStateName = "not-signed-in";
|
||||
this._shouldShowSuccessConfirmation = false;
|
||||
this._didShowMobilePromo = false;
|
||||
this.waitingForTabs = false;
|
||||
this._waitingForTabs = false;
|
||||
|
||||
this.syncHasWorked = false;
|
||||
|
||||
@ -178,8 +178,6 @@ export const TabsSetupFlowManager = new (class {
|
||||
mobileDeviceConnected: this.mobileDeviceConnected,
|
||||
secondaryDeviceConnected: this.secondaryDeviceConnected,
|
||||
};
|
||||
// keep track of tab-pickup-container instance visibilities
|
||||
this._viewVisibilityStates = new Map();
|
||||
}
|
||||
|
||||
get isPrimaryPasswordLocked() {
|
||||
@ -217,14 +215,6 @@ export const TabsSetupFlowManager = new (class {
|
||||
Services.obs.removeObserver(this, FXA_DEVICE_CONNECTED);
|
||||
Services.obs.removeObserver(this, FXA_DEVICE_DISCONNECTED);
|
||||
}
|
||||
get hasVisibleViews() {
|
||||
return Array.from(this._viewVisibilityStates.values()).reduce(
|
||||
(hasVisible, visibility) => {
|
||||
return hasVisible || visibility == "visible";
|
||||
},
|
||||
false
|
||||
);
|
||||
}
|
||||
get currentSetupState() {
|
||||
return this.setupState.get(this._currentSetupStateName);
|
||||
}
|
||||
@ -306,15 +296,12 @@ export const TabsSetupFlowManager = new (class {
|
||||
break;
|
||||
case TOPIC_DEVICELIST_UPDATED:
|
||||
this.logger.debug("Handling observer notification:", topic, data);
|
||||
const { deviceStateChanged, deviceAdded } = await this.refreshDevices();
|
||||
if (deviceStateChanged) {
|
||||
if (await this.refreshDevices()) {
|
||||
this.logger.debug(
|
||||
"refreshDevices made changes, calling maybeUpdateUI"
|
||||
);
|
||||
this.maybeUpdateUI(true);
|
||||
}
|
||||
if (deviceAdded && this.secondaryDeviceConnected) {
|
||||
this.logger.debug("device was added");
|
||||
this._newlyAddedDeviceTabsPending = true;
|
||||
this.startWaitingForNewDeviceTabs();
|
||||
}
|
||||
break;
|
||||
case FXA_DEVICE_CONNECTED:
|
||||
case FXA_DEVICE_DISCONNECTED:
|
||||
@ -324,20 +311,18 @@ export const TabsSetupFlowManager = new (class {
|
||||
case SYNC_SERVICE_ERROR:
|
||||
this.logger.debug(`Handling ${SYNC_SERVICE_ERROR}`);
|
||||
if (lazy.UIState.get().status == lazy.UIState.STATUS_SIGNED_IN) {
|
||||
this.waitingForTabs = false;
|
||||
this._waitingForTabs = false;
|
||||
this.syncIsWorking = false;
|
||||
this.maybeUpdateUI(true);
|
||||
}
|
||||
break;
|
||||
case NETWORK_STATUS_CHANGED:
|
||||
this.networkIsOnline = data == "online";
|
||||
this.waitingForTabs = false;
|
||||
this._waitingForTabs = false;
|
||||
this.maybeUpdateUI(true);
|
||||
break;
|
||||
case SYNC_SERVICE_FINISHED:
|
||||
this.logger.debug(`Handling ${SYNC_SERVICE_FINISHED}`);
|
||||
// We intentionally leave any pending flag and empty-tabs timestamp here
|
||||
// as we may be still waiting for a sync that delivers some tabs
|
||||
this._waitingForTabs = false;
|
||||
if (!this.syncIsWorking) {
|
||||
this.syncIsWorking = true;
|
||||
@ -355,50 +340,15 @@ export const TabsSetupFlowManager = new (class {
|
||||
}
|
||||
}
|
||||
|
||||
updateViewVisiblity(instanceId, visibility) {
|
||||
this.logger.debug(
|
||||
`updateViewVisiblity for instance: ${instanceId}, visibility: ${visibility}`
|
||||
);
|
||||
if (visibility == "unloaded") {
|
||||
this._viewVisibilityStates.delete(instanceId);
|
||||
} else {
|
||||
this._viewVisibilityStates.set(instanceId, visibility);
|
||||
}
|
||||
// If we're currently waiting for tabs from a newly-added device
|
||||
// we may want to start the empty-tabs visible timer
|
||||
if (this._newlyAddedDeviceTabsPending && this.hasVisibleViews) {
|
||||
this.startWaitingForNewDeviceTabs();
|
||||
}
|
||||
if (!this.hasVisibleViews) {
|
||||
this.logger.debug(
|
||||
"Resetting timestamp and tabs pending flags as there are no visible views"
|
||||
);
|
||||
// if there's no view visible, we're not really waiting anymore
|
||||
this._noTabsVisibleFromAddedDeviceTimestamp = 0;
|
||||
this._newlyAddedDeviceTabsPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
get waitingForTabs() {
|
||||
return !!(
|
||||
return (
|
||||
// signed in & at least 1 other device is sycning indicates there's something to wait for
|
||||
(
|
||||
this.secondaryDeviceConnected &&
|
||||
// last recent tabs request came back empty and we've not had a sync finish (or error) yet
|
||||
(this._waitingForTabs || this._noTabsVisibleFromAddedDeviceTimestamp)
|
||||
)
|
||||
this.secondaryDeviceConnected &&
|
||||
// last recent tabs request came back empty and we've not had a sync finish (or error) yet
|
||||
this._waitingForTabs
|
||||
);
|
||||
}
|
||||
|
||||
set waitingForTabs(isWaiting) {
|
||||
this._waitingForTabs = isWaiting;
|
||||
if (!isWaiting) {
|
||||
// also clear out the device-added / tabs pending flags
|
||||
this._noTabsVisibleFromAddedDeviceTimestamp = 0;
|
||||
this._newlyAddedDeviceTabsPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
startWaitingForTabs() {
|
||||
if (!this._waitingForTabs) {
|
||||
this._waitingForTabs = true;
|
||||
@ -406,33 +356,9 @@ export const TabsSetupFlowManager = new (class {
|
||||
}
|
||||
}
|
||||
|
||||
async stopWaitingForTabs() {
|
||||
const recentTabs = await lazy.SyncedTabs.getRecentTabs(1);
|
||||
if (!recentTabs.length && this._newlyAddedDeviceTabsPending) {
|
||||
// we are still waiting for some tabs to show...
|
||||
this.logger.debug(
|
||||
"stopWaitingForTabs: Still no recent tabs, we are still waiting"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this._noTabsVisibleFromAddedDeviceTimestamp) {
|
||||
// If we were waiting for tabs from a newly-added device, record
|
||||
// the time elapsed
|
||||
const elapsed = Date.now() - this._noTabsVisibleFromAddedDeviceTimestamp;
|
||||
this.logger.debug(
|
||||
"stopWaitingForTabs, resetting _noTabsVisibleFromAddedDeviceTimestamp and recording telemetry:",
|
||||
Math.round(elapsed / 1000)
|
||||
);
|
||||
Services.telemetry.recordEvent(
|
||||
"firefoxview",
|
||||
"synced_tabs_empty",
|
||||
"since_device_added",
|
||||
Math.round(elapsed / 1000).toString()
|
||||
);
|
||||
}
|
||||
const wasWaiting = this.waitingForTabs;
|
||||
this.waitingForTabs = false;
|
||||
if (wasWaiting) {
|
||||
stopWaitingForTabs() {
|
||||
if (this._waitingForTabs) {
|
||||
this._waitingForTabs = false;
|
||||
Services.obs.notifyObservers(null, TOPIC_SETUPSTATE_CHANGED);
|
||||
}
|
||||
}
|
||||
@ -443,7 +369,7 @@ export const TabsSetupFlowManager = new (class {
|
||||
this.maybeUpdateUI(true);
|
||||
if (!this.fxaSignedIn) {
|
||||
// As we just signed out, ensure the waiting flag is reset for next time around
|
||||
this.waitingForTabs = false;
|
||||
this._waitingForTabs = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -462,8 +388,7 @@ export const TabsSetupFlowManager = new (class {
|
||||
|
||||
// When SyncedTabs has resolved the getRecentTabs promise,
|
||||
// we also know we can update devices-related internal state
|
||||
const { deviceStateChanged } = await this.refreshDevices();
|
||||
if (deviceStateChanged) {
|
||||
if (await this.refreshDevices()) {
|
||||
this.logger.debug(
|
||||
"onSignedInChange, after refreshDevices, calling maybeUpdateUI"
|
||||
);
|
||||
@ -499,28 +424,6 @@ export const TabsSetupFlowManager = new (class {
|
||||
}
|
||||
}
|
||||
|
||||
async startWaitingForNewDeviceTabs() {
|
||||
// don't start a timer if all tab-pickup-container views are currently hidden
|
||||
if (!this.hasVisibleViews) {
|
||||
return;
|
||||
}
|
||||
// if we're already waiting for tabs, don't reset
|
||||
if (this._noTabsVisibleFromAddedDeviceTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
// take a timestamp whenever the latest device is added and we have 0 tabs to show,
|
||||
// allowing us to track how long we show an empty list after a new device is added
|
||||
const hasRecentTabs = (await lazy.SyncedTabs.getRecentTabs(1)).length;
|
||||
if (this.hasVisibleViews && !hasRecentTabs) {
|
||||
this._noTabsVisibleFromAddedDeviceTimestamp = Date.now();
|
||||
this.logger.debug(
|
||||
"New device added, storing timestamp:",
|
||||
this._noTabsVisibleFromAddedDeviceTimestamp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshDevices() {
|
||||
// If current device not found in recent device list, refresh device list
|
||||
if (
|
||||
@ -534,15 +437,13 @@ export const TabsSetupFlowManager = new (class {
|
||||
// compare new values to the previous values
|
||||
const mobileDeviceConnected = this.mobileDeviceConnected;
|
||||
const secondaryDeviceConnected = this.secondaryDeviceConnected;
|
||||
const oldDevicesCount = this._deviceStateSnapshot?.devicesCount ?? 0;
|
||||
const devicesCount = lazy.fxAccounts.device?.recentDeviceList?.length ?? 0;
|
||||
|
||||
this.logger.debug(
|
||||
`refreshDevices, mobileDeviceConnected: ${mobileDeviceConnected}, `,
|
||||
`secondaryDeviceConnected: ${secondaryDeviceConnected}`
|
||||
);
|
||||
|
||||
let deviceStateChanged =
|
||||
let didDeviceStateChange =
|
||||
this._deviceStateSnapshot.mobileDeviceConnected !=
|
||||
mobileDeviceConnected ||
|
||||
this._deviceStateSnapshot.secondaryDeviceConnected !=
|
||||
@ -564,9 +465,8 @@ export const TabsSetupFlowManager = new (class {
|
||||
this._deviceStateSnapshot = {
|
||||
mobileDeviceConnected,
|
||||
secondaryDeviceConnected,
|
||||
devicesCount,
|
||||
};
|
||||
if (deviceStateChanged) {
|
||||
if (didDeviceStateChange) {
|
||||
this.logger.debug("refreshDevices: device state did change");
|
||||
if (!secondaryDeviceConnected) {
|
||||
this.logger.debug(
|
||||
@ -577,10 +477,7 @@ export const TabsSetupFlowManager = new (class {
|
||||
} else {
|
||||
this.logger.debug("refreshDevices: no device state change");
|
||||
}
|
||||
return {
|
||||
deviceStateChanged,
|
||||
deviceAdded: oldDevicesCount < devicesCount,
|
||||
};
|
||||
return didDeviceStateChange;
|
||||
}
|
||||
|
||||
maybeUpdateUI(forceUpdate = false) {
|
||||
|
@ -20,7 +20,6 @@ class TabPickupContainer extends HTMLDetailsElement {
|
||||
this._currentSetupStateIndex = -1;
|
||||
this.errorState = null;
|
||||
this.tabListAdded = null;
|
||||
this._id = Math.floor(Math.random() * 10e6);
|
||||
}
|
||||
get setupContainerElem() {
|
||||
return this.querySelector(".sync-setup-container");
|
||||
@ -42,7 +41,7 @@ class TabPickupContainer extends HTMLDetailsElement {
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this);
|
||||
this.addEventListener("toggle", this);
|
||||
this.ownerDocument.addEventListener("visibilitychange", this);
|
||||
this.addEventListener("visibilitychange", this);
|
||||
Services.obs.addObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED);
|
||||
|
||||
for (let elem of this.querySelectorAll("a[data-support-url]")) {
|
||||
@ -55,7 +54,6 @@ class TabPickupContainer extends HTMLDetailsElement {
|
||||
// when its safe to assume the custom-element's methods will be available
|
||||
this.tabListAdded = this.promiseChildAdded();
|
||||
this.update();
|
||||
this.onVisibilityChange();
|
||||
}
|
||||
|
||||
promiseChildAdded() {
|
||||
@ -75,8 +73,6 @@ class TabPickupContainer extends HTMLDetailsElement {
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
TabsSetupFlowManager.updateViewVisiblity(this._id, "unloaded");
|
||||
this.ownerDocument?.removeEventListener("visibilitychange", this);
|
||||
Services.obs.removeObserver(this.boundObserve, TOPIC_SETUPSTATE_CHANGED);
|
||||
}
|
||||
|
||||
@ -87,7 +83,6 @@ class TabPickupContainer extends HTMLDetailsElement {
|
||||
handleEvent(event) {
|
||||
if (event.type == "toggle") {
|
||||
onToggleContainer(this);
|
||||
this.onVisibilityChange();
|
||||
return;
|
||||
}
|
||||
if (event.type == "click" && event.target.dataset.action) {
|
||||
@ -135,21 +130,11 @@ class TabPickupContainer extends HTMLDetailsElement {
|
||||
}
|
||||
}
|
||||
// Returning to fxview seems like a likely time for a device check
|
||||
if (event.type == "visibilitychange") {
|
||||
this.onVisibilityChange();
|
||||
}
|
||||
}
|
||||
onVisibilityChange() {
|
||||
const isVisible = document.visibilityState == "visible";
|
||||
const isOpen = this.open;
|
||||
if (isVisible && isOpen) {
|
||||
if (
|
||||
event.type == "visibilitychange" &&
|
||||
document.visibilityState === "visible"
|
||||
) {
|
||||
this.update();
|
||||
TabsSetupFlowManager.updateViewVisiblity(this._id, "visible");
|
||||
} else {
|
||||
TabsSetupFlowManager.updateViewVisiblity(
|
||||
this._id,
|
||||
isVisible ? "closed" : "hidden"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,15 +98,10 @@ async function withFirefoxView(
|
||||
return result;
|
||||
}
|
||||
|
||||
function isFirefoxViewTabSelectedInWindow(win) {
|
||||
return win.gBrowser.selectedBrowser.currentURI.spec == "about:firefoxview";
|
||||
}
|
||||
|
||||
export {
|
||||
withFirefoxView,
|
||||
assertFirefoxViewTab,
|
||||
assertFirefoxViewTabSelected,
|
||||
openFirefoxViewTab,
|
||||
closeFirefoxViewTab,
|
||||
isFirefoxViewTabSelectedInWindow,
|
||||
};
|
||||
|
@ -27,7 +27,5 @@ skip-if = true # Bug 1783684
|
||||
[browser_sync_admin_disabled.js]
|
||||
[browser_tab_close_last_tab.js]
|
||||
[browser_tab_on_close_warning.js]
|
||||
[browser_tab_pickup_device_added_telemetry.js]
|
||||
[browser_tab_pickup_list.js]
|
||||
[browser_tab_pickup_visibility.js]
|
||||
[browser_ui_state.js]
|
||||
|
@ -35,8 +35,6 @@ add_task(async function test_primary_password_locked() {
|
||||
sandbox
|
||||
.stub(TabsSetupFlowManager, "syncTabs")
|
||||
.returns(Promise.resolve(null));
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
syncedTabsMock.returns(Promise.resolve(getMockTabData(syncedTabsData1)));
|
||||
|
||||
const { document } = browser.contentWindow;
|
||||
Services.obs.notifyObservers(null, UIState.ON_UPDATE);
|
||||
|
@ -132,7 +132,7 @@ add_task(async function test_tab_sync_loading() {
|
||||
|
||||
add_task(async function test_tab_no_sync() {
|
||||
// Ensure we take down the waiting message if SyncedTabs determines it doesnt need to sync
|
||||
const recentTabsData = structuredClone(syncedTabsData1[0].tabs);
|
||||
const recentTabsData = [];
|
||||
const sandbox = setupMocks(recentTabsData);
|
||||
// stub syncTabs so it resolves to false - meaning it will not trigger a sync, which is the case
|
||||
// we want to cover in this test.
|
||||
|
@ -1,271 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
ChromeUtils.defineESModuleGetters(globalThis, {
|
||||
SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
|
||||
});
|
||||
|
||||
SimpleTest.requestCompleteLog();
|
||||
|
||||
registerCleanupFunction(async function() {
|
||||
await clearAllParentTelemetryEvents();
|
||||
cleanup_tab_pickup();
|
||||
});
|
||||
|
||||
function setupWithFxaDevices() {
|
||||
const sandbox = setupSyncFxAMocks({
|
||||
state: UIState.STATUS_SIGNED_IN,
|
||||
fxaDevices: [
|
||||
{
|
||||
id: 1,
|
||||
name: "My desktop",
|
||||
isCurrentDevice: true,
|
||||
type: "desktop",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Other device",
|
||||
isCurrentDevice: false,
|
||||
type: "mobile",
|
||||
},
|
||||
],
|
||||
});
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
const mockDesktopTab1 = {
|
||||
client: "6c12bonqXZh8",
|
||||
device: "My desktop",
|
||||
deviceType: "desktop",
|
||||
type: "tab",
|
||||
title: "Example2",
|
||||
url: "https://example.com",
|
||||
icon: "https://example/favicon.png",
|
||||
lastUsed: Math.floor((Date.now() - 1000 * 60) / 1000), // This is one minute from now, which is below the threshold for 'Just now'
|
||||
};
|
||||
|
||||
const mockDesktopTab2 = {
|
||||
client: "6c12bonqXZh8",
|
||||
device: "My desktop",
|
||||
deviceType: "desktop",
|
||||
type: "tab",
|
||||
title: "Sandboxes - Sinon.JS",
|
||||
url: "https://sinonjs.org/releases/latest/sandbox/",
|
||||
icon: "https://sinonjs.org/assets/images/favicon.png",
|
||||
lastUsed: 1655391592, // Thu Jun 16 2022 14:59:52 GMT+0000
|
||||
};
|
||||
|
||||
const mockMobileTab1 = {
|
||||
client: "9d0y686hBXel",
|
||||
device: "My phone",
|
||||
deviceType: "mobile",
|
||||
type: "tab",
|
||||
title: "Element",
|
||||
url: "https://chat.mozilla.org/#room:mozilla.org",
|
||||
icon: "https://chat.mozilla.org/vector-icons/favicon.ico",
|
||||
lastUsed: 1664571288,
|
||||
};
|
||||
|
||||
const NO_TABS_EVENTS = [
|
||||
["firefoxview", "entered", "firefoxview", undefined],
|
||||
["firefoxview", "synced_tabs", "tabs", undefined, { count: "0" }],
|
||||
];
|
||||
const SINGLE_TAB_EVENTS = [
|
||||
["firefoxview", "entered", "firefoxview", undefined],
|
||||
["firefoxview", "synced_tabs", "tabs", undefined, { count: "1" }],
|
||||
];
|
||||
const DEVICE_ADDED_NO_TABS_EVENTS = [
|
||||
["firefoxview", "synced_tabs", "tabs", undefined, undefined],
|
||||
["firefoxview", "synced_tabs_empty", "since_device_added", undefined],
|
||||
];
|
||||
const DEVICE_ADDED_TABS_EVENTS = [
|
||||
["firefoxview", "synced_tabs", "tabs", undefined, undefined],
|
||||
];
|
||||
|
||||
async function test_device_added({
|
||||
initialRecentTabsResult,
|
||||
expectedInitialTelementryEvents,
|
||||
expectedDeviceAddedTelementryEvents,
|
||||
}) {
|
||||
const recentTabsResult = initialRecentTabsResult;
|
||||
await clearAllParentTelemetryEvents();
|
||||
const sandbox = setupWithFxaDevices();
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${recentTabsResult.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(recentTabsResult);
|
||||
});
|
||||
|
||||
ok(
|
||||
!TabsSetupFlowManager.hasVisibleViews,
|
||||
"Initially hasVisibleViews is false"
|
||||
);
|
||||
is(
|
||||
TabsSetupFlowManager._viewVisibilityStates.size,
|
||||
0,
|
||||
"Initially, there are no visible views"
|
||||
);
|
||||
ok(
|
||||
!isFirefoxViewTabSelected(),
|
||||
"Before we call withFirefoxView, about:firefoxview tab is not selected"
|
||||
);
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
info("inside withFirefoxView taskFn, waiting for setupListState");
|
||||
const { document } = browser.contentWindow;
|
||||
await setupListState(browser);
|
||||
info("setupListState finished");
|
||||
|
||||
const isTablistVisible = !!initialRecentTabsResult.length;
|
||||
testVisibility(browser, {
|
||||
expectedVisible: {
|
||||
"ol.synced-tabs-list": isTablistVisible,
|
||||
"#synced-tabs-placeholder": !isTablistVisible,
|
||||
},
|
||||
});
|
||||
const syncedTabsItems = document.querySelectorAll(
|
||||
"ol.synced-tabs-list > li:not(.synced-tab-li-placeholder)"
|
||||
);
|
||||
info(
|
||||
"list items: " +
|
||||
Array.from(syncedTabsItems)
|
||||
.map(li => `li.${li.className}`)
|
||||
.join(", ")
|
||||
);
|
||||
is(
|
||||
syncedTabsItems.length,
|
||||
initialRecentTabsResult.length,
|
||||
`synced-tabs-list should have initial count of ${initialRecentTabsResult.length} non-placeholder list items`
|
||||
);
|
||||
|
||||
// confirm telemetry is in expected state?
|
||||
info(
|
||||
"Checking telemetry against expectedInitialTelementryEvents: " +
|
||||
JSON.stringify(expectedInitialTelementryEvents, null, 2)
|
||||
);
|
||||
TelemetryTestUtils.assertEvents(
|
||||
expectedInitialTelementryEvents,
|
||||
{ category: "firefoxview" },
|
||||
{ clear: true, process: "parent" }
|
||||
);
|
||||
|
||||
// add a new mock device
|
||||
info("Adding a new mock fxa dedvice");
|
||||
gMockFxaDevices.push({
|
||||
id: 1,
|
||||
name: "My primary phone",
|
||||
isCurrentDevice: false,
|
||||
type: "mobile",
|
||||
});
|
||||
|
||||
// Notify of the newly added device
|
||||
const startWaitingSpy = sandbox.spy(
|
||||
TabsSetupFlowManager,
|
||||
"startWaitingForNewDeviceTabs"
|
||||
);
|
||||
|
||||
info("Notifying devicelist_updated with the new mobile device");
|
||||
Services.obs.notifyObservers(null, "fxaccounts:devicelist_updated");
|
||||
info("Waiting for the startWaitingForNewDeviceTabs spy to be called");
|
||||
await TestUtils.waitForCondition(() => startWaitingSpy.called);
|
||||
|
||||
is(
|
||||
startWaitingSpy.getCall(0).returnValue.constructor.name,
|
||||
"Promise",
|
||||
"startWaitingForNewDeviceTabs returned a promise"
|
||||
);
|
||||
|
||||
// Some time passes here waiting for sync to get data from that device
|
||||
// we expect new-device handling to kick in. If there are 0 tabs we'll signal we're waiting,
|
||||
// create a timestamp and only clear it when there are > 0 tabs.
|
||||
// If there are already > 0 tabs, we'll basically do nothing, showing any new tabs when the arrive
|
||||
info("Waiting for the startWaitingForNewDeviceTabs promise to resolve");
|
||||
await startWaitingSpy.getCall(0).returnValue;
|
||||
|
||||
info(
|
||||
"Initial tabs count: " +
|
||||
recentTabsResult.length +
|
||||
", assert on waitingForTabs, TabsSetupFlowManager.waitingForTabs: " +
|
||||
TabsSetupFlowManager.waitingForTabs
|
||||
);
|
||||
is(
|
||||
TabsSetupFlowManager.waitingForTabs,
|
||||
!recentTabsResult.length,
|
||||
"Should be waiting if there were 0 tabs initially"
|
||||
);
|
||||
|
||||
// Add tab data from this new device and notify of the changed data
|
||||
recentTabsResult.push(mockMobileTab1);
|
||||
const stopWaitingSpy = sandbox.spy(
|
||||
TabsSetupFlowManager,
|
||||
"stopWaitingForTabs"
|
||||
);
|
||||
|
||||
info("Notifying tabs.changed with the new mobile device's tabs");
|
||||
Services.obs.notifyObservers(null, "services.sync.tabs.changed");
|
||||
|
||||
// handling the tab.change and clearing the timestamp is necessarily async
|
||||
// as counting synced tabs via getRecentTabs() is async.
|
||||
// There may not be any outcome depending on the tab state, so we just wait
|
||||
// for stopWaitingForTabs to get called and its promise to resolve
|
||||
if (TabsSetupFlowManager.waitingForTabs) {
|
||||
// the setup manager will notify when we stop loading/waiting
|
||||
info("Waiting for setupstate.changed to be notified");
|
||||
await TestUtils.topicObserved("firefox-view.setupstate.changed");
|
||||
}
|
||||
info("Waiting for the stopWaitingSpy to be called");
|
||||
await TestUtils.waitForCondition(() => stopWaitingSpy.called);
|
||||
is(
|
||||
stopWaitingSpy.getCall(0).returnValue.constructor.name,
|
||||
"Promise",
|
||||
"stopWaitingTabs returned a promise"
|
||||
);
|
||||
info("Waiting for the stopWaiting promise to resolve");
|
||||
await stopWaitingSpy.getCall(0).returnValue;
|
||||
|
||||
let snapshots = Services.telemetry.snapshotEvents(
|
||||
Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
|
||||
false
|
||||
);
|
||||
info(
|
||||
"Weve added a synced tab and updated the tab list, got snapshotEvents:" +
|
||||
JSON.stringify(snapshots, null, 2)
|
||||
);
|
||||
// confirm no telemetry was recorded for tabs from the newly-added device
|
||||
// as the tab list was never empty
|
||||
info(
|
||||
"Checking telemetry against expectedDeviceAddedTelementryEvents: " +
|
||||
JSON.stringify(expectedDeviceAddedTelementryEvents, null, 2)
|
||||
);
|
||||
TelemetryTestUtils.assertEvents(
|
||||
expectedDeviceAddedTelementryEvents,
|
||||
{ category: "firefoxview" },
|
||||
{ clear: true, process: "parent" }
|
||||
);
|
||||
});
|
||||
sandbox.restore();
|
||||
cleanup_tab_pickup();
|
||||
}
|
||||
|
||||
add_task(async function test_device_added_with_existing_tabs() {
|
||||
/* Confirm that no telemetry is recorded when a new device is added while the synced tabs list has tabs */
|
||||
await test_device_added({
|
||||
initialRecentTabsResult: [mockDesktopTab1],
|
||||
expectedInitialTelementryEvents: SINGLE_TAB_EVENTS,
|
||||
expectedDeviceAddedTelementryEvents: DEVICE_ADDED_TABS_EVENTS,
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function test_device_added_with_empty_list() {
|
||||
/* Confirm that telemetry is recorded when a device is added and the synced tabs list
|
||||
is empty until its tabs get synced
|
||||
*/
|
||||
await test_device_added({
|
||||
initialRecentTabsResult: [],
|
||||
expectedInitialTelementryEvents: NO_TABS_EVENTS,
|
||||
expectedDeviceAddedTelementryEvents: DEVICE_ADDED_NO_TABS_EVENTS,
|
||||
});
|
||||
});
|
@ -145,12 +145,11 @@ add_task(async function test_tab_list_ordering() {
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData(syncedTabsData1);
|
||||
let mockTabs2 = getMockTabData(syncedTabsData2);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -183,7 +182,7 @@ add_task(async function test_tab_list_ordering() {
|
||||
"Last list item in synced-tabs-list is in the correct order"
|
||||
);
|
||||
|
||||
getRecentTabsResult = mockTabs2;
|
||||
syncedTabsMock.returns(mockTabs2);
|
||||
// Initiate a synced tabs update
|
||||
Services.obs.notifyObservers(null, "services.sync.tabs.changed");
|
||||
|
||||
@ -224,12 +223,11 @@ add_task(async function test_empty_list_items() {
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData(syncedTabsData3);
|
||||
let mockTabs2 = getMockTabData(syncedTabsData4);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -269,7 +267,7 @@ add_task(async function test_empty_list_items() {
|
||||
"Last list item in synced-tabs-list should be a placeholder"
|
||||
);
|
||||
|
||||
getRecentTabsResult = mockTabs2;
|
||||
syncedTabsMock.returns(mockTabs2);
|
||||
// Initiate a synced tabs update
|
||||
Services.obs.notifyObservers(null, "services.sync.tabs.changed");
|
||||
|
||||
@ -307,12 +305,11 @@ add_task(async function test_empty_list() {
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData([]);
|
||||
let mockTabs2 = getMockTabData(syncedTabsData4);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -353,7 +350,12 @@ add_task(async function test_empty_list() {
|
||||
{ clear: true, process: "parent" }
|
||||
);
|
||||
|
||||
getRecentTabsResult = mockTabs2;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs2.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(mockTabs2);
|
||||
});
|
||||
// Initiate a synced tabs update
|
||||
Services.obs.notifyObservers(null, "services.sync.tabs.changed");
|
||||
|
||||
@ -385,12 +387,11 @@ add_task(async function test_time_updates_correctly() {
|
||||
const sandbox = setupRecentDeviceListMocks();
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData(syncedTabsData5);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -496,12 +497,11 @@ add_task(async function test_tabs_sync_on_user_page_reload() {
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData(syncedTabsData1);
|
||||
let expectedTabsAfterReload = getMockTabData(syncedTabsData3);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -518,12 +518,9 @@ add_task(async function test_tabs_sync_on_user_page_reload() {
|
||||
ok(true, "Firefox View has been reloaded");
|
||||
ok(TabsSetupFlowManager.waitingForTabs, "waitingForTabs is true");
|
||||
|
||||
let waitedForTabs = TestUtils.waitForCondition(() => {
|
||||
return !TabsSetupFlowManager.waitingForTabs;
|
||||
});
|
||||
|
||||
getRecentTabsResult = expectedTabsAfterReload;
|
||||
syncedTabsMock.returns(expectedTabsAfterReload);
|
||||
Services.obs.notifyObservers(null, "services.sync.tabs.changed");
|
||||
ok(!TabsSetupFlowManager.waitingForTabs, "waitingForTabs is false");
|
||||
|
||||
const syncedTabsList = document.querySelector("ol.synced-tabs-list");
|
||||
// The tab pickup list has been updated
|
||||
@ -533,7 +530,6 @@ add_task(async function test_tabs_sync_on_user_page_reload() {
|
||||
() =>
|
||||
syncedTabsList.firstChild.textContent.includes("Sandboxes - Sinon.JS")
|
||||
);
|
||||
await waitedForTabs;
|
||||
|
||||
sandbox.restore();
|
||||
cleanup_tab_pickup();
|
||||
@ -546,12 +542,11 @@ add_task(async function test_keyboard_navigation() {
|
||||
const sandbox = setupRecentDeviceListMocks();
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData(syncedTabsData1);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -658,12 +653,11 @@ add_task(async function test_duplicate_tab_filter() {
|
||||
const sandbox = setupRecentDeviceListMocks();
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs6 = getMockTabData(syncedTabsData6);
|
||||
let getRecentTabsResult = mockTabs6;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs6.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs6);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -714,12 +708,11 @@ add_task(async function test_tabs_dont_update_unnecessarily() {
|
||||
const sandbox = setupRecentDeviceListMocks();
|
||||
const syncedTabsMock = sandbox.stub(SyncedTabs, "getRecentTabs");
|
||||
let mockTabs1 = getMockTabData(syncedTabsData1);
|
||||
let getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.callsFake(() => {
|
||||
info(
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${getRecentTabsResult.length} tabs\n`
|
||||
`Stubbed SyncedTabs.getRecentTabs returning a promise that resolves to ${mockTabs1.length} tabs\n`
|
||||
);
|
||||
return Promise.resolve(getRecentTabsResult);
|
||||
return Promise.resolve(mockTabs1);
|
||||
});
|
||||
|
||||
await withFirefoxView({}, async browser => {
|
||||
@ -760,15 +753,13 @@ add_task(async function test_tabs_dont_update_unnecessarily() {
|
||||
|
||||
observer.observe(syncedTabsList, { childList: true, subtree: true });
|
||||
|
||||
getRecentTabsResult = mockTabs1;
|
||||
syncedTabsMock.returns(mockTabs1);
|
||||
const tabPickupList = document.querySelector("tab-pickup-list");
|
||||
const updateTabsListSpy = sandbox.spy(tabPickupList, "updateTabsList");
|
||||
|
||||
// Initiate a synced tabs update
|
||||
Services.obs.notifyObservers(null, "services.sync.tabs.changed");
|
||||
await TestUtils.waitForCondition(() => {
|
||||
return !TabsSetupFlowManager.waitingForTabs;
|
||||
});
|
||||
|
||||
await TestUtils.waitForCondition(() => updateTabsListSpy.called);
|
||||
Assert.ok(!wasMutated, "The synced tabs list was not mutated");
|
||||
|
||||
|
@ -1,149 +0,0 @@
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
registerCleanupFunction(async function() {
|
||||
Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF);
|
||||
});
|
||||
|
||||
async function setup({ open } = {}) {
|
||||
TabsSetupFlowManager.resetInternalState();
|
||||
// sanity check initial values
|
||||
ok(
|
||||
!TabsSetupFlowManager.hasVisibleViews,
|
||||
"Initially hasVisibleViews is false"
|
||||
);
|
||||
is(
|
||||
TabsSetupFlowManager._viewVisibilityStates.size,
|
||||
0,
|
||||
"Initially, there are no visible views"
|
||||
);
|
||||
ok(
|
||||
!isFirefoxViewTabSelected(),
|
||||
"During setup, the about:firefoxview tab is not selected"
|
||||
);
|
||||
|
||||
if (typeof open == "undefined") {
|
||||
Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF);
|
||||
} else {
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
set: [[TAB_PICKUP_STATE_PREF, open]],
|
||||
});
|
||||
}
|
||||
const sandbox = sinon.createSandbox();
|
||||
sandbox.stub(TabsSetupFlowManager, "isTabSyncSetupComplete").get(() => true);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
add_task(async function test_tab_pickup_visibility() {
|
||||
/* Confirm the correct number of tab-pickup views are registered as visible */
|
||||
const sandbox = await setup();
|
||||
|
||||
await withFirefoxView({ win: window }, async function(browser) {
|
||||
const { document } = browser.contentWindow;
|
||||
let tabPickupContainer = document.querySelector("#tab-pickup-container");
|
||||
|
||||
ok(tabPickupContainer.open, "Tab Pickup container should be open");
|
||||
ok(isFirefoxViewTabSelected(), "The firefox view tab is selected");
|
||||
ok(TabsSetupFlowManager.hasVisibleViews, "hasVisibleViews");
|
||||
is(TabsSetupFlowManager._viewVisibilityStates.size, 1, "One view");
|
||||
|
||||
info("Opening and switching to different tab to background fx-view");
|
||||
let newTab = await BrowserTestUtils.openNewForegroundTab(
|
||||
gBrowser,
|
||||
"about:mozilla"
|
||||
);
|
||||
ok(!isFirefoxViewTabSelected(), "The firefox view tab is not selected");
|
||||
ok(
|
||||
!TabsSetupFlowManager.hasVisibleViews,
|
||||
"no view visible when fx-view is not active"
|
||||
);
|
||||
let newWin = await BrowserTestUtils.openNewBrowserWindow();
|
||||
await openFirefoxViewTab(newWin);
|
||||
|
||||
ok(
|
||||
isFirefoxViewTabSelected(newWin),
|
||||
"The firefox view tab in the new window is selected"
|
||||
);
|
||||
ok(
|
||||
TabsSetupFlowManager.hasVisibleViews,
|
||||
"view registered as visible when fx-view is opened in a new window"
|
||||
);
|
||||
is(TabsSetupFlowManager._viewVisibilityStates.size, 2, "2 tracked views");
|
||||
|
||||
await BrowserTestUtils.closeWindow(newWin);
|
||||
|
||||
ok(
|
||||
!isFirefoxViewTabSelected(),
|
||||
"The firefox view tab in the original window is not selected"
|
||||
);
|
||||
ok(
|
||||
!TabsSetupFlowManager.hasVisibleViews,
|
||||
"no visible views when fx-view is not the active tab in the remaining window"
|
||||
);
|
||||
is(
|
||||
TabsSetupFlowManager._viewVisibilityStates.size,
|
||||
1,
|
||||
"Back to one tracked view"
|
||||
);
|
||||
|
||||
// Switch back to FxView:
|
||||
await BrowserTestUtils.switchTab(
|
||||
gBrowser,
|
||||
gBrowser.getTabForBrowser(browser)
|
||||
);
|
||||
|
||||
ok(
|
||||
isFirefoxViewTabSelected(),
|
||||
"The firefox view tab in the original window is now selected"
|
||||
);
|
||||
ok(
|
||||
TabsSetupFlowManager.hasVisibleViews,
|
||||
"View visibility updated when we switch tab"
|
||||
);
|
||||
BrowserTestUtils.removeTab(newTab);
|
||||
});
|
||||
sandbox.restore();
|
||||
await SpecialPowers.popPrefEnv();
|
||||
ok(
|
||||
!TabsSetupFlowManager.hasVisibleViews,
|
||||
"View visibility updated after withFirefoxView"
|
||||
);
|
||||
});
|
||||
|
||||
add_task(async function test_instance_closed() {
|
||||
/* Confirm tab-pickup views are correctly accounted for when toggled closed */
|
||||
const sandbox = await setup({ open: false });
|
||||
await withFirefoxView({ win: window }, async function(browser) {
|
||||
const { document } = browser.contentWindow;
|
||||
info(
|
||||
"tab-pickup.open pref: " +
|
||||
Services.prefs.getBoolPref(
|
||||
"browser.tabs.firefox-view.ui-state.tab-pickup.open"
|
||||
)
|
||||
);
|
||||
info(
|
||||
"isTabSyncSetupComplete: " + TabsSetupFlowManager.isTabSyncSetupComplete
|
||||
);
|
||||
let tabPickupContainer = document.querySelector("#tab-pickup-container");
|
||||
ok(!tabPickupContainer.open, "Tab Pickup container should be closed");
|
||||
info(
|
||||
"_viewVisibilityStates" +
|
||||
JSON.stringify(
|
||||
Array.from(TabsSetupFlowManager._viewVisibilityStates.values()),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
ok(!TabsSetupFlowManager.hasVisibleViews, "no visible views");
|
||||
is(
|
||||
TabsSetupFlowManager._viewVisibilityStates.size,
|
||||
1,
|
||||
"One registered view"
|
||||
);
|
||||
|
||||
tabPickupContainer.open = true;
|
||||
await TestUtils.waitForTick();
|
||||
ok(TabsSetupFlowManager.hasVisibleViews, "view visible");
|
||||
});
|
||||
sandbox.restore();
|
||||
});
|
@ -7,7 +7,6 @@ const {
|
||||
assertFirefoxViewTabSelected,
|
||||
openFirefoxViewTab,
|
||||
closeFirefoxViewTab,
|
||||
isFirefoxViewTabSelectedInWindow,
|
||||
} = ChromeUtils.importESModule(
|
||||
"resource://testing-common/FirefoxViewTestUtils.sys.mjs"
|
||||
);
|
||||
@ -528,7 +527,3 @@ function cleanup_tab_pickup() {
|
||||
Services.prefs.clearUserPref("services.sync.lastTabFetch");
|
||||
Services.prefs.clearUserPref(TAB_PICKUP_STATE_PREF);
|
||||
}
|
||||
|
||||
function isFirefoxViewTabSelected(win = window) {
|
||||
return isFirefoxViewTabSelectedInWindow(win);
|
||||
}
|
||||
|
@ -3650,20 +3650,6 @@ firefoxview:
|
||||
- 1789641
|
||||
expiry_version: "never"
|
||||
release_channel_collection: opt-out
|
||||
synced_tabs_empty:
|
||||
objects: ["since_device_added"]
|
||||
description: >
|
||||
Records the time elapsed in seconds that the Tab Pickup empty message is displayed after a new device was added
|
||||
notification_emails:
|
||||
- firefox-view-engineers@mozilla.com
|
||||
products:
|
||||
- "firefox"
|
||||
record_in_processes:
|
||||
- main
|
||||
bug_numbers:
|
||||
- 1809661
|
||||
expiry_version: "120"
|
||||
release_channel_collection: opt-out
|
||||
closed_tabs_open:
|
||||
objects: ["tabs"]
|
||||
description: >
|
||||
|
Loading…
Reference in New Issue
Block a user