diff --git a/special-pages/01.png b/special-pages/01.png new file mode 100644 index 0000000000..7dfbeff873 Binary files /dev/null and b/special-pages/01.png differ diff --git a/special-pages/02.png b/special-pages/02.png new file mode 100644 index 0000000000..6f75d89e13 Binary files /dev/null and b/special-pages/02.png differ diff --git a/special-pages/03.png b/special-pages/03.png new file mode 100644 index 0000000000..8a85bfcbef Binary files /dev/null and b/special-pages/03.png differ diff --git a/special-pages/end.png b/special-pages/end.png new file mode 100644 index 0000000000..3331756384 Binary files /dev/null and b/special-pages/end.png differ diff --git a/special-pages/end2.png b/special-pages/end2.png new file mode 100644 index 0000000000..f42ad93087 Binary files /dev/null and b/special-pages/end2.png differ diff --git a/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js b/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js index b87df3b6fe..fbe85a1042 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js @@ -110,5 +110,8 @@ export function useCustomizer({ title, id, icon, toggle, visibility, index }) { useEffect(() => { window.dispatchEvent(new Event(UPDATE_EVENT)); + return () => { + window.dispatchEvent(new Event(UPDATE_EVENT)); + }; }, [visibility]); } diff --git a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js index 9ce34cb71c..260a8a9127 100644 --- a/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js +++ b/special-pages/pages/new-tab/app/customizer/integration-tests/customizer.page.js @@ -20,6 +20,8 @@ export class CustomizerPage { this.ntp = ntp; } + context = () => this.ntp.page.locator('aside'); + async showsColorSelectionPanel() { const { page } = this.ntp; await page.locator('aside').getByLabel('Solid Colors').click(); @@ -459,4 +461,34 @@ export class CustomizerPage { button: 'right', }); } + + /** + * @param {string} name + * @returns {Promise} + */ + async isChecked(name) { + await expect(this.context().getByRole('switch', { name })).toBeChecked(); + } + + /** + * @param {string} name + * @returns {Promise} + */ + async isUnchecked(name) { + await expect(this.context().getByRole('switch', { name })).not.toBeChecked({ timeout: 1000 }); + } + + /** + * @param {string} name + */ + async hasSwitch(name) { + await expect(this.context().getByRole('switch', { name })).toBeVisible(); + } + + /** + * @param {string} name + */ + async doesntHaveSwitch(name) { + await expect(this.context().getByRole('switch', { name })).not.toBeVisible(); + } } diff --git a/special-pages/pages/new-tab/app/customizer/mocks.js b/special-pages/pages/new-tab/app/customizer/mocks.js index 60ff3bce28..ebeb4e0238 100644 --- a/special-pages/pages/new-tab/app/customizer/mocks.js +++ b/special-pages/pages/new-tab/app/customizer/mocks.js @@ -71,7 +71,6 @@ export function customizerMockTransport() { case 'customizer_onBackgroundUpdate': case 'customizer_onImagesUpdate': { subscriptions.set(sub, cb); - console.log('did add sub', sub); return () => { console.log('-- did remove sub', sub); return subscriptions.delete(sub); diff --git a/special-pages/pages/new-tab/app/index.js b/special-pages/pages/new-tab/app/index.js index 7d7fc74a58..bf9b61deb7 100644 --- a/special-pages/pages/new-tab/app/index.js +++ b/special-pages/pages/new-tab/app/index.js @@ -16,6 +16,9 @@ import { CustomizerService } from './customizer/customizer.service.js'; import { InlineErrorBoundary } from './InlineErrorBoundary.js'; import { DocumentVisibilityProvider } from '../../../shared/components/DocumentVisibility.js'; import { applyDefaultStyles } from './customizer/utils.js'; +import { TabsService } from './tabs/tabs.service.js'; +import { TabsDebug, TabsProvider } from './tabs/TabsProvider.js'; +import { PersistentScrollProvider } from './tabs/ScrollRestore.js'; /** * @import {Telemetry} from "./telemetry/telemetry.js" @@ -89,6 +92,7 @@ export async function init(root, messaging, telemetry, baseEnvironment) { // Resolve the entry points for each selected widget const entryPoints = await resolveEntryPoints(init.widgets, didCatch); + const tabs = new TabsService(messaging, init.tabs || TabsService.DEFAULT); // Create an instance of the global widget api const widgetConfigAPI = new WidgetConfigService(messaging, init.widgetConfigs); @@ -129,7 +133,11 @@ export async function init(root, messaging, telemetry, baseEnvironment) { widgets={init.widgets} entryPoints={entryPoints} > - + + + {environment.urlParams.has('tabs.debug') && } + + diff --git a/special-pages/pages/new-tab/app/mock-transport.js b/special-pages/pages/new-tab/app/mock-transport.js index a43d032f3f..9c04d5fc36 100644 --- a/special-pages/pages/new-tab/app/mock-transport.js +++ b/special-pages/pages/new-tab/app/mock-transport.js @@ -10,6 +10,7 @@ import { freemiumPIRDataExamples } from './freemium-pir-banner/mocks/freemiumPIR import { activityMockTransport } from './activity/mocks/activity.mock-transport.js'; import { protectionsMockTransport } from './protections/mocks/protections.mock-transport.js'; import { omnibarMockTransport } from './omnibar/mocks/omnibar.mock-transport.js'; +import { tabsMockTransport } from './tabs/tabs.mock-transport.js'; /** * @typedef {import('../types/new-tab').Favorite} Favorite @@ -119,6 +120,7 @@ export function mockTransport() { activity: activityMockTransport(), protections: protectionsMockTransport(), omnibar: omnibarMockTransport(), + tabs: tabsMockTransport(), }; return new TestTransportConfig({ @@ -492,68 +494,7 @@ export function mockTransport() { return Promise.resolve(fromStorage); } case 'initialSetup': { - /** @type {import('../types/new-tab.ts').Widgets} */ - const widgetsFromStorage = read('widgets') || [ - { id: 'updateNotification' }, - { id: 'rmf' }, - { id: 'freemiumPIRBanner' }, - { id: 'nextSteps' }, - { id: 'favorites' }, - ]; - - /** @type {import('../types/new-tab.ts').WidgetConfigs} */ - const widgetConfigFromStorage = read('widget_config') || [{ id: 'favorites', visibility: 'visible' }]; - - /** @type {UpdateNotificationData} */ - let updateNotification = { content: null }; - const isDelayed = url.searchParams.has('update-notification-delay'); - - if (!isDelayed && url.searchParams.has('update-notification')) { - const value = url.searchParams.get('update-notification'); - if (value && value in updateNotificationExamples) { - updateNotification = updateNotificationExamples[value]; - } - } - - /** @type {import('../types/new-tab.ts').InitialSetupResponse} */ - const initial = { - widgets: widgetsFromStorage, - widgetConfigs: widgetConfigFromStorage, - platform: { name: 'integration' }, - env: 'development', - locale: 'en', - updateNotification, - }; - - widgetsFromStorage.push({ id: 'protections' }); - widgetConfigFromStorage.push({ id: 'protections', visibility: 'visible' }); - - if (url.searchParams.has('omnibar')) { - const favoritesWidgetIndex = widgetsFromStorage.findIndex((widget) => widget.id === 'favorites') ?? 0; - widgetsFromStorage.splice(favoritesWidgetIndex, 0, { id: 'omnibar' }); - const favoritesWidgetConfigIndex = widgetConfigFromStorage.findIndex((widget) => widget.id === 'favorites') ?? 0; - widgetConfigFromStorage.splice(favoritesWidgetConfigIndex, 0, { id: 'omnibar', visibility: 'visible' }); - } - - initial.customizer = customizerData(); - - /** @type {import('../types/new-tab').NewTabPageSettings} */ - const settings = { - customizerDrawer: { state: 'enabled' }, - }; - - if (url.searchParams.get('autoOpen') === 'true' && settings.customizerDrawer) { - settings.customizerDrawer.autoOpen = true; - } - - if (url.searchParams.get('adBlocking') === 'enabled') { - settings.adBlocking = { state: 'enabled' }; - } - - // feature flags - initial.settings = settings; - - return Promise.resolve(initial); + return Promise.resolve(initialSetup(url)); } default: { return Promise.reject(new Error('unhandled request' + msg)); @@ -563,6 +504,78 @@ export function mockTransport() { }); } +/** + * @param {URL} url + * @return {import('../types/new-tab').InitialSetupResponse} + */ +export function initialSetup(url) { + /** @type {import('../types/new-tab.ts').Widgets} */ + const widgetsFromStorage = [ + { id: 'updateNotification' }, + { id: 'rmf' }, + { id: 'freemiumPIRBanner' }, + { id: 'nextSteps' }, + { id: 'favorites' }, + ]; + + /** @type {import('../types/new-tab.ts').WidgetConfigs} */ + const widgetConfigFromStorage = [{ id: 'favorites', visibility: 'visible' }]; + + /** @type {UpdateNotificationData} */ + let updateNotification = { content: null }; + const isDelayed = url.searchParams.has('update-notification-delay'); + + if (!isDelayed && url.searchParams.has('update-notification')) { + const value = url.searchParams.get('update-notification'); + if (value && value in updateNotificationExamples) { + updateNotification = updateNotificationExamples[value]; + } + } + + /** @type {import('../types/new-tab.ts').InitialSetupResponse} */ + const initial = { + widgets: widgetsFromStorage, + widgetConfigs: widgetConfigFromStorage, + platform: { name: 'integration' }, + env: 'development', + locale: 'en', + updateNotification, + }; + + widgetsFromStorage.push({ id: 'protections' }); + widgetConfigFromStorage.push({ id: 'protections', visibility: 'visible' }); + + if (url.searchParams.has('omnibar')) { + const favoritesWidgetIndex = widgetsFromStorage.findIndex((widget) => widget.id === 'favorites') ?? 0; + widgetsFromStorage.splice(favoritesWidgetIndex, 0, { id: 'omnibar' }); + const favoritesWidgetConfigIndex = widgetConfigFromStorage.findIndex((widget) => widget.id === 'favorites') ?? 0; + widgetConfigFromStorage.splice(favoritesWidgetConfigIndex, 0, { id: 'omnibar', visibility: 'visible' }); + } + + initial.customizer = customizerData(); + + /** @type {import('../types/new-tab').NewTabPageSettings} */ + const settings = { + customizerDrawer: { state: 'enabled' }, + }; + + if (url.searchParams.get('autoOpen') === 'true' && settings.customizerDrawer) { + settings.customizerDrawer.autoOpen = true; + } + + if (url.searchParams.get('adBlocking') === 'enabled') { + settings.adBlocking = { state: 'enabled' }; + } + + if (url.searchParams.has('tabs')) { + initial.tabs = { tabId: '01', tabIds: ['01'] }; + } + + // feature flags + initial.settings = settings; + return initial; +} + /** * @template {{id: string}} T * @param {T[]} array diff --git a/special-pages/pages/new-tab/app/new-tab.md b/special-pages/pages/new-tab/app/new-tab.md index a507d63259..190dc5103c 100644 --- a/special-pages/pages/new-tab/app/new-tab.md +++ b/special-pages/pages/new-tab/app/new-tab.md @@ -11,6 +11,7 @@ children: - ./customizer/customizer.md - ./protections/protections.md - ./omnibar/omnibar.md + - ./tabs/tabs.md --- ## Requests diff --git a/special-pages/pages/new-tab/app/omnibar/components/Omnibar.js b/special-pages/pages/new-tab/app/omnibar/components/Omnibar.js index 2b30b582c1..6e921b0166 100644 --- a/special-pages/pages/new-tab/app/omnibar/components/Omnibar.js +++ b/special-pages/pages/new-tab/app/omnibar/components/Omnibar.js @@ -10,6 +10,7 @@ import { SearchForm } from './SearchForm'; import { SearchFormProvider } from './SearchFormProvider'; import { SuggestionsList } from './SuggestionsList'; import { TabSwitcher } from './TabSwitcher'; +import { useQueryWithLocalPersistence } from './PersistentOmnibarValuesProvider.js'; /** * @typedef {import('../strings.json')} Strings @@ -23,11 +24,12 @@ import { TabSwitcher } from './TabSwitcher'; * @param {OmnibarConfig['mode']} props.mode * @param {(mode: OmnibarConfig['mode']) => void} props.setMode * @param {boolean} props.enableAi + * @param {string|null|undefined} props.tabId */ -export function Omnibar({ mode, setMode, enableAi }) { +export function Omnibar({ mode, setMode, enableAi, tabId }) { const { t } = useTypedTranslationWith(/** @type {Strings} */ ({})); - const [query, setQuery] = useState(/** @type {String} */ ('')); + const [query, setQuery] = useQueryWithLocalPersistence(tabId); const [resetKey, setResetKey] = useState(0); const [autoFocus, setAutoFocus] = useState(false); diff --git a/special-pages/pages/new-tab/app/omnibar/components/OmnibarConsumer.js b/special-pages/pages/new-tab/app/omnibar/components/OmnibarConsumer.js index 2c90a53394..20ce68d868 100644 --- a/special-pages/pages/new-tab/app/omnibar/components/OmnibarConsumer.js +++ b/special-pages/pages/new-tab/app/omnibar/components/OmnibarConsumer.js @@ -6,10 +6,13 @@ import { useVisibility } from '../../widget-list/widget-config.provider.js'; import { Omnibar } from './Omnibar.js'; import { OmnibarContext } from './OmnibarProvider.js'; import { ArrowIndentCenteredIcon } from '../../components/Icons.js'; +import { useModeWithLocalPersistence } from './PersistentOmnibarValuesProvider.js'; +import { useTabState } from '../../tabs/TabsProvider.js'; /** * @typedef {import('../strings.json')} Strings * @typedef {import('../../../types/new-tab.js').OmnibarConfig} OmnibarConfig + * @typedef {import('../../../types/new-tab.js').OmnibarMode} Mode */ /** @@ -26,8 +29,9 @@ import { ArrowIndentCenteredIcon } from '../../components/Icons.js'; */ export function OmnibarConsumer() { const { state } = useContext(OmnibarContext); + const { current } = useTabState(); if (state.status === 'ready') { - return ; + return ; } return null; } @@ -35,13 +39,17 @@ export function OmnibarConsumer() { /** * @param {object} props * @param {OmnibarConfig} props.config + * @param {string} props.tabId */ -function OmnibarReadyState({ config: { enableAi = true, showAiSetting = true, mode } }) { +function OmnibarReadyState({ config, tabId }) { + const { enableAi = true, showAiSetting = true, mode: defaultMode } = config; const { setEnableAi, setMode } = useContext(OmnibarContext); + const modeForCurrentTab = useModeWithLocalPersistence(tabId, defaultMode); + return ( <> {showAiSetting && } - + ); } diff --git a/special-pages/pages/new-tab/app/omnibar/components/OmnibarCustomized.js b/special-pages/pages/new-tab/app/omnibar/components/OmnibarCustomized.js index 213e775164..4e595577e2 100644 --- a/special-pages/pages/new-tab/app/omnibar/components/OmnibarCustomized.js +++ b/special-pages/pages/new-tab/app/omnibar/components/OmnibarCustomized.js @@ -6,6 +6,7 @@ import { h } from 'preact'; import { OmnibarConsumer } from './OmnibarConsumer.js'; import { SearchIcon } from '../../components/Icons.js'; +import { PersistentModeProvider, PersistentTextInputProvider } from './PersistentOmnibarValuesProvider.js'; /** * @import enStrings from "../strings.json" @@ -30,13 +31,15 @@ export function OmnibarCustomized() { useCustomizer({ title: sectionTitle, id, icon: , toggle, visibility: visibility.value, index }); - if (visibility.value === 'hidden') { - return null; - } - return ( - - - + + + {visibility.value === 'visible' && ( + + + + )} + + ); } diff --git a/special-pages/pages/new-tab/app/omnibar/components/OmnibarProvider.js b/special-pages/pages/new-tab/app/omnibar/components/OmnibarProvider.js index fb4199e796..87d05040f7 100644 --- a/special-pages/pages/new-tab/app/omnibar/components/OmnibarProvider.js +++ b/special-pages/pages/new-tab/app/omnibar/components/OmnibarProvider.js @@ -1,5 +1,5 @@ import { createContext, h } from 'preact'; -import { useCallback, useEffect, useReducer, useRef } from 'preact/hooks'; +import { useCallback, useContext, useEffect, useReducer, useRef } from 'preact/hooks'; import { useMessaging } from '../../types.js'; import { reducer, useInitialDataAndConfig, useConfigSubscription } from '../../service.hooks.js'; import { OmnibarService } from '../omnibar.service.js'; @@ -153,7 +153,7 @@ export function OmnibarProvider(props) { /** * @return {import("preact").RefObject} */ -export function useService() { +function useService() { const service = useRef(/** @type {OmnibarService|null} */ (null)); const ntp = useMessaging(); useEffect(() => { @@ -165,3 +165,7 @@ export function useService() { }, [ntp]); return service; } + +export function useOmnibarService() { + return useContext(OmnibarServiceContext); +} diff --git a/special-pages/pages/new-tab/app/omnibar/components/PersistentOmnibarValuesProvider.js b/special-pages/pages/new-tab/app/omnibar/components/PersistentOmnibarValuesProvider.js new file mode 100644 index 0000000000..18521e22c9 --- /dev/null +++ b/special-pages/pages/new-tab/app/omnibar/components/PersistentOmnibarValuesProvider.js @@ -0,0 +1,122 @@ +import { h, createContext } from 'preact'; +import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; +import { OmnibarContext, useOmnibarService } from './OmnibarProvider.js'; +import { useTabState } from '../../tabs/TabsProvider.js'; +import { PersistentValue } from '../../tabs/PersistentValue.js'; +import { invariant } from '../../utils.js'; + +/** + * @typedef {import("../../../types/new-tab.js").OmnibarConfig["mode"]} Mode + */ + +const TextInputContext = createContext(/** @type {PersistentValue|null} */ (null)); +const ModeContext = createContext(/** @type {PersistentValue|null} */ (null)); + +/** + * @param {object} props + * @param {import('preact').ComponentChildren} props.children + */ +export function PersistentTextInputProvider({ children }) { + const [value] = useState(() => /** @type {PersistentValue} */ (new PersistentValue())); + const { all } = useTabState(); + useEffect(() => { + return all.subscribe((tabIds) => { + value?.prune({ preserve: tabIds }); + }); + }, [all, value]); + return {children}; +} + +/** + * @param {object} props + * @param {import('preact').ComponentChildren} props.children + */ +export function PersistentModeProvider({ children }) { + const [value] = useState(() => /** @type {PersistentValue} */ (new PersistentValue())); + const { all } = useTabState(); + useEffect(() => { + return all.subscribe((tabIds) => { + value?.prune({ preserve: tabIds }); + }); + }, [all, value]); + return {children}; +} + +/** + * A normal set-state, but with values recorded. Must be used when the Omnibar Service is ready + * @param {string|null|undefined} tabId + */ +export function useQueryWithLocalPersistence(tabId) { + const terms = useContext(TextInputContext); + + invariant( + useContext(OmnibarContext).state.status === 'ready', + 'Cannot use `useQueryWithLocalPersistence` without Omnibar Service being ready.', + ); + + const [query, setQuery] = useState(() => terms?.byId(tabId) || ''); + + /** @type {(term: string) => void} */ + const setter = useCallback( + (term) => { + if (tabId) { + terms?.update({ id: tabId, value: term }); + } + setQuery(term); + }, + [tabId, terms], + ); + + return /** @type {const} */ ([query, setter]); +} + +/** + * Sets and remembers the 'mode' for many tabs. Mode doesn't update dynamically with config changes + * and that's why we use the name 'defaultMode'. + * + * Each new tab that's opened adopts the most recent 'mode' value that was persisted in the browser. + * + * @param {string|null|undefined} tabId + * @param {Mode} defaultMode - what to apply for a newly created tab + * @return {Mode} + */ +export function useModeWithLocalPersistence(tabId, defaultMode) { + const values = useContext(ModeContext); + + const [mode, setState] = useState(() => { + const prev = values?.byId(tabId); + if (prev) return prev; + if (tabId && defaultMode) { + values?.update({ id: tabId, value: defaultMode }); + } + return defaultMode; + }); + + invariant( + useContext(OmnibarContext).state.status === 'ready', + 'Cannot use `useQueryWithPersistence` without Omnibar Service being ready.', + ); + + const service = useOmnibarService(); + + useEffect(() => { + if (!service) return; + return service.onConfig((v) => { + if (!tabId) return; + + // when manually updated + enableAi === 'true', allow this tab to be recorded + if (v.source === 'manual') { + values?.update({ id: tabId, value: v.data.mode }); + } + + // when `enableAi` is false, we reset ALL tabs to 'search' + if (v.data.enableAi === false) { + values?.updateAll({ value: 'search' }); + } + + setState(v.data.mode); + }); + }, [service, tabId, values, defaultMode]); + + return mode; +} diff --git a/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.page.js b/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.page.js index ac2c170bfc..89aba37a7c 100644 --- a/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.page.js +++ b/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.page.js @@ -1,4 +1,9 @@ -import { expect } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +/** + * @typedef {import("../../../types/new-tab.js").OmnibarMode} Mode + * @typedef {import("../../../types/new-tab.js").OmnibarConfig} Config + */ export class OmnibarPage { /** @@ -7,6 +12,7 @@ export class OmnibarPage { constructor(ntp) { this.ntp = ntp; this.page = this.ntp.page; + this.page.on('console', (msg) => console.log(msg.text())); } context() { @@ -102,6 +108,13 @@ export class OmnibarPage { await expect(this.searchInput()).toHaveValue(value); } + /** + * @param {string} value + */ + async expectChatValue(value) { + await expect(this.chatInput()).toHaveValue(value); + } + /** * @param {number} startIndex * @param {number} endIndex @@ -167,4 +180,89 @@ export class OmnibarPage { const calls = await this.ntp.mocks.outgoing({ names: [method] }); expect(calls).toHaveLength(0); } + + /** + * @param {string} tabId + * @param {string[]} tabIds + * @returns {Promise} + */ + async didSwitchToTab(tabId, tabIds) { + await test.step(`simulate tab change event, to: ${tabId} `, async () => { + const event = sub('tabs_onDataUpdate').payload({ tabId, tabIds }); + await this.ntp.mocks.simulateSubscriptionEvent(event); + }); + } + + /** + * @param {Config} config + * @returns {Promise} + */ + async didReceiveConfig(config) { + const event = sub('omnibar_onConfigUpdate').payload(config); + await test.step(`simulates global disabled (eg: settings): ${JSON.stringify(event.name)} ${JSON.stringify(event.payload)} `, async () => { + await this.ntp.mocks.simulateSubscriptionEvent(event); + }); + } + + /** + * @param {object} props + * @param {Mode} props.mode + * @returns {Promise} + */ + switchMode({ mode }) { + switch (mode) { + case 'ai': { + return this.aiTab().click(); + } + case 'search': { + return this.searchTab().click(); + } + } + } + + /** + * @param {object} props + * @param {Mode} props.mode + * @param {string} props.value + */ + async expectValue({ mode, value }) { + switch (mode) { + case 'ai': { + return await expect(this.chatInput()).toHaveValue(value); + } + case 'search': { + return await expect(this.searchInput()).toHaveValue(value); + } + } + } + + /** + * @param {object} props + * @param {Mode} props.mode + * @param {string} props.value + * @returns {Promise} + */ + types({ mode, value }) { + switch (mode) { + case 'ai': { + return this.chatInput().fill(value); + } + case 'search': { + return this.searchInput().fill(value); + } + } + } +} + +/** + * @template {import("../../../types/new-tab.js").NewTabMessages["subscriptions"]["subscriptionEvent"]} SubName + * @param {SubName} name + * @return {{payload: (payload: Extract['params']) => {name: string, payload: any}}} + */ +function sub(name) { + return { + payload: (payload) => { + return { name, payload }; + }, + }; } diff --git a/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.persistence.spec.js b/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.persistence.spec.js new file mode 100644 index 0000000000..d76864721f --- /dev/null +++ b/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.persistence.spec.js @@ -0,0 +1,112 @@ +import { test } from '@playwright/test'; +import { NewtabPage } from '../../../integration-tests/new-tab.page.js'; +import { OmnibarPage } from './omnibar.page.js'; +import { CustomizerPage } from '../../customizer/integration-tests/customizer.page.js'; + +test.describe('omnibar widget persistence', () => { + test('remembers input across tabs', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const omnibar = new OmnibarPage(ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { omnibar: true, tabs: true, 'tabs.debug': true } }); + await omnibar.ready(); + + // first fill + await omnibar.types({ mode: 'search', value: 'shoes' }); + + // switch + await ntp.didSwitchToTab('02', ['01', '02']); + await omnibar.expectInputValue(''); + + // second fill + await omnibar.types({ mode: 'search', value: 'dresses' }); + + // back to first + await ntp.didSwitchToTab('01', ['01', '02']); + await omnibar.expectInputValue('shoes'); + + // back to second again + await ntp.didSwitchToTab('02', ['01', '02']); + await omnibar.expectInputValue('dresses'); + }); + test('remembers `mode` across tabs', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const omnibar = new OmnibarPage(ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { omnibar: true, tabs: true, 'tabs.debug': true } }); + await omnibar.ready(); + + // first fill + await omnibar.types({ mode: 'search', value: 'shoes' }); + await page.getByRole('tab', { name: 'Duck.ai' }).click(); + + // new tab, should be opened with duck.ai input still visible + await ntp.didSwitchToTab('02', ['01', '02']); + await omnibar.expectChatValue(''); + + // switch back + await ntp.didSwitchToTab('01', ['01', '02']); + await omnibar.expectChatValue('shoes'); + }); + test('adjusts mode of other tabs when duck.ai is disabled', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const omnibar = new OmnibarPage(ntp); + const customizer = new CustomizerPage(ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { omnibar: true, tabs: true, 'tabs.debug': true } }); + await omnibar.ready(); + + // first tab, switch to ai mode + await omnibar.switchMode({ mode: 'ai' }); + + // switch to second tab, should be empty but still on duck.ai + await omnibar.didSwitchToTab('02', ['01', '02']); + await omnibar.expectValue({ value: '', mode: 'ai' }); + await omnibar.types({ value: 'shoes', mode: 'ai' }); + + // now turn duck.ai off, 'shoes' should remain, but on search mode + await customizer.opensCustomizer(); + await omnibar.toggleDuckAiButton().uncheck(); + await omnibar.expectValue({ value: 'shoes', mode: 'search' }); + + // back to first tab, should be empty + search + await omnibar.didSwitchToTab('01', ['01', '02']); + await omnibar.expectValue({ value: '', mode: 'search' }); + }); + test('adjusts mode of other tabs when duck.ai is globally disabled', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + const omnibar = new OmnibarPage(ntp); + const customizer = new CustomizerPage(ntp); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { omnibar: true, tabs: true, 'tabs.debug': true } }); + await omnibar.ready(); + + // first tab, switch to ai mode + await omnibar.switchMode({ mode: 'ai' }); + + // switch to second tab, should be empty but still on duck.ai + await omnibar.didSwitchToTab('02', ['01', '02']); + await omnibar.expectValue({ value: '', mode: 'ai' }); + + // open sidebar + await customizer.opensCustomizer(); + + // control: make sure the Duck.ai toggle is there + await customizer.hasSwitch('Toggle Duck.ai'); + + // now receive global config settings... + await omnibar.didReceiveConfig({ mode: 'search', enableAi: false, showAiSetting: false }); + + // ...and expect search box to be empty, but still on search mode. + await omnibar.expectValue({ value: '', mode: 'search' }); + + // also, expect menu in sidebar to be updated (eg: switch is removed) + await customizer.doesntHaveSwitch('Toggle Duck.ai'); + + // Second config, disable globally, but keep 'showAiSetting: true' + await omnibar.didReceiveConfig({ mode: 'search', enableAi: false, showAiSetting: true }); + + // Ensure the toggle is back and is unchecked + await customizer.isUnchecked('Toggle Duck.ai'); + }); +}); diff --git a/special-pages/pages/new-tab/app/protections/mocks/protections.mock-transport.js b/special-pages/pages/new-tab/app/protections/mocks/protections.mock-transport.js index 3743e6c480..c92573c58c 100644 --- a/special-pages/pages/new-tab/app/protections/mocks/protections.mock-transport.js +++ b/special-pages/pages/new-tab/app/protections/mocks/protections.mock-transport.js @@ -68,6 +68,9 @@ export function protectionsMockTransport() { subs.set(sub, cb); return () => {}; } + if (sub === 'protections_onDataUpdate') { + return () => {}; + } console.warn('unhandled sub', sub); return () => {}; }, diff --git a/special-pages/pages/new-tab/app/tabs/PersistentValue.js b/special-pages/pages/new-tab/app/tabs/PersistentValue.js new file mode 100644 index 0000000000..71e87d6dc5 --- /dev/null +++ b/special-pages/pages/new-tab/app/tabs/PersistentValue.js @@ -0,0 +1,85 @@ +/** + * @template {string|number} T - the value to hold. + */ +export class PersistentValue { + /** @type {Map} */ + #values = new Map(); + + name() { + return 'PersistentValue'; + } + + /** + * Updates the value associated with a given identifier. + * + * @param {object} args + * @param {string} args.id + * @param {T} args.value + */ + update({ id, value }) { + if (string(id) && value !== null && value !== undefined) { + this.#values.set(id, value); + } + } + + /** + * Updates the value with every entry + * + * @param {object} args + * @param {T} args.value + */ + updateAll({ value }) { + for (const [key] of this.#values) { + this.#values.set(key, value); + } + } + + /** + * @param {object} params + * @param {string[]} params.preserve + */ + prune({ preserve }) { + for (const key of this.#values.keys()) { + if (!preserve.includes(key)) { + this.#values.delete(key); + } + } + } + + /** + * @param {object} args + * @param {string} args.id + */ + remove({ id }) { + if (string(id)) { + this.#values.delete(id); + } + } + + /** + * @param {string|null|undefined} id + * @return {T | null} + */ + byId(id) { + if (typeof id !== 'string') return null; + const value = this.#values.get(id); + if (value === null || value === undefined) return null; + return value; + } + + print() { + for (const [key, value] of this.#values) { + console.log(`key: ${key}, value: ${value}`); + } + } +} + +/** + * @param {unknown} input + * @returns {string} + */ +function string(input) { + if (typeof input !== 'string') return ''; + if (input.trim().length < 1) return ''; + return input; +} diff --git a/special-pages/pages/new-tab/app/tabs/ScrollRestore.js b/special-pages/pages/new-tab/app/tabs/ScrollRestore.js new file mode 100644 index 0000000000..47d1b3073f --- /dev/null +++ b/special-pages/pages/new-tab/app/tabs/ScrollRestore.js @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'preact/hooks'; +import { useTabState } from './TabsProvider.js'; +import { PersistentValue } from './PersistentValue.js'; +import { invariant } from '../utils.js'; + +const SCROLLER = '[data-main-scroller]'; + +/** + * Allow recording and restoring of the scroll position + */ +export function PersistentScrollProvider() { + const [value] = useState(() => /** @type {PersistentValue} */ (new PersistentValue())); + const { all, current } = useTabState(); + useEffect(() => { + let last = current.peek(); + const elem = document.querySelector(SCROLLER); + invariant(elem, 'must have access to scroller here'); + + /** + * Subscribe to changes in available tab IDs to prune stored scroll positions + * for tabs that no longer exist + */ + const unsub1 = all.subscribe((tabIds) => { + value?.prune({ preserve: tabIds }); + }); + /** + * Subscribe to changes in the current tab to restore the saved scroll position + * when switching between tabs. If no scroll position is saved, defaults to 0. + */ + const unsub2 = current.subscribe((tabId) => { + last = tabId; + const restore = value.byId(last); + const nextY = restore ?? 0; + + elem.scrollTop = nextY; + }); + + const controller = new AbortController(); + elem.addEventListener( + 'scroll', + (e) => { + if (!(e.target instanceof HTMLElement)) throw new Error('unreachable'); + value.update({ id: last, value: e.target.scrollTop }); + }, + { signal: controller.signal }, + ); + + return () => { + unsub1(); + unsub2(); + controller.abort(); + }; + }, [all, current, value]); + return null; +} diff --git a/special-pages/pages/new-tab/app/tabs/TabsProvider.js b/special-pages/pages/new-tab/app/tabs/TabsProvider.js new file mode 100644 index 0000000000..eec3c0dc89 --- /dev/null +++ b/special-pages/pages/new-tab/app/tabs/TabsProvider.js @@ -0,0 +1,66 @@ +import { h, createContext } from 'preact'; +import { useContext, useEffect } from 'preact/hooks'; +import { CustomizerThemesContext } from '../customizer/CustomizerProvider.js'; +import { signal, useComputed, useSignal } from '@preact/signals'; +import { TabsService } from './tabs.service'; + +/** + * @template T + * @typedef {import('@preact/signals').ReadonlySignal} ReadonlySignal + */ + +/** + * @typedef {import("preact").ComponentChild} ComponentChild + * @typedef {import('../../types/new-tab').Tabs} Tabs + */ + +const TabsStateContext = createContext(signal(/** @type {Tabs} */ (TabsService.DEFAULT))); + +/** + * Global state provider for tab information. + * + * This exposes a signal to the Tabs object. use the hook below to access the individual fields. + * + * @param {object} props + * @param {ComponentChild} props.children + * @param {TabsService} props.service + */ +export function TabsProvider({ children, service }) { + const tabs = useSignal(service.snapshot()); + useEffect(() => { + return service.onData(({ data }) => { + tabs.value = data; + }); + }, [service, tabs]); + return {children}; +} + +/** + * Exposes 2 signals - one for the current tab ID and one for the list of tabIds. + * + * In a component, if you want to trigger a re-render based on the current tab, you can + * access the .value field directly. + * + * ```js + * const { current } = useTabState(); + * return + * ``` + * + * @returns {{current: ReadonlySignal, all: ReadonlySignal}} + */ +export function useTabState() { + const tabs = useContext(TabsStateContext); + const current = useComputed(() => tabs.value.tabId); + const all = useComputed(() => tabs.value.tabIds); + return { current, all }; +} + +export function TabsDebug() { + const theme = useContext(CustomizerThemesContext); + const state = useTabState(); + return ( +
+            {JSON.stringify(state, null, 2)}
+        
+ ); +} diff --git a/special-pages/pages/new-tab/app/tabs/tabs.md b/special-pages/pages/new-tab/app/tabs/tabs.md new file mode 100644 index 0000000000..2e1d4607de --- /dev/null +++ b/special-pages/pages/new-tab/app/tabs/tabs.md @@ -0,0 +1,53 @@ +--- +title: Tabs +--- + +# Tabs + +This feature allows native sides to indicate which 'tab' is currently active. + +The fields `tabId` and `tabIds` are used together to indicate which tabs are open and which is active. + +For example, the following indicates that there are three tabs and `def` is the visible one. + +```json +{ + "tabId": "def", + "tabIds": ["abc", "def", "ghi"] +} +``` + +To remove a tab you would send a shorter list. To add a tab, send a longer list. To switch between tabs, send the +entire list still, but update `tabId` to reflect the currently active tab. + +**NOTE: you MUST send both fields every time.** + +## Setup + +Since this data is global in nature, you must add the initial tab state to {@link "NewTab Messages".InitialSetupResponse} + +- Add {@link "NewTab Messages".Tabs} to the `tabs` field on {@link "NewTab Messages".InitialSetupResponse} +- Example: + +```json +{ + "...": "...", + "tabs": { + "tabId": "abc", + "tabIds": ["abc", "def"] + } +} +``` + +## Subscriptions: + +Once the page is running, you can send `tabs_onDataUpdate` updates as often as you need to. + +### `tabs_onDataUpdate` +- {@link "NewTab Messages".TabsOnDataUpdateSubscription}. +```json +{ + "tabId": "string", + "tabIds": ["string", "string"] +} +``` \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/tabs/tabs.mock-transport.js b/special-pages/pages/new-tab/app/tabs/tabs.mock-transport.js new file mode 100644 index 0000000000..b1c525ecf3 --- /dev/null +++ b/special-pages/pages/new-tab/app/tabs/tabs.mock-transport.js @@ -0,0 +1,58 @@ +import { TestTransportConfig } from '@duckduckgo/messaging'; +import { initialSetup } from '../mock-transport.js'; +import { TabsService } from './tabs.service.js'; + +/** + * @typedef {import('../../types/new-tab').NewTabMessages["subscriptions"]} Subs + * @typedef {import('../../types/new-tab').Tabs} Tabs + * @typedef {Subs['subscriptionEvent']} Names + */ +const url = new URL(window.location.href); + +/** + * + */ +export function tabsMockTransport() { + const initial = initialSetup(url); + const memory = initial.tabs ? structuredClone(initial.tabs) : TabsService.DEFAULT; + return new TestTransportConfig({ + request() { + return Promise.reject(new Error('not implemented yet')); + }, + notify() { + return Promise.reject(new Error('not implemented yet')); + }, + /** + * @template {Names} K + * @template {{ subscriptionName: K, context: string, featureName: string }} Msg + * @param {Msg} msg + */ + subscribe(msg, cb) { + if (msg.subscriptionName === 'tabs_onDataUpdate') { + /** @type {any} */ (window)._tabs = { + add: (id) => { + memory.tabId = id; + memory.tabIds.push(id); + memory.tabIds = [...new Set(memory.tabIds)]; + cb(structuredClone(memory)); + }, + tabs: ({ tabId, tabIds }) => { + memory.tabId = tabId; + memory.tabIds = tabIds; + cb(structuredClone(memory)); + }, + delete: (id) => { + memory.tabIds = memory.tabIds.filter((x) => x !== id); + cb(structuredClone(memory)); + }, + switch: (id) => { + memory.tabId = id; + cb(structuredClone(memory)); + }, + }; + return () => {}; + } + return () => {}; + }, + }); +} diff --git a/special-pages/pages/new-tab/app/tabs/tabs.service.js b/special-pages/pages/new-tab/app/tabs/tabs.service.js new file mode 100644 index 0000000000..fcbd674d70 --- /dev/null +++ b/special-pages/pages/new-tab/app/tabs/tabs.service.js @@ -0,0 +1,56 @@ +import { Service } from '../service.js'; + +/** + * @typedef {import("../../types/new-tab.js").Tabs} Tabs + */ + +export class TabsService { + /** @type {Tabs} */ + static DEFAULT = { + tabId: 'unknown', + tabIds: ['unknown'], + }; + /** + * @param {import("../../src/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method. + * @param {Tabs} tabs + * @internal + */ + constructor(ntp, tabs) { + this.ntp = ntp; + + /** @type {Service} */ + this.tabsService = new Service( + { + subscribe: (cb) => ntp.messaging.subscribe('tabs_onDataUpdate', cb), + }, + tabs, + ); + } + + name() { + return 'TabsService'; + } + + /** + * @param {(evt: {data: Tabs, source: import('../service.js').InvocationSource}) => void} cb + * @internal + */ + onData(cb) { + return this.tabsService.onData(cb); + } + + /** + * @internal + */ + destroy() { + this.tabsService.destroy(); + } + + /** + * @returns {Tabs} + */ + snapshot() { + if (!this.tabsService.data) throw new Error('unreachable'); + return this.tabsService.data; + } +} diff --git a/special-pages/pages/new-tab/app/utils.js b/special-pages/pages/new-tab/app/utils.js index 21d3a0ab2d..71f6a010be 100644 --- a/special-pages/pages/new-tab/app/utils.js +++ b/special-pages/pages/new-tab/app/utils.js @@ -97,3 +97,14 @@ export function useOnMiddleClick(ref, handler) { }; }, [ref, handler]); } + +/** + * @param {any} condition + * @param {string} [message] + * @return {asserts condition} + */ +export function invariant(condition, message) { + if (condition) return; + if (message) throw new Error('Invariant failed: ' + message); + throw new Error('Invariant failed'); +} diff --git a/special-pages/pages/new-tab/integration-tests/new-tab.page.js b/special-pages/pages/new-tab/integration-tests/new-tab.page.js index 7cb99dd2bb..a7755a537c 100644 --- a/special-pages/pages/new-tab/integration-tests/new-tab.page.js +++ b/special-pages/pages/new-tab/integration-tests/new-tab.page.js @@ -178,4 +178,62 @@ export class NewtabPage { const rgb = `rgb(${[r, g, b].join(', ')})`; await expect(this.page.locator('body')).toHaveCSS('background-color', rgb, { timeout: 1000 }); } + + /** + * @param {string} tabId + * @param {string[]} tabIds + * @returns {Promise} + */ + async didSwitchToTab(tabId, tabIds) { + await test.step('simulate tab change event', async () => { + await this.mocks.simulateSubscriptionMessage(sub('tabs_onDataUpdate'), tabs({ tabId, tabIds })); + }); + } + + /** + * @return {Promise<{y: number}>} + */ + async didScrollToEnd() { + const { page } = this; + return await test.step(`manually setting scroll position to end of element`, async () => { + const y = await page.evaluate(() => { + const scroller = document.querySelector('[data-main-scroller]'); + if (!scroller) throw new Error('missing element'); + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + return scroller.scrollTop; + }); + expect(y).toBeGreaterThan(0); + return { y }; + }); + } + + /** + * @param {object} props + * @param {number} props.y + * @returns {Promise} + */ + async scrollIs({ y }) { + const { page } = this; + await test.step(`fetching the scroll position and comparing to ${y}`, async () => { + await page.waitForFunction( + ({ y }) => (document.querySelector('[data-main-scroller]')?.scrollTop ?? 0) === y, + { y }, + { timeout: 1000 }, + ); + }); + } +} + +/** + * @param {import("../types/new-tab.js").NewTabMessages["subscriptions"]["subscriptionEvent"]} name + */ +function sub(name) { + return name; +} + +/** + * @param {import("../types/new-tab.js").Tabs} t + */ +function tabs(t) { + return t; } diff --git a/special-pages/pages/new-tab/integration-tests/new-tab.spec.js b/special-pages/pages/new-tab/integration-tests/new-tab.spec.js index e9f93481cf..b5d23794e8 100644 --- a/special-pages/pages/new-tab/integration-tests/new-tab.spec.js +++ b/special-pages/pages/new-tab/integration-tests/new-tab.spec.js @@ -149,4 +149,27 @@ test.describe('newtab widgets', () => { await ntp.hasBackgroundColor({ hex: '#000000' }); }); }); + + test.describe('scroll restoration', () => { + test.use({ viewport: { height: 400, width: 800 } }); + test('restores to previous position', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + await ntp.openPage({ additional: { tabs: true, 'tabs.debug': true } }); + + // initial + await ntp.scrollIs({ y: 0 }); + + // scroll to end + const tab1 = await ntp.didScrollToEnd(); + + // new tab = should be back to 0 for scroll + await ntp.didSwitchToTab('02', ['01', '02']); + await ntp.scrollIs({ y: 0 }); + + // now back to original + await ntp.didSwitchToTab('01', ['01', '02']); + await ntp.scrollIs({ y: tab1.y }); + }); + }); }); diff --git a/special-pages/pages/new-tab/messages/initialSetup.response.json b/special-pages/pages/new-tab/messages/initialSetup.response.json index 2cd81f6779..1df38f541d 100644 --- a/special-pages/pages/new-tab/messages/initialSetup.response.json +++ b/special-pages/pages/new-tab/messages/initialSetup.response.json @@ -41,6 +41,16 @@ "$ref": "types/update-notification.json" } ] + }, + "tabs": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "types/tabs.json" + } + ] } } } diff --git a/special-pages/pages/new-tab/messages/tabs_onDataUpdate.subscribe.json b/special-pages/pages/new-tab/messages/tabs_onDataUpdate.subscribe.json new file mode 100644 index 0000000000..7eb3919788 --- /dev/null +++ b/special-pages/pages/new-tab/messages/tabs_onDataUpdate.subscribe.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/tabs.json" + } + ] +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/messages/types/tabs.json b/special-pages/pages/new-tab/messages/types/tabs.json new file mode 100644 index 0000000000..d7118e2be7 --- /dev/null +++ b/special-pages/pages/new-tab/messages/types/tabs.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Tabs", + "type": "object", + "required": [ + "tabId", + "tabIds" + ], + "properties": { + "tabId": { + "type": "string" + }, + "tabIds": { + "type": "array", + "items": { + "type": "string" + } + } + } +} diff --git a/special-pages/pages/new-tab/types/new-tab.ts b/special-pages/pages/new-tab/types/new-tab.ts index 690141ff0b..f589e12937 100644 --- a/special-pages/pages/new-tab/types/new-tab.ts +++ b/special-pages/pages/new-tab/types/new-tab.ts @@ -174,6 +174,7 @@ export interface NewTabMessages { | ProtectionsOnDataUpdateSubscription | RmfOnDataUpdateSubscription | StatsOnDataUpdateSubscription + | TabsOnDataUpdateSubscription | UpdateNotificationOnDataUpdateSubscription | WidgetsOnConfigUpdatedSubscription; } @@ -821,6 +822,7 @@ export interface InitialSetupResponse { }; customizer?: CustomizerData; updateNotification: null | UpdateNotificationData; + tabs?: null | Tabs; } export interface WidgetListItem { /** @@ -864,6 +866,10 @@ export interface UpdateNotification { version: string; notes: string[]; } +export interface Tabs { + tabId: string; + tabIds: string[]; +} /** * Generated from @see "../messages/nextSteps_getConfig.request.json" */ @@ -1138,6 +1144,13 @@ export interface StatsOnDataUpdateSubscription { subscriptionEvent: "stats_onDataUpdate"; params: PrivacyStatsData; } +/** + * Generated from @see "../messages/tabs_onDataUpdate.subscribe.json" + */ +export interface TabsOnDataUpdateSubscription { + subscriptionEvent: "tabs_onDataUpdate"; + params: Tabs; +} /** * Generated from @see "../messages/updateNotification_onDataUpdate.subscribe.json" */ diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js index 032faf1eec..71d42de25a 100644 --- a/special-pages/playwright.config.js +++ b/special-pages/playwright.config.js @@ -38,6 +38,7 @@ export default defineConfig({ 'protections.spec.js', 'protections.screenshots.spec.js', 'omnibar.spec.js', + 'omnibar.persistence.spec.js', ], use: { ...devices['Desktop Chrome'], diff --git a/special-pages/shared/mocks.js b/special-pages/shared/mocks.js index f3e62929d2..99411b685f 100644 --- a/special-pages/shared/mocks.js +++ b/special-pages/shared/mocks.js @@ -92,6 +92,20 @@ export class Mocks { }); } + /** + * @param {object} props + * @param {string} props.name + * @param {Record} props.payload + */ + async simulateSubscriptionEvent(props) { + await this.page.evaluate(simulateSubscriptionMessage, { + messagingContext: this.messagingContext, + name: props.name, + payload: props.payload, + injectName: this.build.name, + }); + } + /** * @param {{names: string[]}} [opts] * @returns {Promise}