From 4f4bf3e1ab7af0b82b741ba9c18426cb67d194db Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 4 Nov 2024 09:50:52 +0000 Subject: [PATCH 1/4] favorites implementation --- .../pages/new-tab/app/components/Chevron.js | 11 - .../pages/new-tab/app/components/Examples.jsx | 54 +++ special-pages/pages/new-tab/app/docs.js | 1 + .../pages/new-tab/app/favorites/Color.js | 33 ++ .../pages/new-tab/app/favorites/Favorites.js | 212 +++++++++++- .../app/favorites/Favorites.module.css | 207 ++++++++++++ .../app/favorites/FavoritesProvider.js | 132 ++++++++ .../new-tab/app/favorites/FavouritesGrid.js | 160 +++++++++ .../pages/new-tab/app/favorites/Tile.js | 309 ++++++++++++++++++ .../app/favorites/favorites-diagrams.md | 22 ++ .../app/favorites/favorites.service.js | 136 ++++++++ .../app/favorites/favorites.service.md | 79 +++++ .../integration-tests/favorites.page.js | 263 +++++++++++++++ .../integration-tests/favorites.spec.js | 118 +++++++ .../favorites/mocks/MockFavoritesProvider.js | 87 +++++ .../app/favorites/mocks/favorites.data.js | 222 +++++++++++++ .../new-tab/integration-tests/new-tab.page.js | 8 +- special-pages/pages/new-tab/src/js/index.js | 1 + .../pages/new-tab/src/js/mock-transport.js | 119 ++++++- special-pages/playwright.config.js | 1 + 20 files changed, 2156 insertions(+), 19 deletions(-) delete mode 100644 special-pages/pages/new-tab/app/components/Chevron.js create mode 100644 special-pages/pages/new-tab/app/favorites/Color.js create mode 100644 special-pages/pages/new-tab/app/favorites/Favorites.module.css create mode 100644 special-pages/pages/new-tab/app/favorites/FavoritesProvider.js create mode 100644 special-pages/pages/new-tab/app/favorites/FavouritesGrid.js create mode 100644 special-pages/pages/new-tab/app/favorites/Tile.js create mode 100644 special-pages/pages/new-tab/app/favorites/favorites-diagrams.md create mode 100644 special-pages/pages/new-tab/app/favorites/favorites.service.js create mode 100644 special-pages/pages/new-tab/app/favorites/favorites.service.md create mode 100644 special-pages/pages/new-tab/app/favorites/integration-tests/favorites.page.js create mode 100644 special-pages/pages/new-tab/app/favorites/integration-tests/favorites.spec.js create mode 100644 special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js create mode 100644 special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js diff --git a/special-pages/pages/new-tab/app/components/Chevron.js b/special-pages/pages/new-tab/app/components/Chevron.js deleted file mode 100644 index baa5763f0..000000000 --- a/special-pages/pages/new-tab/app/components/Chevron.js +++ /dev/null @@ -1,11 +0,0 @@ -import { h } from 'preact' - -export function Chevron () { - return ( - - - - ) -} diff --git a/special-pages/pages/new-tab/app/components/Examples.jsx b/special-pages/pages/new-tab/app/components/Examples.jsx index d716aa4ca..beb8bee2f 100644 --- a/special-pages/pages/new-tab/app/components/Examples.jsx +++ b/special-pages/pages/new-tab/app/components/Examples.jsx @@ -3,6 +3,9 @@ import { PrivacyStatsMockProvider } from '../privacy-stats/mocks/PrivacyStatsMoc import { Body, Heading, PrivacyStatsConsumer } from '../privacy-stats/PrivacyStats.js' import { RemoteMessagingFramework } from '../remote-messaging-framework/RemoteMessagingFramework.js' import { stats } from '../privacy-stats/mocks/stats.js' +import { MockFavoritesProvider } from "../favorites/mocks/MockFavoritesProvider.js"; +import { favorites } from "../favorites/mocks/favorites.data.js"; +import { FavoritesConsumer } from "../favorites/Favorites.js"; import { noop } from '../utils.js' import { VisibilityMenu } from '../customizer/VisibilityMenu.js' import { CustomizerButton } from '../customizer/Customizer.js' @@ -83,6 +86,57 @@ export const mainExamples = { dismiss={() => {}} /> ) + }, + 'favorites.many': { + factory: () => ( + + ) + }, + 'favorites.few.7': { + factory: () => ( + + ) + }, + 'favorites.few.7.no-animation': { + factory: () => ( + + ) + }, + 'favorites.few.6': { + factory: () => ( + + ) + }, + 'favorites.few.12': { + factory: () => ( + + ) + }, + 'favorites.multi': { + factory: () => ( +
+ +
+ +
+ +
+ +
+ ) + }, + 'favorites.single': { + factory: () => ( + + ) + }, + 'favorites.none': { + factory: () => ( + + ) } } diff --git a/special-pages/pages/new-tab/app/docs.js b/special-pages/pages/new-tab/app/docs.js index 3b278c671..d09968cdb 100644 --- a/special-pages/pages/new-tab/app/docs.js +++ b/special-pages/pages/new-tab/app/docs.js @@ -7,3 +7,4 @@ * * @module NewTab Services */ +export * from './favorites/favorites.service.js' diff --git a/special-pages/pages/new-tab/app/favorites/Color.js b/special-pages/pages/new-tab/app/favorites/Color.js new file mode 100644 index 000000000..dae531398 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/Color.js @@ -0,0 +1,33 @@ +// Constant array of colors for empty favicon backgrounds +const EMPTY_FAVICON_TEXT_BACKGROUND_COLOR_BRUSHES = [ + '#94B3AF', '#727998', '#645468', '#4D5F7F', + '#855DB6', '#5E5ADB', '#678FFF', '#6BB4EF', + '#4A9BAE', '#66C4C6', '#55D388', '#99DB7A', + '#ECCC7B', '#E7A538', '#DD6B4C', '#D65D62' +] + +// DJB hashing algorithm to get a consistent color index from URL host +function getDJBHash (str) { + let hash = 5381 + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) + hash + str.charCodeAt(i) + } + return hash +} + +// Extracts the host part of the URL +function getHost (url) { + try { + const urlObj = new URL(url) + return urlObj.hostname.replace(/^www\./, '') + } catch (e) { + return '?' + } +} + +// Main function: Converts a URL to a color from the predefined array +export function urlToColor (url) { + const host = getHost(url) + const index = Math.abs(getDJBHash(host) % EMPTY_FAVICON_TEXT_BACKGROUND_COLOR_BRUSHES.length) + return EMPTY_FAVICON_TEXT_BACKGROUND_COLOR_BRUSHES[index] +} diff --git a/special-pages/pages/new-tab/app/favorites/Favorites.js b/special-pages/pages/new-tab/app/favorites/Favorites.js index 33062d5fa..6c9958a82 100644 --- a/special-pages/pages/new-tab/app/favorites/Favorites.js +++ b/special-pages/pages/new-tab/app/favorites/Favorites.js @@ -1,7 +1,193 @@ import { h } from 'preact' +import cn from 'classnames' + import { useVisibility } from '../widget-list/widget-config.provider.js' +import styles from './Favorites.module.css' +import { useContext, useId, useMemo } from 'preact/hooks' +import { Placeholder, PlusIconMemo, TileMemo } from './Tile.js' +import { FavoritesContext, FavoritesProvider } from './FavoritesProvider.js' +import { useGridState } from './FavouritesGrid.js' +import { memo } from 'preact/compat' +import { ShowHideButton } from '../components/ShowHideButton.jsx' import { useTypedTranslation } from '../types.js' import { useCustomizer } from '../customizer/Customizer.js' +import { usePlatformName } from '../settings.provider.js' + +/** + * @typedef {import('../../../../types/new-tab').Expansion} Expansion + * @typedef {import('../../../../types/new-tab').Animation} Animation + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig + * @typedef {import('../../../../types/new-tab').FavoritesOpenAction['target']} OpenTarget + */ + +/** + * @param {object} props + * @param {Favorite[]} props.favorites + * @param {(list: Favorite[], id: string, toIndex: number) => void} props.listDidReOrder + * @param {(id: string) => void} props.openContextMenu + * @param {(id: string, target: OpenTarget) => void} props.openFavorite + * @param {() => void} props.add + * @param {Expansion} props.expansion + * @param {() => void} props.toggle + */ +export function Favorites (props) { + return ( + + ) +} + +const FavoritesMemo = memo(FavoritesConfigured) + +/** + * @param {object} props + * @param {import("preact").Ref} [props.gridRef] + * @param {Favorite[]} props.favorites + * @param {import("preact").ComponentProps['listDidReOrder']} props.listDidReOrder + * @param {Expansion} props.expansion + * @param {Animation['kind']} props.animateItems + * @param {() => void} props.toggle + * @param {(id: string) => void} props.openContextMenu + * @param {(id: string, target: OpenTarget) => void} props.openFavorite + * @param {() => void} props.add + */ +export function FavoritesConfigured ({ gridRef, favorites, listDidReOrder, expansion, toggle, animateItems, openContextMenu, openFavorite, add }) { + useGridState(favorites, listDidReOrder, animateItems) + const platformName = usePlatformName() + const { t } = useTypedTranslation() + + // todo: does this need to be dynamic for smaller screens? + const ROW_CAPACITY = 6 + + // see: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/ + const WIDGET_ID = useId() + const TOGGLE_ID = useId() + + const ITEM_PREFIX = useId() + const placeholders = calculatePlaceholders(favorites.length, ROW_CAPACITY) + const hiddenCount = expansion === 'collapsed' + ? favorites.length - ROW_CAPACITY + : 0 + + // only recompute the list + const items = useMemo(() => { + return favorites.map((item, index) => { + return ( + + ) + }).concat(Array.from({ length: placeholders }).map((_, index) => { + if (index === 0) { + return + } + return ( + + ) + })) + }, [favorites, placeholders, ITEM_PREFIX, add]) + + /** + * @param {MouseEvent} event + */ + function onContextMenu (event) { + let target = /** @type {HTMLElement|null} */(event.target) + while (target && target !== event.currentTarget) { + if (typeof target.dataset.id === 'string') { + event.preventDefault() + event.stopImmediatePropagation() + return openContextMenu(target.dataset.id) + } else { + target = target.parentElement + } + } + } + /** + * @param {MouseEvent} event + */ + function onClick (event) { + let target = /** @type {HTMLElement|null} */(event.target) + while (target && target !== event.currentTarget) { + if (typeof target.dataset.id === 'string') { + event.preventDefault() + event.stopImmediatePropagation() + const isControlClick = platformName === 'macos' ? event.metaKey : event.ctrlKey + if (isControlClick) { + return openFavorite(target.dataset.id, 'new-tab') + } else if (event.shiftKey) { + return openFavorite(target.dataset.id, 'new-window') + } + return openFavorite(target.dataset.id, 'same-tab') + } else { + target = target.parentElement + } + } + } + + const canToggleExpansion = items.length > ROW_CAPACITY + + return ( +
+
+ {items.slice(0, expansion === 'expanded' ? undefined : ROW_CAPACITY)} +
+
+ {canToggleExpansion && ( + + )} +
+
+ ) +} + +/** + * @param {number} totalItems + * @param {number} itemsPerRow + * @return {number|number} + */ +function calculatePlaceholders (totalItems, itemsPerRow) { + if (totalItems === 0) return itemsPerRow + if (totalItems === itemsPerRow) return 1 + // Calculate how many items are left over in the last row + const itemsInLastRow = totalItems % itemsPerRow + + // If there are leftover items, calculate the placeholders needed to fill the last row + const placeholders = itemsInLastRow > 0 ? itemsPerRow - itemsInLastRow : 1 + + return placeholders +} export function FavoritesCustomized () { const { t } = useTypedTranslation() @@ -15,6 +201,30 @@ export function FavoritesCustomized () { return null } return ( -

Favourites here... (id: {id})

+ + + ) } + +/** + * Component that consumes FavoritesContext for displaying favorites list. + */ +export function FavoritesConsumer () { + const { state, toggle, listDidReOrder, openContextMenu, openFavorite, add } = useContext(FavoritesContext) + + if (state.status === 'ready') { + return ( + + ) + } + return null +} diff --git a/special-pages/pages/new-tab/app/favorites/Favorites.module.css b/special-pages/pages/new-tab/app/favorites/Favorites.module.css new file mode 100644 index 000000000..392a0c30c --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/Favorites.module.css @@ -0,0 +1,207 @@ +.root { + width: 100%; + margin: 0 auto; + display: grid; + grid-template-rows: auto auto; + grid-template-areas: + 'grid' + 'showhide'; +} + +.showhide { + grid-area: showhide; + height: 32px; + display: grid; + justify-items: center; +} + +.root { + &:hover { + .showhideVisible [aria-controls] { + opacity: 1; + } + } + &:focus-within { + .showhideVisible [aria-controls] { + opacity: 1; + } + } +} + +.hr { + grid-area: middle; + width: 100%; + height: 1px; + border-color: var(--color-black-at-9); + @media screen and (prefers-color-scheme: dark) { + border-color: var(--color-white-at-9); + } +} + +.grid { + grid-area: grid; +} + +.grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + align-items: start; + grid-row-gap: 8px; + margin-left: -12px; + margin-right: -12px; +} + +.item { + display: grid; + grid-row-gap: 6px; + align-content: center; + justify-content: center; + position: relative; + text-decoration: none; + color: currentColor; + padding-inline: 12px; + outline: none; + + &:focus-visible .icon { + outline: 1px dotted var(--ntp-focus-outline-color); + } +} + +.icon { + display: grid; + align-content: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 12px; +} + +.draggable { + background-color: var(--color-black-at-3); + + &:hover { + background-color: var(--color-black-at-9); + } + + &:active { + transform: scale(0.95); + } + + @media screen and (prefers-color-scheme: dark) { + background-color: var(--color-white-at-9); + &:hover { + background-color: var(--color-white-at-12); + } + } +} + +.favicon { + display: block; + width: 32px; + height: 32px; + border-radius: 8px; + background-repeat: no-repeat; + background-size: contain; + pointer-events: none; + opacity: 0; + transition: opacity .3s; + + &[data-loaded] { + opacity: 1; + } + + &[data-loaded][data-did-try-fallback] { + border-radius: 8px; + } + + &[data-errored] { + + } +} + +.text { + text-align: center; + font-size: 10px; + line-height: 13px; + font-weight: 400; + min-height: 2.8em; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; +} +.preview { + padding: .5em; + border-radius: 5px; + background: white; +} + +.placeholder { + background-color: transparent; + border: 1.5px dashed var(--color-black-at-9); + + @media screen and (prefers-color-scheme: dark) { + border-color: var(--color-white-at-9); + } +} + +.plus { + outline: none; + border-style: solid; + color: var(--color-black-at-90); + + @media screen and (prefers-color-scheme: dark) { + color: var(--color-white-at-85); + } + + &:hover { + background: var(--color-black-at-3); + + @media screen and (prefers-color-scheme: dark) { + background: var(--color-white-at-9); + } + } + + &:active { + transform: scale(0.95); + } + + &:focus-visible { + border-style: dotted; + border-color: var(--ntp-focus-outline-color); + border-width: 1px; + } +} + +.dropper { + width: 2px; + height: 64px; + position: absolute; + top: 0; + background-color: var(--color-black-at-12); + @media screen and (prefers-color-scheme: dark) { + background-color: var(--color-white-at-12); + } +} + +.dropper[data-edge="left"] { + left: -1px; +} + +.dropper[data-edge="right"] { + right: -1px; +} + +[data-item-state="idle"] { + &:hover { + border-color: #FFF; + } +} + +[data-item-state="dragging"] { + opacity: 0.4; +} + +[data-item-state="is-dragging-over"] { +} diff --git a/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js b/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js new file mode 100644 index 000000000..6684c14bd --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js @@ -0,0 +1,132 @@ +import { createContext, h } from 'preact' +import { InstanceIdContext } from './FavouritesGrid.js' +import { useCallback, useEffect, useReducer, useRef, useState } from 'preact/hooks' +import { FavoritesService } from './favorites.service.js' +import { useMessaging } from '../types.js' +import { + reducer, + useConfigSubscription, + useDataSubscription, + useInitialDataAndConfig +} from '../service.hooks.js' + +/** + * @typedef {import('../../../../types/new-tab.js').Favorite} Favorite + * @typedef {import('../../../../types/new-tab.js').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab.js').FavoritesConfig} FavoritesConfig + * @typedef {import('../../../../types/new-tab.js').FavoritesOpenAction['target']} OpenTarget + * @typedef {import('../service.hooks.js').State} State + * @typedef {import('../service.hooks.js').Events} Events + */ + +/** + * These are the values exposed to consumers. + */ +export const FavoritesContext = createContext({ + /** @type {import('../service.hooks.js').State} */ + state: { status: 'idle', data: null, config: null }, + /** @type {() => void} */ + toggle: () => { + throw new Error('must implement') + }, + /** @type {(list: Favorite[], id: string, targetIndex: number) => void} */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + listDidReOrder: (list, id, targetIndex) => { + throw new Error('must implement') + }, + /** @type {(id: string) => void} */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + openContextMenu: (id) => { + throw new Error('must implement') + }, + /** @type {(id: string, target: OpenTarget) => void} */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + openFavorite: (id, target) => { + throw new Error('must implement') + }, + /** @type {() => void} */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + add: () => { + throw new Error('must implement add') + } +}) + +export const FavoritesDispatchContext = createContext(/** @type {import("preact/hooks").Dispatch} */({})) + +function getInstanceId () { + return Symbol('instance-id') +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + */ +export function FavoritesProvider ({ children }) { + const initial = /** @type {State} */({ + status: /** @type {const} */('idle'), + data: null, + config: null + }) + + const [state, dispatch] = useReducer(reducer, initial) + + const [instanceId] = useState(getInstanceId) + + const service = useService() + + // get initial data + useInitialDataAndConfig({ dispatch, service }) + + // subscribe to data updates + useDataSubscription({ dispatch, service }) + + // subscribe to toggle + expose a fn for sync toggling + const { toggle } = useConfigSubscription({ dispatch, service }) + + /** @type {(f: Favorite[], id: string, targetIndex: number) => void} */ + const listDidReOrder = useCallback((favorites, id, targetIndex) => { + if (!service.current) return + service.current.setFavoritesOrder({ favorites }, id, targetIndex) + }, [service]) + + /** @type {(id: string) => void} */ + const openContextMenu = useCallback((id) => { + if (!service.current) return + service.current.openContextMenu(id) + }, [service]) + + /** @type {(id: string, target: OpenTarget) => void} */ + const openFavorite = useCallback((id, target) => { + if (!service.current) return + service.current.openFavorite(id, target) + }, [service]) + + /** @type {() => void} */ + const add = useCallback(() => { + if (!service.current) return + service.current.add() + }, [service]) + + return ( + + + + {children} + + + + ) +} + +export function useService () { + const service = useRef(/** @type {FavoritesService | null} */(null)) + const ntp = useMessaging() + useEffect(() => { + const stats = new FavoritesService(ntp) + service.current = stats + return () => { + stats.destroy() + } + }, [ntp]) + return service +} diff --git a/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js b/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js new file mode 100644 index 000000000..305a9acc0 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js @@ -0,0 +1,160 @@ +import { createContext } from 'preact' +import { useContext, useEffect } from 'preact/hooks' +import { flushSync } from 'preact/compat' + +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge' +import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge' +import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index' +import { monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter' +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine' +import { getHTML } from '@atlaskit/pragmatic-drag-and-drop/external/html' +import { DDG_MIME_TYPE } from './favorites.service.js' + +/** + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').Animation} Animation + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +function getInstanceId () { + return Symbol('instance-id') +} + +/** @type {import("preact").Context} */ +export const InstanceIdContext = createContext(getInstanceId()) + +/** + * @param {Favorite[]} favorites + * @param {import("preact").ComponentProps['listDidReOrder']} setFavorites + * @param {Animation['kind']} animation + */ +export function useGridState (favorites, setFavorites, animation) { + const instanceId = useContext(InstanceIdContext) + useEffect(() => { + return combine( + monitorForExternal({ + onDrop (payload) { + // const data = ''; + const data = getHTML(payload) + if (!data) return console.warn('missing text/html payload') + + // Create a document fragment using the safer createContextualFragment + const fragment = document.createRange().createContextualFragment(data) + + // Get the first element + const node = fragment.firstElementChild + if (!node) return console.warn('missing first element') + + // check the name attribute + if (node.getAttribute('name') !== DDG_MIME_TYPE) return console.warn(`attribute name was not ${DDG_MIME_TYPE}`) + + // check the id + const id = node.getAttribute('content') + if (!id) return console.warn('id missing from `content` attribute') + + const location = payload.location + const target = location.current.dropTargets[0] + + if (!target || !target.data || typeof target.data.url !== 'string') { + return console.warn('missing data from target') + } + + const closestEdgeOfTarget = extractClosestEdge(target.data) + const destinationSrc = target.data.url + let indexOfTarget = favorites.findIndex(item => item.url === destinationSrc) + if (indexOfTarget === -1 && destinationSrc.includes('PLACEHOLDER-URL')) { + indexOfTarget = favorites.length + } + const targetIndex = getReorderDestinationIndex({ + closestEdgeOfTarget, + startIndex: favorites.length, + indexOfTarget, + axis: 'horizontal' + }) + + setFavorites(favorites, id, targetIndex) + } + }), + monitorForElements({ + canMonitor ({ source }) { + return source.data.instanceId === instanceId + }, + onDrop ({ source, location }) { + const target = location.current.dropTargets[0] + if (!target) { + return + } + + const destinationSrc = target.data.url + const startSrc = source.data.url + const startId = source.data.id + + if (typeof startId !== 'string') { + return console.warn('could not access the id') + } + + if (typeof destinationSrc !== 'string') { + return console.warn('could not access the destinationSrc') + } + + if (typeof startSrc !== 'string') { + return console.warn('could not access the startSrc') + } + + const startIndex = favorites.findIndex(item => item.url === startSrc) + let indexOfTarget = favorites.findIndex(item => item.url === destinationSrc) + + if (indexOfTarget === -1 && destinationSrc.includes('PLACEHOLDER-URL')) { + indexOfTarget = favorites.length + } + + const closestEdgeOfTarget = extractClosestEdge(target.data) + + // where should the element be inserted? + // we only use this value to send to the native side + const targetIndex = getReorderDestinationIndex({ + closestEdgeOfTarget, + startIndex, + indexOfTarget, + axis: 'horizontal' + }) + + // reorder the list using the helper from the dnd lib + const reorderedList = reorderWithEdge({ + list: favorites, + startIndex, + indexOfTarget, + closestEdgeOfTarget, + axis: 'horizontal' + }) + + flushSync(() => { + try { + setFavorites(reorderedList, startId, targetIndex) + } catch (e) { + console.error('did catch', e) + } + }) + + const htmlElem = source.element + + const pulseAnimation = htmlElem.animate([ + { transform: 'scale(1)' }, + { transform: 'scale(1.1)' }, + { transform: 'scale(1)' } + ], { + duration: 500, // duration in milliseconds + iterations: 1, // run the animation once + easing: 'ease-in-out' // easing function + }) + + pulseAnimation.onfinish = () => { + // additional actions can be placed here or handle the end of the animation if needed + } + } + }) + ) + }, [instanceId, favorites, animation]) +} diff --git a/special-pages/pages/new-tab/app/favorites/Tile.js b/special-pages/pages/new-tab/app/favorites/Tile.js new file mode 100644 index 000000000..960e7216f --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/Tile.js @@ -0,0 +1,309 @@ +import { h } from 'preact' +import cn from 'classnames' +import { useContext, useEffect, useId, useRef, useState } from 'preact/hooks' +import { memo } from 'preact/compat' +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine' +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter' +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge' + +import styles from './Favorites.module.css' +import { InstanceIdContext } from './FavouritesGrid.js' +import { DDG_MIME_TYPE, DDG_FALLBACK_ICON, DDG_DEFAULT_ICON_SIZE } from './favorites.service.js' +import { urlToColor } from './Color.js' +import { dropTargetForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter' + +/** + * @typedef {{ type: 'idle' } + * | { type: 'dragging' } + * | { type: 'is-dragging-over'; closestEdge: null | import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge").Edge } + * } DNDState + * + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +/** + * @param {object} props + * @param {Favorite['url']} props.url + * @param {Favorite['id']} props.id + * @param {Favorite['title']} props.title + * @param {string|null|undefined} props.faviconSrc + * @param {number|null|undefined} props.faviconMax + * @param {number} props.index + */ +export function Tile ({ url, faviconSrc, faviconMax, index, title, id }) { + const { state, ref } = useTileState(url, id) + const [visible, setVisible] = useState(true) + + useEffect(() => { + if (!ref) return + let elem = ref.current + if (!elem) return + /** @type {IntersectionObserver | null} */ + let o = new IntersectionObserver((entries) => { + const last = entries[entries.length - 1] + requestAnimationFrame(() => { + setVisible(last.isIntersecting) + }) + }, { threshold: [0] }) + o.observe(elem) + return () => { + if (elem) { + o?.unobserve(elem) + } + o = null + elem = null + } + }, []) + + return ( + +
+ {visible && ( + + )} +
+
{title}
+ {state.type === 'is-dragging-over' && state.closestEdge + ? ( +
+ ) + : null + } + + ) +} + +/** + * Loads and displays an image for a given webpage. + * + * @param {Object} props - The props for the image loader. + * @param {string} props.faviconSrc - The URL of the favicon image to load. + * @param {number} props.faviconMax - The maximum size this icon be displayed as + * @param {string} props.title - The title associated with the image. + * @param {string} props.url - The URL of the webpage to load the image for. + */ +function ImageLoader ({ faviconSrc, faviconMax, title, url }) { + const imgError = (e) => { + if (!e.target) return + if (!(e.target instanceof HTMLImageElement)) return + if (e.target.src === e.target.dataset.fallback) return console.warn('refusing to load same fallback') + if (e.target.dataset.didTryFallback) { + e.target.dataset.errored = String(true) + return + } + e.target.dataset.didTryFallback = String(true) + e.target.src = e.target.dataset.fallback + } + + const imgLoaded = (e) => { + if (!e.target) return + if (!(e.target instanceof HTMLImageElement)) return + e.target.dataset.loaded = String(true) + if (e.target.src.endsWith('other.svg')) { + return + } + if (e.target.dataset.didTryFallback) { + e.target.style.background = urlToColor(url) + } + } + + const size = Math.min(faviconMax, DDG_DEFAULT_ICON_SIZE) + const src = faviconSrc + '?preferredSize=' + size + + return ( + {`favicon + ) +} + +/** + * @param {string|null|undefined} url + */ +function fallbackSrcFor (url) { + if (!url) return null + try { + const parsed = new URL(url) + const char1 = parsed.hostname.match(/[a-z]/i)?.[0] + if (char1) { + return `./letters/${char1}.svg` + } + } catch (e) { + + } + return null +} + +/** + * @param {string} url + * @param {string} id + * @return {{ ref: import("preact").RefObject; state: DNDState }} + */ +function useTileState (url, id) { + /** @type {import("preact").Ref} */ + const ref = useRef(null) + const [state, setState] = useState(/** @type {DNDState} */({ type: 'idle' })) + const instanceId = useContext(InstanceIdContext) + + useEffect(() => { + const el = ref.current + if (!el) throw new Error('unreachable') + + return combine( + draggable({ + element: el, + getInitialData: () => ({ type: 'grid-item', url, id, instanceId }), + getInitialDataForExternal: () => ({ + 'text/plain': url, + [DDG_MIME_TYPE]: id + }), + onDragStart: () => setState({ type: 'dragging' }), + onDrop: () => setState({ type: 'idle' }) + }), + dropTargetForExternal({ + element: el, + canDrop: ({ source }) => { + return source.types.some(type => type === 'text/html') + }, + getData: ({ input }) => { + return attachClosestEdge({ url, id }, { + element: el, + input, + allowedEdges: ['left', 'right'] + }) + }, + onDrop: () => { + setState({ type: 'idle' }) + }, + onDragLeave: () => setState({ type: 'idle' }), + onDrag: ({ self }) => { + const closestEdge = extractClosestEdge(self.data) + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current + } + return { type: 'is-dragging-over', closestEdge } + }) + } + }), + dropTargetForElements({ + element: el, + getData: ({ input }) => { + return attachClosestEdge({ url, id }, { + element: el, + input, + allowedEdges: ['left', 'right'] + }) + }, + getIsSticky: () => true, + canDrop: ({ source }) => { + return source.data.instanceId === instanceId && + source.data.type === 'grid-item' && + source.data.id !== id + }, + onDragEnter: ({ self }) => { + const closestEdge = extractClosestEdge(self.data) + setState({ type: 'is-dragging-over', closestEdge }) + }, + onDrag ({ self }) { + const closestEdge = extractClosestEdge(self.data) + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current + } + return { type: 'is-dragging-over', closestEdge } + }) + }, + onDragLeave: () => setState({ type: 'idle' }), + onDrop: () => setState({ type: 'idle' }) + }) + ) + }, [instanceId, url, id]) + + return { ref, state } +} + +export const TileMemo = memo(Tile) + +export function Placeholder () { + const id = useId() + const { state, ref } = useTileState(`PLACEHOLDER-URL-${id}`, `PLACEHOLDER-ID-${id}`) + return ( +
+
+ {state.type === 'is-dragging-over' && state.closestEdge + ? ( +
+ ) + : null + } +
+ ) +} + +/** + * @param {object} props + * @param {() => void} props.onClick + */ +export function PlusIcon ({ onClick }) { + const id = useId() + const { state, ref } = useTileState(`PLACEHOLDER-URL-${id}`, `PLACEHOLDER-ID-${id}`) + return ( +
+ +
+ {'Add Favorite'} +
+ {state.type === 'is-dragging-over' && state.closestEdge + ? ( +
+ ) + : null + } +
+ ) +} + +export const PlusIconMemo = memo(PlusIcon) diff --git a/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md b/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md new file mode 100644 index 000000000..1b09c0d73 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md @@ -0,0 +1,22 @@ +--- +title: Favorites Diagrams +--- + +## Page Load + Move operation + +```mermaid +sequenceDiagram + participant 🖥️ Page + participant ⭐ FavoritesWidget + participant 🌐 Browser + + 🖥️ Page->>⭐ FavoritesWidget: Page loads 🖱️ + ⭐ FavoritesWidget->>🌐 Browser: Request initial data+config 📡 + 🌐 Browser-->>⭐ FavoritesWidget: Respond with initial data+config 📦 + ⭐ FavoritesWidget->>🖥️ Page: Render tiles 🧩 + + User->>⭐ FavoritesWidget: Drag and drop a tile 🖱️↔️ + ⭐ FavoritesWidget->>🌐 Browser: Send `favorites_move` action 📤 + 🌐 Browser-->>⭐ FavoritesWidget: Push fresh data 🔄 + ⭐ FavoritesWidget->>🖥️ Page: Re-render with updated order 🧩🔄 +``` diff --git a/special-pages/pages/new-tab/app/favorites/favorites.service.js b/special-pages/pages/new-tab/app/favorites/favorites.service.js new file mode 100644 index 000000000..aa530601b --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/favorites.service.js @@ -0,0 +1,136 @@ +import { Service } from '../service.js' + +/** + * @typedef {import("../../../../types/new-tab.js").FavoritesData} FavoritesData + * @typedef {import("../../../../types/new-tab.js").Favorite} Favorite + * @typedef {import("../../../../types/new-tab.js").FavoritesConfig} FavoritesConfig + * @typedef {import("../../../../types/new-tab.js").FavoritesOpenAction} FavoritesOpenAction + */ + +export const DDG_MIME_TYPE = 'application/vnd.duckduckgo.bookmark-by-id' +export const DDG_FALLBACK_ICON = './company-icons/other.svg' +export const DDG_DEFAULT_ICON_SIZE = 64 + +/** + * + * @document ./favorites.service.md + * @document ./favorites-diagrams.md + */ +export class FavoritesService { + /** + * @param {import("../../src/js/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method. + * @internal + */ + constructor (ntp) { + this.ntp = ntp + + /** @type {Service} */ + this.dataService = new Service({ + initial: () => ntp.messaging.request('favorites_getData'), + subscribe: (cb) => ntp.messaging.subscribe('favorites_onDataUpdate', cb) + }) + + /** @type {Service} */ + this.configService = new Service({ + initial: () => ntp.messaging.request('favorites_getConfig'), + subscribe: (cb) => ntp.messaging.subscribe('favorites_onConfigUpdate', cb), + persist: (data) => ntp.messaging.notify('favorites_setConfig', data) + }) + } + + /** + * @returns {Promise<{data: FavoritesData; config: FavoritesConfig}>} + * @internal + */ + async getInitial () { + const p1 = this.configService.fetchInitial() + const p2 = this.dataService.fetchInitial() + const [config, data] = await Promise.all([p1, p2]) + return { config, data } + } + + /** + * @internal + */ + destroy () { + this.configService.destroy() + this.dataService.destroy() + } + + /** + * @param {(evt: {data: FavoritesData, source: 'manual' | 'subscription'}) => void} cb + * @internal + */ + onData (cb) { + return this.dataService.onData(cb) + } + + /** + * @param {(evt: {data: FavoritesConfig, source: 'manual' | 'subscription'}) => void} cb + * @internal + */ + onConfig (cb) { + return this.configService.onData(cb) + } + + /** + * Update the in-memory data immediate and persist. + * Any state changes will be broadcast to consumers synchronously + * @internal + */ + toggleExpansion () { + this.configService.update(old => { + if (old.expansion === 'expanded') { + return { ...old, expansion: /** @type {const} */('collapsed') } + } else { + return { ...old, expansion: /** @type {const} */('expanded') } + } + }) + } + + /** + * @param {FavoritesData} data + * @param {string} id - entity id to move + * @param {number} targetIndex - target index + * @internal + */ + setFavoritesOrder (data, id, targetIndex) { + // update in memory instantly - this will broadcast changes to all listeners + // eslint-disable-next-line @typescript-eslint/no-unused-vars + this.dataService.update((_old) => { + return data + }) + + // then let the native side know about it + this.ntp.messaging.notify('favorites_move', { + id, + targetIndex + }) + } + + /** + * @param {string} id - entity id + * @internal + */ + openContextMenu (id) { + // let the native side know too + this.ntp.messaging.notify('favorites_openContextMenu', { id }) + } + + /** + * @param {string} id - entity id + * @param {FavoritesOpenAction['target']} target + * @internal + */ + openFavorite (id, target) { + // let the native side know too + this.ntp.messaging.notify('favorites_open', { id, target }) + } + + /** + * @internal + */ + add () { + this.ntp.messaging.notify('favorites_add') + } +} diff --git a/special-pages/pages/new-tab/app/favorites/favorites.service.md b/special-pages/pages/new-tab/app/favorites/favorites.service.md new file mode 100644 index 000000000..95fe6e4db --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/favorites.service.md @@ -0,0 +1,79 @@ +--- +title: Favorites API +--- + +# Public API for the Favorites Widget. + +## Drag + Drop + +When dragging - the follow data is available: + + - `"text/plain": ""` + - `"application/vnd.duckduckgo.bookmark-by-id": ""` + - See this link for the actual value used in code: {@link DDG_MIME_TYPE `DDG_MIME_TYPE`} + +# Messages + +## Requests: + +- {@link "NewTab Messages".FavoritesGetDataRequest `favorites_getData`} + - Used to fetch the initial data (during the first render) + - returns {@link "NewTab Messages".FavoritesData} +- {@link "NewTab Messages".FavoritesGetDataRequest `favorites_getConfig`} + - Used to fetch the initial data (during the first render) + - returns {@link "NewTab Messages".FavoritesConfig} + + +## Subscriptions: + +- {@link "NewTab Messages".FavoritesOnDataUpdateSubscription `favorites_onDataUpdate`}. + - The tracker/company data used in the feed. + - returns {@link "NewTab Messages".FavoritesData} +- {@link "NewTab Messages".FavoritesOnConfigUpdateSubscription `favorites_onConfigUpdate`}. + - The widget config + - returns {@link "NewTab Messages".FavoritesConfig} + + +## Notifications: + +- {@link "NewTab Messages".FavoritesSetConfigNotification `favorites_setConfig`} + - Sent when the user toggles the expansion of the favorites + - Sends {@link "NewTab Messages".FavoritesConfig} + - Example payload: + ```json + { + "expansion": "collapsed" + } + ``` +- {@link "NewTab Messages".FavoritesMoveNotification `favorites_move`} + - Sends {@link "NewTab Messages".FavoritesMoveAction} + - When you receive this message, apply the following + - Search your collection to find the object with the given `id`. + - Remove that object from its current position. + - Insert it into the new position specified by `targetIndex`. + - Example payload: + ```json + { + "id": "abc", + "targetIndex": 1 + } + ``` +- {@link "NewTab Messages".FavoritesOpenContextMenuNotification `favorites_openContextMenu`} + - Sends {@link "NewTab Messages".FavoritesOpenContextMenuAction} + - When you receive this message, show the context menu for the entity +- Example payload: + ```json + { + "id": "abc" + } + ``` +- {@link "NewTab Messages".FavoritesOpenNotification `favorites_open`} + - Sends {@link "NewTab Messages".FavoritesOpenNotification} + - When you receive this message, open the favorite in the given target +- Example payload: + ```json + { + "id": "abc", + "target": "same-tab" + } + ``` diff --git a/special-pages/pages/new-tab/app/favorites/integration-tests/favorites.page.js b/special-pages/pages/new-tab/app/favorites/integration-tests/favorites.page.js new file mode 100644 index 000000000..b84026f8a --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/integration-tests/favorites.page.js @@ -0,0 +1,263 @@ +import { expect } from '@playwright/test' + +export class FavoritesPage { + /** + * @param {import("../../../integration-tests/new-tab.page.js").NewtabPage} ntp + */ + constructor (ntp) { + this.ntp = ntp + } + + async togglesExpansion () { + const { page } = this.ntp + await page.getByLabel('Show more (9 remaining)').click() + await expect(page.getByLabel('Add Favorite')).toBeVisible() + await page.getByLabel('Show less').click() + await expect(page.getByLabel('Add Favorite')).not.toBeVisible() + } + + async opensInNewTab () { + await this.nthFavorite(0).click({ modifiers: ['Meta'] }) + const calls = await this.ntp.mocks.waitForCallCount({ method: 'favorites_open', count: 1 }) + expect(calls[0].payload.params).toStrictEqual({ id: 'id-many-1', target: 'new-tab' }) + } + + async opensInNewWindow () { + await this.nthFavorite(0).click({ modifiers: ['Shift'] }) + const calls = await this.ntp.mocks.waitForCallCount({ method: 'favorites_open', count: 1 }) + expect(calls[0].payload.params).toStrictEqual({ id: 'id-many-1', target: 'new-window' }) + } + + async opensInSameTab () { + await this.nthFavorite(0).click() + const calls = await this.ntp.mocks.waitForCallCount({ method: 'favorites_open', count: 1 }) + expect(calls[0].payload.params).toStrictEqual({ id: 'id-many-1', target: 'same-tab' }) + } + + async addsAnItem () { + const { page } = this.ntp + await page.pause() + await page.getByLabel('Show more (9 remaining)').click() + await page.getByLabel('Add Favorite').click() + await this.ntp.mocks.waitForCallCount({ method: 'favorites_add', count: 1 }) + } + + async rightClickInvokesContextMenuFor () { + const first = this.nthFavorite(0) + const second = this.nthFavorite(1) + const [id, id2] = await Promise.all([first.getAttribute('data-id'), second.getAttribute('data-id')]) + await first.click({ button: 'right' }) + await second.click({ button: 'right' }) + const calls = await this.ntp.mocks.waitForCallCount({ method: 'favorites_openContextMenu', count: 2 }) + expect(calls[0].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'favorites_openContextMenu', + params: { id } + }) + expect(calls[1].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'favorites_openContextMenu', + params: { id: id2 } + }) + } + + async tabsThroughItems () { + const { page } = this.ntp + + const context = page.getByTestId('FavoritesConfigured') + await context.press('Tab') + const firstTile = context.locator('a[href^="https:"][data-id]').nth(0) + const secondTile = context.locator('a[href^="https:"][data-id]').nth(1) + const isActiveElement = await firstTile.evaluate(elem => elem === document.activeElement) + + expect(isActiveElement).toBe(true) + + { + // second + await context.press('Tab') + const isActiveElement = await secondTile.evaluate(elem => elem === document.activeElement) + expect(isActiveElement).toBe(true) + } + + // 3rd + await context.press('Tab') + // 4th + await context.press('Tab') + // 5th + await context.press('Tab') + // 6th + await context.press('Tab') + // 7th - should be the 'show more' toggle now + await context.press('Tab') + + { + const button = page.getByLabel('Show more (9 remaining)') + const isActiveElement = await button.evaluate(elem => elem === document.activeElement) + expect(isActiveElement).toBe(true) + } + + await context.press('Space') + await this.waitForNumFavorites(15) + await context.press('Space') + await this.waitForNumFavorites(6) + } + + /** + * Drags a favorite item from one position to another on the page. + * + * @param {object} options - The drag options. + * @param {number} options.index - The index of the favorite item to be dragged. + * @param {number} options.to - The index where the favorite item should be dragged to. + * @returns {Promise<{ id: string }>} + */ + async drags ({ index, to }) { + const { page } = this.ntp + + const source = this.nthFavorite(index) + const target = this.nthFavorite(to) + + // read the id of the thing we'll drag so we can compare with the payload + const id = await source.getAttribute('data-id') + if (!id) throw new Error('unreachable!, must have id') + const href = await source.getAttribute('href') + if (!id) throw new Error('unreachable, must have href') + + // capture the drag data + await page.evaluate(() => { + document.addEventListener('dragstart', (event) => { + const dataTransfer = event.dataTransfer + const url = dataTransfer?.getData('text/plain') + const data = dataTransfer?.getData('application/vnd.duckduckgo.bookmark-by-id') + + if (url && data) { + /** @type {any} */(window).__dragdata ??= []; + /** @type {any} */(window).__dragdata.push(url, data) + } else { + throw new Error('missing text/plain or application/vnd.duckduckgo.bookmark-by-id') + } + }) + }) + + /** + * ⚠️⚠️⚠️ NOTE ⚠️⚠️⚠️ + * the `targetPosition` here needs to be over HALF of the icon width, since + * the drag and drop implementation drops into the gaps between icons. + * + * So, when we want to drag index 0 to index 2, we have to get to the third element, but cross + * over half-way. + */ + await source.dragTo(target, { targetPosition: { x: 50, y: 50 } }) + + // verify drag data + const dragData = await page.evaluate(() => /** @type {any} */(window).__dragdata) + expect(dragData).toStrictEqual([href, id]) + + return { id } + } + + async sent ({ id, targetIndex }) { + const calls = await this.ntp.mocks.waitForCallCount({ method: 'favorites_move', count: 1 }) + expect(calls[0].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'favorites_move', + params: { id, targetIndex } + }) + } + + async dragsExternal () { + + } + + /** + * @param {number} number + */ + async waitForNumFavorites (number) { + const { page } = this.ntp + await page.waitForFunction((count) => { + const collection = document.querySelectorAll('[data-testid="FavoritesConfigured"] a[href^="https:"][data-id]') + return collection.length === count + }, number, { timeout: 2000 }) + } + + async tabsPastEmptyFavorites () { + const { page } = this.ntp + const body = page.locator('body') + await body.press('Tab') + await body.press('Tab') + const statsToggle = page.getByLabel('Hide recent activity') + const isActive = await statsToggle.evaluate(handle => handle === document.activeElement) + expect(isActive).toBe(true) + } + + /** + * Retrieves the nth favorite item from the Favorites section on the current page. + * + * @param {number} n - The index of the favorite item to retrieve (starting from 0). + * @return {import("@playwright/test").Locator} + */ + nthFavorite (n) { + const { page } = this.ntp + const context = page.getByTestId('FavoritesConfigured') + return context.locator('[data-drop-target-for-element="true"]').nth(n) + } + + /** + * Retrieves the nth favorite item from the Favorites section on the current page. + * + * @param {number} n - The index of the favorite item to retrieve (starting from 0). + * @return {import("@playwright/test").Locator} + */ + nthExternalDropTarget (n) { + const { page } = this.ntp + const context = page.getByTestId('FavoritesConfigured') + return context.locator('[data-drop-target-for-external="true"]').nth(n) + } + + async requestsSmallFavicon () { + const first = this.nthFavorite(0) + const src = await first.locator('img').getAttribute('src') + expect(src).toBe('./icons/favicon@2x.png?preferredSize=16') + } + + /** + * Accepts an external drop at a specified index on the page. + * + * @param {Object} param + * @param {number} param.index - The index of the element where the drop will occur. + */ + async acceptsExternalDrop ({ index }) { + const { page } = this.ntp + const handle = await this.nthExternalDropTarget(index).elementHandle() + + await page.evaluate((target) => { + function createDragEvent (type) { + const event = new DragEvent(type, { + bubbles: true, + cancelable: true, + dataTransfer: new DataTransfer() + }) + event.dataTransfer?.setData('text/html', '') + return event + } + + // Dispatch the dragenter and dragover events to simulate the drag start + target?.dispatchEvent(createDragEvent('dragenter')) + target?.dispatchEvent(createDragEvent('dragover')) + target?.dispatchEvent(createDragEvent('drop')) + }, handle) + + const calls = await this.ntp.mocks.waitForCallCount({ method: 'favorites_move', count: 1 }) + expect(calls[0].payload).toStrictEqual({ + context: 'specialPages', + featureName: 'newTabPage', + method: 'favorites_move', + params: { + id: '3', + targetIndex: index + } + }) + } +} diff --git a/special-pages/pages/new-tab/app/favorites/integration-tests/favorites.spec.js b/special-pages/pages/new-tab/app/favorites/integration-tests/favorites.spec.js new file mode 100644 index 000000000..2c9bff9cd --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/integration-tests/favorites.spec.js @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test' +import { NewtabPage } from '../../../integration-tests/new-tab.page.js' +import { FavoritesPage } from './favorites.page.js' + +test.describe('newtab favorites', () => { + test('fetches config + favorites data', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + await ntp.reducedMotion() + await ntp.openPage() + + const calls1 = await ntp.mocks.waitForCallCount({ method: 'initialSetup', count: 1 }) + const calls2 = await ntp.mocks.waitForCallCount({ method: 'favorites_getConfig', count: 1 }) + const calls3 = await ntp.mocks.waitForCallCount({ method: 'favorites_getData', count: 1 }) + + expect(calls1.length).toBe(1) + expect(calls2.length).toBe(1) + expect(calls3.length).toBe(1) + }) + test('Toggles expansion', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage() + await favorites.togglesExpansion() + }) + test('Opens a favorite', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ platformName: 'macos' }) + await favorites.opensInSameTab() + }) + test('Opens a favorite in new tab', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ platformName: 'macos' }) + await favorites.opensInNewTab() + }) + test('Opens a favorite in new window', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ platformName: 'macos' }) + await favorites.opensInNewWindow() + }) + test('Adds an item', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage() + await favorites.addsAnItem() + }) + test('Opens context menu', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage() + await favorites.rightClickInvokesContextMenuFor() + }) + test('Supports keyboard nav', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage() + await favorites.tabsThroughItems() + }) + test('initial empty state', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ favorites: 0 }) + await favorites.tabsPastEmptyFavorites() + }) + test('re-orders items', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage() + const { id } = await favorites.drags({ index: 0, to: 2 }) + await favorites.sent({ id, targetIndex: 2 }) + }) + test('support drop on placeholders', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ favorites: '2' }) + /** + * Dragging the element onto position 4 is a placeholder (because only 2 favorites were loaded) + * Therefor, this test is asserting that dropping onto a placeholder is the same action as + * dropping into the last position in the list + */ + const PLACEHOLDER_INDEX = 4 + const EXPECTED_TARGET_INDEX = 2 + const { id } = await favorites.drags({ index: 0, to: PLACEHOLDER_INDEX }) + await favorites.sent({ id, targetIndex: EXPECTED_TARGET_INDEX }) + }) + test('accepts external drag/drop', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage() + await favorites.acceptsExternalDrop({ index: 0 }) + }) + test('requests small favicon', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ favorites: 'small-icon' }) + await favorites.requestsSmallFavicon() + }) + test('requests loads fallbacks', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo) + // const favorites = new FavoritesPage(ntp) + await ntp.reducedMotion() + await ntp.openPage({ favorites: 'fallbacks' }) + }) +}) diff --git a/special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js b/special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js new file mode 100644 index 000000000..9db655113 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/mocks/MockFavoritesProvider.js @@ -0,0 +1,87 @@ +import { h } from 'preact' +import { InstanceIdContext } from '../FavouritesGrid.js' +import { FavoritesContext, FavoritesDispatchContext } from '../FavoritesProvider.js' +import { useCallback, useReducer, useState } from 'preact/hooks' +import { useEnv } from '../../../../../shared/components/EnvironmentProvider.js' +import { favorites } from './favorites.data.js' +import { reducer } from '../../service.hooks.js' + +/** + * @typedef {import('../../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../../types/new-tab').FavoritesConfig} FavoritesConfig + * @typedef {import('../../service.hooks.js').State} State + * @typedef {import('../../service.hooks.js').Events} Events + */ + +function getInstanceId () { + return Symbol('instance-id') +} + +/** @type {FavoritesConfig} */ +const DEFAULT_CONFIG = { + expansion: 'expanded' +} + +/** + * @param {object} props + * @param {import("preact").ComponentChild} props.children + * @param {FavoritesData} [props.data] + * @param {FavoritesConfig} [props.config] + */ +export function MockFavoritesProvider ({ + data = favorites.many, + config = DEFAULT_CONFIG, + children +}) { + const { isReducedMotion } = useEnv() + + const [instanceId] = useState(getInstanceId) + + const initial = /** @type {State} */({ + status: 'ready', + data, + config + }) + + /** @type {[State, import('preact/hooks').Dispatch]} */ + const [state, dispatch] = useReducer(reducer, initial) + + const toggle = useCallback(() => { + if (state.status !== 'ready') return + if (state.config.expansion === 'expanded') { + dispatch({ kind: 'config', config: { ...state.config, expansion: 'collapsed' } }) + } else { + dispatch({ kind: 'config', config: { ...state.config, expansion: 'expanded' } }) + } + }, [state.status, state.config?.expansion, isReducedMotion]) + + const listDidReOrder = useCallback((/** @type {Favorite[]} */newList) => { + dispatch({ kind: 'data', data: { favorites: newList } }) + }, []) + + const openContextMenu = (...args) => { + console.log('noop openContextMenu', ...args) + /* no-op */ + } + + const openFavorite = (...args) => { + console.log('noop openFavorite', ...args) + /* no-op */ + } + + const add = (...args) => { + /* no-op */ + console.log('noop add', ...args) + } + + return ( + + + + {children} + + + + ) +} diff --git a/special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js b/special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js new file mode 100644 index 000000000..00a41f372 --- /dev/null +++ b/special-pages/pages/new-tab/app/favorites/mocks/favorites.data.js @@ -0,0 +1,222 @@ +/** + * @typedef {import('../../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../../types/new-tab').FavoritesConfig} FavoritesConfig + */ + +/** + * @type {{ + * many: {favorites: Favorite[]}; + * single: {favorites: Favorite[]}; + * none: {favorites: Favorite[]}; + * two: {favorites: Favorite[]}; + * "small-icon": {favorites: Favorite[]}; + * "fallbacks": {favorites: Favorite[]}; + * }} + */ +export const favorites = { + many: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-many-1', url: 'https://example.com?id=id-many-1', title: 'Amazon', favicon: { src: './company-icons/amazon.svg', maxAvailableSize: 64 } }, + { id: 'id-many-2', url: 'https://example.com?id=id-many-2', title: 'Adform', favicon: null }, + { id: 'id-many-3', url: 'https://a.example.com?id=id-many-3', title: 'Adobe', favicon: { src: './this-does-note-exist', maxAvailableSize: 64 } }, + { id: 'id-many-3', url: 'https://b.example.com?id=id-many-3', title: 'Adobe', favicon: { src: './this-does-note-exist', maxAvailableSize: 64 } }, + { id: 'id-many-4', url: 'https://222?id=id-many-3', title: 'Gmail', favicon: null }, + { id: 'id-many-5', url: 'https://example.com?id=id-many-5', title: 'TikTok', favicon: { src: './company-icons/bytedance.svg', maxAvailableSize: 64 } }, + { id: 'id-many-6', url: 'https://example.com?id=id-many-6', title: 'DoorDash', favicon: { src: './company-icons/d.svg', maxAvailableSize: 64 } }, + { id: 'id-many-7', url: 'https://example.com?id=id-many-7', title: 'Facebook', favicon: { src: './company-icons/facebook.svg', maxAvailableSize: 64 } }, + { id: 'id-many-8', url: 'https://example.com?id=id-many-8', title: 'Beeswax', favicon: { src: './company-icons/beeswax.svg', maxAvailableSize: 64 } }, + { id: 'id-many-9', url: 'https://example.com?id=id-many-9', title: 'Adobe', favicon: { src: './company-icons/adobe.svg', maxAvailableSize: 64 } }, + { id: 'id-many-10', url: 'https://example.com?id=id-many-10', title: 'Beeswax', favicon: { src: './company-icons/beeswax.svg', maxAvailableSize: 64 } }, + { id: 'id-many-11', url: 'https://example.com?id=id-many-11', title: 'Facebook', favicon: { src: './company-icons/facebook.svg', maxAvailableSize: 64 } }, + { id: 'id-many-12', url: 'https://example.com?id=id-many-12', title: 'Gmail', favicon: { src: './company-icons/google.svg', maxAvailableSize: 64 } }, + { id: 'id-many-13', url: 'https://example.com?id=id-many-13', title: 'TikTok', favicon: { src: './company-icons/bytedance.svg', maxAvailableSize: 64 } }, + { id: 'id-many-14', url: 'https://example.com?id=id-many-14', title: 'yeti', favicon: { src: './company-icons/d.svg', maxAvailableSize: 64 } } + ] + }, + two: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-two-1', url: 'https://example.com?id=id-two-1', title: 'Amazon', favicon: { src: './company-icons/amazon.svg', maxAvailableSize: 32 } }, + { id: 'id-two-2', url: 'https://example.com?id=id-two-2', title: 'Adform', favicon: { src: './company-icons/adform.svg', maxAvailableSize: 32 } } + ] + }, + single: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-single-1', url: 'https://example.com?id=id-single-1', title: 'Amazon', favicon: { src: './company-icons/amazon.svg', maxAvailableSize: 32 } } + ] + }, + none: { + /** @type {Favorite[]} */ + favorites: [] + }, + 'small-icon': { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-small-icon-1', url: 'https://duckduckgo.com', title: 'DuckDuckGo', favicon: { src: './icons/favicon@2x.png', maxAvailableSize: 16 } } + ] + }, + fallbacks: { + /** @type {Favorite[]} */ + favorites: [ + { id: 'id-fallbacks-1', url: 'https://example.com?id=id-many-1', title: 'Amazon', favicon: { src: './company-icons/amazon.svg', maxAvailableSize: 64 } }, + { id: 'id-fallbacks-2', url: 'https://example.com?id=id-many-2', title: 'Adform', favicon: null }, + { id: 'id-fallbacks-3', url: 'https://a.example.com?id=id-many-3', title: 'Adobe', favicon: { src: './this-does-note-exist', maxAvailableSize: 16 } } + ] + } +} + +export function gen (count = 1000) { + const max = Math.min(count, 1000) + const icons = [ + '33across.svg', + 'a.svg', + 'acuityads.svg', + 'adform.svg', + 'adjust.svg', + 'adobe.svg', + 'akamai.svg', + 'amazon.svg', + 'amplitude.svg', + 'appsflyer.svg', + 'automattic.svg', + 'b.svg', + 'beeswax.svg', + 'bidtellect.svg', + 'branch-metrics.svg', + 'braze.svg', + 'bugsnag.svg', + 'bytedance.svg', + 'c.svg', + 'chartbeat.svg', + 'cloudflare.svg', + 'cognitiv.svg', + 'comscore.svg', + 'crimtan-holdings.svg', + 'criteo.svg', + 'd.svg', + 'deepintent.svg', + 'e.svg', + 'exoclick.svg', + 'eyeota.svg', + 'f.svg', + 'facebook.svg', + 'g.svg', + 'google.svg', + 'google-ads.svg', + 'google-analytics.svg', + 'gumgum.svg', + 'h.svg', + 'hotjar.svg', + 'i.svg', + 'id5.svg', + 'improve-digital.svg', + 'index-exchange.svg', + 'inmar.svg', + 'instagram.svg', + 'intent-iq.svg', + 'iponweb.svg', + 'j.svg', + 'k.svg', + 'kargo.svg', + 'kochava.svg', + 'l.svg', + 'line.svg', + 'linkedin.svg', + 'liveintent.svg', + 'liveramp.svg', + 'loopme-ltd.svg', + 'lotame-solutions.svg', + 'm.svg', + 'magnite.svg', + 'mediamath.svg', + 'medianet-advertising.svg', + 'mediavine.svg', + 'merkle.svg', + 'microsoft.svg', + 'mixpanel.svg', + 'n.svg', + 'narrative.svg', + 'nativo.svg', + 'neustar.svg', + 'new-relic.svg', + 'o.svg', + 'onetrust.svg', + 'openjs-foundation.svg', + 'openx.svg', + 'opera-software.svg', + 'oracle.svg', + 'other.svg', + 'outbrain.svg', + 'p.svg', + 'pinterest.svg', + 'prospect-one.svg', + 'pubmatic.svg', + 'pulsepoint.svg', + 'q.svg', + 'quantcast.svg', + 'r.svg', + 'rhythmone.svg', + 'roku.svg', + 'rtb-house.svg', + 'rubicon.svg', + 's.svg', + 'salesforce.svg', + 'semasio.svg', + 'sharethrough.svg', + 'simplifi-holdings.svg', + 'smaato.svg', + 'snap.svg', + 'sonobi.svg', + 'sovrn-holdings.svg', + 'spotx.svg', + 'supership.svg', + 'synacor.svg', + 't.svg', + 'taboola.svg', + 'tapad.svg', + 'teads.svg', + 'the-nielsen-company.svg', + 'the-trade-desk.svg', + 'triplelift.svg', + 'twitter.svg', + 'u.svg', + 'unruly-group.svg', + 'urban-airship.svg', + 'v.svg', + 'verizon-media.svg', + 'w.svg', + 'warnermedia.svg', + 'wpp.svg', + 'x.svg', + 'xaxis.svg', + 'y.svg', + 'yahoo-japan.svg', + 'yandex.svg', + 'yieldmo.svg', + 'youtube.svg', + 'z.svg', + 'zeotap.svg', + 'zeta-global.svg' + ] + return { + favorites: Array.from({ length: max }).map((_, index) => { + const randomFavicon = icons[Math.floor(Math.random() * icons.length)] + const joined = `./company-icons/${randomFavicon}` + const alpha = 'abcdefghijklmnopqrstuvwxyz' + + /** @type {Favorite} */ + const out = { + id: `id-many-${index}`, + url: `https://${alpha[index % 7]}.example.com?id=${index}`, + title: `Example ${index}`, + favicon: { src: joined, maxAvailableSize: 64 } + } + + return out + }) + + } +} 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 e6859d6e8..f97ab1215 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 @@ -59,17 +59,17 @@ export class NewtabPage { * @param {Object} [params] - Optional parameters for opening the page. * @param {'debug' | 'production'} [params.mode] - Optional parameters for opening the page. * @param {boolean} [params.willThrow] - Optional flag to simulate an exception - * @param {number} [params.favoritesCount] - Optional flag to preload a list of favorites + * @param {string|number} [params.favorites] - Optional flag to preload a list of favorites * @param {string} [params.rmf] - Optional flag to point to display=components view with certain rmf example visible * @param {string} [params.updateNotification] - Optional flag to point to display=components view with certain rmf example visible * @param {string} [params.platformName] - Optional parameters for opening the page. */ - async openPage ({ mode = 'debug', platformName, willThrow = false, favoritesCount, rmf, updateNotification } = { }) { + async openPage ({ mode = 'debug', platformName, willThrow = false, favorites, rmf, updateNotification } = { }) { await this.mocks.install() const searchParams = new URLSearchParams({ mode, willThrow: String(willThrow) }) - if (favoritesCount !== undefined) { - searchParams.set('favorites', String(favoritesCount)) + if (favorites !== undefined) { + searchParams.set('favorites', String(favorites)) } if (rmf !== undefined) { diff --git a/special-pages/pages/new-tab/src/js/index.js b/special-pages/pages/new-tab/src/js/index.js index e63901a47..dbf53bd9d 100644 --- a/special-pages/pages/new-tab/src/js/index.js +++ b/special-pages/pages/new-tab/src/js/index.js @@ -76,6 +76,7 @@ const messaging = createSpecialPageMessaging({ }) const newTabMessaging = new NewTabPage(messaging, import.meta.injectName) + init(newTabMessaging, baseEnvironment).catch(e => { console.error(e) const msg = typeof e?.message === 'string' ? e.message : 'unknown init error' diff --git a/special-pages/pages/new-tab/src/js/mock-transport.js b/special-pages/pages/new-tab/src/js/mock-transport.js index 48eee2e5b..783ee73c6 100644 --- a/special-pages/pages/new-tab/src/js/mock-transport.js +++ b/special-pages/pages/new-tab/src/js/mock-transport.js @@ -2,9 +2,13 @@ import { TestTransportConfig } from '@duckduckgo/messaging' import { stats } from '../../app/privacy-stats/mocks/stats.js' import { rmfDataExamples } from '../../app/remote-messaging-framework/mocks/rmf.data.js' +import { favorites, gen } from '../../app/favorites/mocks/favorites.data.js' import { updateNotificationExamples } from '../../app/update-notification/mocks/update-notification.data.js' /** + * @typedef {import('../../../../types/new-tab').Favorite} Favorite + * @typedef {import('../../../../types/new-tab').FavoritesData} FavoritesData + * @typedef {import('../../../../types/new-tab').FavoritesConfig} FavoritesConfig * @typedef {import('../../../../types/new-tab').StatsConfig} StatsConfig * @typedef {import('../../../../types/new-tab').UpdateNotificationData} UpdateNotificationData * @typedef {import('../../../../types/new-tab.js').NewTabMessages['subscriptions']['subscriptionEvent']} SubscriptionNames @@ -109,6 +113,36 @@ export function mockTransport () { clearRmf() return } + case 'favorites_setConfig': { + if (!msg.params) throw new Error('unreachable') + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { animation, ...rest } = msg.params + write('favorites_config', rest) + broadcast('favorites_config') + return + } + case 'favorites_move': { + if (!msg.params) throw new Error('unreachable') + const { id, targetIndex } = msg.params + const data = read('favorites_data') + + if (Array.isArray(data?.favorites)) { + const favorites = reorderArray(data.favorites, id, targetIndex) + write('favorites_data', { favorites }) + broadcast('favorites_data') + } + + return + } + case 'favorites_openContextMenu': { + if (!msg.params) throw new Error('unreachable') + console.log('mock: ignoring favorites_openContextMenu', msg.params) + return + } + case 'favorites_add': { + console.log('mock: ignoring favorites_add') + return + } default: { console.warn('unhandled notification', msg) } @@ -143,6 +177,46 @@ export function mockTransport () { }, { signal: controller.signal }) return () => controller.abort() } + case 'favorites_onDataUpdate': { + const controller = new AbortController() + channel.addEventListener('message', (msg) => { + if (msg.data.change === 'favorites_data') { + const values = read('favorites_data') + if (values) { + cb(values) + } + } + }, { signal: controller.signal }) + + // setTimeout(() => { + // const next = favorites.many.favorites.map(item => { + // if (item.id === 'id-many-2') { + // return { + // ...item, + // favicon: { + // src: './company-icons/adform.svg', maxAvailableSize: 32 + // } + // } + // } + // return item + // }); + // cb({favorites: next}) + // }, 2000) + + return () => controller.abort() + } + case 'favorites_onConfigUpdate': { + const controller = new AbortController() + channel.addEventListener('message', (msg) => { + if (msg.data.change === 'favorites_config') { + const values = read('favorites_config') + if (values) { + cb(values) + } + } + }, { signal: controller.signal }) + return () => controller.abort() + } case 'rmf_onDataUpdate': { // store the callback for later (eg: dismiss) const prev = rmfSubscriptions.get('rmf_onDataUpdate') || [] @@ -161,7 +235,8 @@ export function mockTransport () { }, ms) return () => clearTimeout(timeout) } - return () => {} + return () => { + } } case 'updateNotification_onDataUpdate': { const update = url.searchParams.get('update-notification') @@ -176,7 +251,8 @@ export function mockTransport () { } } } - return () => { } + return () => { + } }, // eslint-ignore-next-line require-await request (_msg) { @@ -189,11 +265,34 @@ export function mockTransport () { } case 'stats_getConfig': { /** @type {StatsConfig} */ - const defaultConfig = { expansion: 'expanded', animation: { kind: 'auto-animate' } } + const defaultConfig = { expansion: 'expanded', animation: { kind: 'none' } } const fromStorage = read('stats_config') || defaultConfig if (url.searchParams.get('animation') === 'none') { fromStorage.animation = { kind: 'none' } + } else { + fromStorage.animation = { kind: 'view-transitions' } + } + return Promise.resolve(fromStorage) + } + case 'favorites_getData': { + const param = url.searchParams.get('favorites') + let data + if (param && param in favorites) { + data = favorites[param] + } else { + data = param + ? gen(Number(url.searchParams.get('favorites'))) + : read('favorites_data') || favorites.many } + + write('favorites_data', data) + // return new Promise((resolve) => setTimeout(() => resolve(dataToWrite), 1000)) + return Promise.resolve(data) + } + case 'favorites_getConfig': { + /** @type {FavoritesConfig} */ + const defaultConfig = { expansion: 'collapsed', animation: { kind: 'none' } } + const fromStorage = read('favorites_config') || defaultConfig if (url.searchParams.get('animation') === 'view-transitions') { fromStorage.animation = { kind: 'view-transitions' } } @@ -257,3 +356,17 @@ export function mockTransport () { } }) } + +/** + * @template {{id: string}} T + * @param {T[]} array + * @param {string} id + * @param {number} toIndex + * @return {T[]} + */ +function reorderArray (array, id, toIndex) { + const fromIndex = array.findIndex(item => item.id === id) + const element = array.splice(fromIndex, 1)[0] // Remove the element from the original position + array.splice(toIndex, 0, element) // Insert the element at the new position + return array +} diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js index 5f13da3a9..7e54a3a93 100644 --- a/special-pages/playwright.config.js +++ b/special-pages/playwright.config.js @@ -22,6 +22,7 @@ export default defineConfig({ 'privacy-stats.spec.js', 'rmf.spec.js', 'new-tab.spec.js', + 'favorites.spec.js', 'update-notification.spec.js' ], use: { From f9b4d689ec0aa04e0c11d3ce69d974319900a0c9 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 4 Nov 2024 11:37:42 +0000 Subject: [PATCH 2/4] remove animations from favorites --- .../pages/new-tab/app/favorites/Favorites.js | 28 +++---------------- .../new-tab/app/favorites/FavouritesGrid.js | 5 ++-- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/special-pages/pages/new-tab/app/favorites/Favorites.js b/special-pages/pages/new-tab/app/favorites/Favorites.js index 6c9958a82..414e8eefd 100644 --- a/special-pages/pages/new-tab/app/favorites/Favorites.js +++ b/special-pages/pages/new-tab/app/favorites/Favorites.js @@ -22,41 +22,21 @@ import { usePlatformName } from '../settings.provider.js' * @typedef {import('../../../../types/new-tab').FavoritesOpenAction['target']} OpenTarget */ -/** - * @param {object} props - * @param {Favorite[]} props.favorites - * @param {(list: Favorite[], id: string, toIndex: number) => void} props.listDidReOrder - * @param {(id: string) => void} props.openContextMenu - * @param {(id: string, target: OpenTarget) => void} props.openFavorite - * @param {() => void} props.add - * @param {Expansion} props.expansion - * @param {() => void} props.toggle - */ -export function Favorites (props) { - return ( - - ) -} - -const FavoritesMemo = memo(FavoritesConfigured) +export const Favorites = memo(FavoritesConfigured) /** * @param {object} props * @param {import("preact").Ref} [props.gridRef] * @param {Favorite[]} props.favorites - * @param {import("preact").ComponentProps['listDidReOrder']} props.listDidReOrder + * @param {(list: Favorite[], id: string, targetIndex: number) => void} props.listDidReOrder * @param {Expansion} props.expansion - * @param {Animation['kind']} props.animateItems * @param {() => void} props.toggle * @param {(id: string) => void} props.openContextMenu * @param {(id: string, target: OpenTarget) => void} props.openFavorite * @param {() => void} props.add */ -export function FavoritesConfigured ({ gridRef, favorites, listDidReOrder, expansion, toggle, animateItems, openContextMenu, openFavorite, add }) { - useGridState(favorites, listDidReOrder, animateItems) +export function FavoritesConfigured ({ gridRef, favorites, listDidReOrder, expansion, toggle, openContextMenu, openFavorite, add }) { + useGridState(favorites, listDidReOrder) const platformName = usePlatformName() const { t } = useTypedTranslation() diff --git a/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js b/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js index 305a9acc0..105fe2068 100644 --- a/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js +++ b/special-pages/pages/new-tab/app/favorites/FavouritesGrid.js @@ -28,9 +28,8 @@ export const InstanceIdContext = createContext(getInstanceId()) /** * @param {Favorite[]} favorites * @param {import("preact").ComponentProps['listDidReOrder']} setFavorites - * @param {Animation['kind']} animation */ -export function useGridState (favorites, setFavorites, animation) { +export function useGridState (favorites, setFavorites) { const instanceId = useContext(InstanceIdContext) useEffect(() => { return combine( @@ -156,5 +155,5 @@ export function useGridState (favorites, setFavorites, animation) { } }) ) - }, [instanceId, favorites, animation]) + }, [instanceId, favorites]) } From 6f736221f31cffb01bf935ccd954280a6b9c6199 Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 4 Nov 2024 11:52:28 +0000 Subject: [PATCH 3/4] bump for delpoy --- special-pages/pages/new-tab/app/favorites/favorites-diagrams.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md b/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md index 1b09c0d73..97ddaf12e 100644 --- a/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md +++ b/special-pages/pages/new-tab/app/favorites/favorites-diagrams.md @@ -1,5 +1,5 @@ --- -title: Favorites Diagrams +title: Favorites Diagrams (test) --- ## Page Load + Move operation From 90b0f0169bade69ed71e8253a6bb1c5c16dd90db Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Mon, 4 Nov 2024 14:34:32 +0000 Subject: [PATCH 4/4] linting --- .../pages/new-tab/app/favorites/FavoritesProvider.js | 8 ++++---- .../pages/new-tab/app/favorites/favorites.service.js | 2 +- special-pages/pages/new-tab/src/js/mock-transport.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js b/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js index 6684c14bd..8bf0478f9 100644 --- a/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js +++ b/special-pages/pages/new-tab/app/favorites/FavoritesProvider.js @@ -30,22 +30,22 @@ export const FavoritesContext = createContext({ throw new Error('must implement') }, /** @type {(list: Favorite[], id: string, targetIndex: number) => void} */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars + listDidReOrder: (list, id, targetIndex) => { throw new Error('must implement') }, /** @type {(id: string) => void} */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars + openContextMenu: (id) => { throw new Error('must implement') }, /** @type {(id: string, target: OpenTarget) => void} */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars + openFavorite: (id, target) => { throw new Error('must implement') }, /** @type {() => void} */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars + add: () => { throw new Error('must implement add') } diff --git a/special-pages/pages/new-tab/app/favorites/favorites.service.js b/special-pages/pages/new-tab/app/favorites/favorites.service.js index aa530601b..f278b5f59 100644 --- a/special-pages/pages/new-tab/app/favorites/favorites.service.js +++ b/special-pages/pages/new-tab/app/favorites/favorites.service.js @@ -96,7 +96,7 @@ export class FavoritesService { */ setFavoritesOrder (data, id, targetIndex) { // update in memory instantly - this will broadcast changes to all listeners - // eslint-disable-next-line @typescript-eslint/no-unused-vars + this.dataService.update((_old) => { return data }) diff --git a/special-pages/pages/new-tab/src/js/mock-transport.js b/special-pages/pages/new-tab/src/js/mock-transport.js index 783ee73c6..9370a5b4b 100644 --- a/special-pages/pages/new-tab/src/js/mock-transport.js +++ b/special-pages/pages/new-tab/src/js/mock-transport.js @@ -115,7 +115,7 @@ export function mockTransport () { } case 'favorites_setConfig': { if (!msg.params) throw new Error('unreachable') - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { animation, ...rest } = msg.params write('favorites_config', rest) broadcast('favorites_config')