diff --git a/special-pages/pages/new-tab/app/components/App.js b/special-pages/pages/new-tab/app/components/App.js index 3b03421fbe..edb57766d0 100644 --- a/special-pages/pages/new-tab/app/components/App.js +++ b/special-pages/pages/new-tab/app/components/App.js @@ -4,7 +4,7 @@ import styles from './App.module.css'; import { useCustomizerDrawerSettings, usePlatformName } from '../settings.provider.js'; import { WidgetList } from '../widget-list/WidgetList.js'; import { useGlobalDropzone } from '../dropzone.js'; -import { CustomizerButton, CustomizerMenuPositionedFixed, useContextMenu } from '../customizer/components/CustomizerMenu.js'; +import { CustomizerButton, CustomizerMenuPositionedFixed } from '../customizer/components/CustomizerMenu.js'; import { useDrawer, useDrawerControls } from './Drawer.js'; import { CustomizerDrawer } from '../customizer/components/CustomizerDrawer.js'; import { BackgroundConsumer } from './BackgroundProvider.js'; @@ -12,6 +12,7 @@ import { useComputed } from '@preact/signals'; import { CustomizerThemesContext } from '../customizer/CustomizerProvider.js'; import { useContext } from 'preact/hooks'; import { InlineErrorBoundary } from '../InlineErrorBoundary.js'; +import { useContextMenu } from '../context-menu.js'; /** * Renders the App component. diff --git a/special-pages/pages/new-tab/app/context-menu.js b/special-pages/pages/new-tab/app/context-menu.js new file mode 100644 index 0000000000..8592b52a5c --- /dev/null +++ b/special-pages/pages/new-tab/app/context-menu.js @@ -0,0 +1,73 @@ +import { useMessaging } from './types.js'; +import { useEffect } from 'preact/hooks'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +class ContextMenuItem { + /** @type {string} */ + title; + /** @type {string} */ + id; + /** @type {number} */ + index; +} + +const OPEN_EVENT = 'ntp-contextMenu-open'; + +/** + * @returns {ContextMenuItem[]} + */ +export function collect() { + /** @type {ContextMenuItem[]} */ + const next = []; + const detail = { + register: (/** @type {ContextMenuItem} */ incoming) => { + next.push(incoming); + }, + }; + const event = new CustomEvent(OPEN_EVENT, { detail }); + window.dispatchEvent(event); + next.sort((a, b) => a.index - b.index); + return next; +} + +/** + * Forward the contextmenu event + */ +export function useContextMenu() { + const messaging = useMessaging(); + useEffect(() => { + function handler(e) { + e.preventDefault(); + e.stopImmediatePropagation(); + const items = collect(); + /** @type {Omit[]} */ + const simplified = items + .filter((x) => !x.id.startsWith('_')) + .map((item) => { + return { + id: item.id, + title: item.title, + }; + }); + messaging.contextMenu({ visibilityMenuItems: simplified }); + } + document.body.addEventListener('contextmenu', handler); + return () => { + document.body.removeEventListener('contextmenu', handler); + }; + }, [messaging]); +} + +/** + * Call this to opt-in to the visibility menu + * @param {ContextMenuItem} row + */ +export function useContextMenuItem({ title, id, index }) { + useEffect(() => { + const handler = (/** @type {CustomEvent} */ e) => { + e.detail.register({ title, id, index }); + }; + window.addEventListener(OPEN_EVENT, handler); + return () => window.removeEventListener(OPEN_EVENT, handler); + }, [title, id, index]); +} 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 fbe85a1042..0f15b99c79 100644 --- a/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js +++ b/special-pages/pages/new-tab/app/customizer/components/CustomizerMenu.js @@ -2,7 +2,7 @@ import { h } from 'preact'; import { useEffect } from 'preact/hooks'; import styles from './Customizer.module.css'; import { CustomizeIcon } from '../../components/Icons.js'; -import { useMessaging, useTypedTranslation } from '../../types.js'; +import { useTypedTranslation } from '../../types.js'; /** * @import { WidgetVisibility, VisibilityMenuItem } from '../../../types/new-tab.js' @@ -21,7 +21,10 @@ import { useMessaging, useTypedTranslation } from '../../types.js'; export const OPEN_EVENT = 'ntp-customizer-open'; export const UPDATE_EVENT = 'ntp-customizer-update'; -export function getItems() { +/** + * @returns {VisibilityRowData[]} + */ +export function getCustomizerItems() { /** @type {VisibilityRowData[]} */ const next = []; const detail = { @@ -35,34 +38,6 @@ export function getItems() { return next; } -/** - * Forward the contextmenu event - */ -export function useContextMenu() { - const messaging = useMessaging(); - useEffect(() => { - function handler(e) { - e.preventDefault(); - e.stopImmediatePropagation(); - const items = getItems(); - /** @type {VisibilityMenuItem[]} */ - const simplified = items - .filter((x) => !x.id.startsWith('_')) - .map((item) => { - return { - id: item.id, - title: item.title, - }; - }); - messaging.contextMenu({ visibilityMenuItems: simplified }); - } - document.body.addEventListener('contextmenu', handler); - return () => { - document.body.removeEventListener('contextmenu', handler); - }; - }, [messaging]); -} - /** * @param {object} props * @param {string} [props.menuId] diff --git a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js index 7d76797ee3..c2c214a2cc 100644 --- a/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js +++ b/special-pages/pages/new-tab/app/customizer/components/VisibilityMenuSection.js @@ -1,16 +1,16 @@ import { useLayoutEffect, useState } from 'preact/hooks'; -import { getItems, UPDATE_EVENT } from './CustomizerMenu.js'; +import { getCustomizerItems, UPDATE_EVENT } from './CustomizerMenu.js'; import { EmbeddedVisibilityMenu } from './VisibilityMenu.js'; import { h } from 'preact'; export function VisibilityMenuSection() { const [rowData, setRowData] = useState(() => { - const items = /** @type {import("./CustomizerMenu.js").VisibilityRowData[]} */ (getItems()); + const items = /** @type {import("./CustomizerMenu.js").VisibilityRowData[]} */ (getCustomizerItems()); return items; }); useLayoutEffect(() => { function handler() { - setRowData(getItems()); + setRowData(getCustomizerItems()); } window.addEventListener(UPDATE_EVENT, handler); diff --git a/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js b/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js index 5f735e5bf3..708e4c8380 100644 --- a/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js +++ b/special-pages/pages/new-tab/app/favorites/components/FavoritesCustomized.js @@ -11,6 +11,7 @@ import { FavoritesMemo } from './Favorites.js'; import { viewTransition } from '../../utils.js'; import { CustomizerContext } from '../../customizer/CustomizerProvider.js'; import { Shield } from '../../components/Icons.js'; +import { useContextMenuItem } from '../../context-menu.js'; /** * @typedef {import('../../../types/new-tab.ts').Favorite} Favorite @@ -68,6 +69,7 @@ export function FavoritesCustomized() { // register with the visibility menu const title = t('favorites_menu_title'); useCustomizer({ title, id, icon: , toggle, visibility: visibility.value, index }); + useContextMenuItem({ title, id, index }); if (visibility.value === 'hidden') { return null; diff --git a/special-pages/pages/new-tab/app/mock-transport.js b/special-pages/pages/new-tab/app/mock-transport.js index 9c04d5fc36..e9473153c9 100644 --- a/special-pages/pages/new-tab/app/mock-transport.js +++ b/special-pages/pages/new-tab/app/mock-transport.js @@ -25,7 +25,7 @@ import { tabsMockTransport } from './tabs/tabs.mock-transport.js'; * @typedef {import('@duckduckgo/messaging/lib/test-utils.mjs').SubscriptionEvent} SubscriptionEvent */ -const VERSION_PREFIX = '__ntp_31__.'; +const VERSION_PREFIX = '__ntp_32__.'; const url = new URL(window.location.href); export function mockTransport() { 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 20ce68d868..17aadd7387 100644 --- a/special-pages/pages/new-tab/app/omnibar/components/OmnibarConsumer.js +++ b/special-pages/pages/new-tab/app/omnibar/components/OmnibarConsumer.js @@ -64,7 +64,7 @@ function AiSetting({ enableAi, setEnableAi }) { const { id, index } = useVisibility(); useCustomizer({ title: t('omnibar_toggleDuckAi'), - id: `_${id}-toggleAi`, + id: `${id}-toggleAi`, icon: , toggle: () => setEnableAi(!enableAi), visibility: enableAi ? 'visible' : 'hidden', 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 4e595577e2..306f3726cd 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 { useContextMenuItem } from '../../context-menu.js'; import { PersistentModeProvider, PersistentTextInputProvider } from './PersistentOmnibarValuesProvider.js'; /** @@ -30,6 +31,12 @@ export function OmnibarCustomized() { const { visibility, id, toggle, index } = useVisibility(); useCustomizer({ title: sectionTitle, id, icon: , toggle, visibility: visibility.value, index }); + useContextMenuItem({ title: sectionTitle, id, index }); + useContextMenuItem({ + title: t('omnibar_toggleDuckAi'), + id: `${id}-toggleAi`, + index: index + 0.1, + }); return ( diff --git a/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.spec.js b/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.spec.js index 6d9551a6ff..a73d0ae1ab 100644 --- a/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.spec.js +++ b/special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.spec.js @@ -993,10 +993,11 @@ test.describe('omnibar widget', () => { // Right-click on the page to trigger context menu await page.click('body', { button: 'right' }); - // Assert that contextMenu notification is sent with real widgets and not e.g. the Duck.ai toggle + // Assert that contextMenu notification is sent with an additional widget. await omnibar.expectMethodCalledWith('contextMenu', { visibilityMenuItems: [ { id: 'omnibar', title: 'Search' }, + { id: 'omnibar-toggleAi', title: 'Duck.ai' }, { id: 'favorites', title: 'Favorites' }, { id: 'protections', title: 'Protections Report' }, ], diff --git a/special-pages/pages/new-tab/app/protections/components/ProtectionsCustomized.js b/special-pages/pages/new-tab/app/protections/components/ProtectionsCustomized.js index 4baffcd303..106c844a6b 100644 --- a/special-pages/pages/new-tab/app/protections/components/ProtectionsCustomized.js +++ b/special-pages/pages/new-tab/app/protections/components/ProtectionsCustomized.js @@ -6,6 +6,7 @@ import { h } from 'preact'; import { ProtectionsConsumer } from './ProtectionsConsumer.js'; import { DuckFoot } from '../../components/Icons.js'; +import { useContextMenuItem } from '../../context-menu.js'; /** * @import enStrings from "../strings.json" @@ -29,6 +30,7 @@ export function ProtectionsCustomized() { const { visibility, id, toggle, index } = useVisibility(); useCustomizer({ title: sectionTitle, id, icon: , toggle, visibility: visibility.value, index }); + useContextMenuItem({ title: sectionTitle, id, index }); if (visibility.value === 'hidden') { return null;