diff --git a/packages/extension/src/manifest.json b/packages/extension/src/manifest.json index c17ba231e8b..d737fdbd3ca 100644 --- a/packages/extension/src/manifest.json +++ b/packages/extension/src/manifest.json @@ -32,8 +32,8 @@ "https://*.staging.daily.dev/" ], "__firefox|dev__permissions": ["storage", "http://localhost/", "https://*.local.fylla.dev/"], - "optional_permissions": ["topSites", "declarativeNetRequestWithHostAccess"], - "__firefox__optional_permissions": ["topSites", "*://*/*"], + "optional_permissions": ["topSites", "bookmarks", "declarativeNetRequestWithHostAccess"], + "__firefox__optional_permissions": ["topSites", "bookmarks", "*://*/*"], "__chrome|opera|edge__optional_host_permissions": [ "*://*/*"], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self';" diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx new file mode 100644 index 00000000000..80df4b7c472 --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -0,0 +1,201 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; +import { + Button, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { Justify } from '@dailydotdev/shared/src/components/utilities'; +import { + Typography, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { MostVisitedSitesPermissionContent } from '@dailydotdev/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent'; +import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { MAX_SHORTCUTS } from '@dailydotdev/shared/src/features/shortcuts/types'; + +// Coordinates the "Import from browser" / "Import from bookmarks" flows for +// the new hub. Keeps the permission modals, picker modal, and silent-import +// paths in one place so the hub UI itself stays declarative. +export function ShortcutImportFlow(): ReactElement | null { + const { + showImportSource, + setShowImportSource, + returnToAfterImport, + topSites, + hasCheckedPermission: hasCheckedTopSitesPermission, + askTopSitesPermission, + bookmarks, + hasCheckedBookmarksPermission, + askBookmarksPermission, + } = useShortcuts(); + const { customLinks } = useSettingsContext(); + const { displayToast } = useToastNotification(); + const { openModal } = useLazyModal(); + + // Prevents running the same import more than once for a single click. + const handledRef = useRef(null); + + useEffect(() => { + if (!showImportSource) { + handledRef.current = null; + return; + } + + const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); + + if (showImportSource === 'topSites') { + if (!hasCheckedTopSitesPermission || topSites === undefined) { + return; + } + if (handledRef.current === 'topSites') { + return; + } + handledRef.current = 'topSites'; + + if (topSites.length === 0) { + displayToast('No top sites yet. Visit some sites and try again.'); + setShowImportSource?.(null); + return; + } + if (capacity === 0) { + displayToast( + `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, + ); + setShowImportSource?.(null); + return; + } + + // Always show the picker so the user sees exactly what gets imported, + // which source it comes from, and can deselect items before confirming. + // Previously we silently imported when items fit in capacity, which was + // confusing ("what just got added? where from?"). + const items = topSites.map((s) => ({ url: s.url })); + openModal({ + type: LazyModal.ImportPicker, + props: { source: 'topSites', items, returnTo: returnToAfterImport }, + }); + setShowImportSource?.(null); + return; + } + + if (showImportSource === 'bookmarks') { + if (!hasCheckedBookmarksPermission || bookmarks === undefined) { + return; + } + if (handledRef.current === 'bookmarks') { + return; + } + handledRef.current = 'bookmarks'; + + if (bookmarks.length === 0) { + displayToast( + 'Your bookmarks bar is empty. Add some bookmarks and try again.', + ); + setShowImportSource?.(null); + return; + } + if (capacity === 0) { + displayToast( + `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, + ); + setShowImportSource?.(null); + return; + } + + const items = bookmarks.map((b) => ({ url: b.url, title: b.title })); + openModal({ + type: LazyModal.ImportPicker, + props: { source: 'bookmarks', items, returnTo: returnToAfterImport }, + }); + setShowImportSource?.(null); + } + }, [ + showImportSource, + topSites, + hasCheckedTopSitesPermission, + bookmarks, + hasCheckedBookmarksPermission, + customLinks, + displayToast, + openModal, + setShowImportSource, + returnToAfterImport, + ]); + + // Permission modals: shown when the user asked to import but the browser + // hasn't granted permission yet. Once granted, the provider refreshes + // `topSites` / `bookmarks` and the effect above finishes the import. + if ( + showImportSource === 'topSites' && + hasCheckedTopSitesPermission && + topSites === undefined + ) { + const onGrant = async () => { + const granted = await askTopSitesPermission(); + if (!granted) { + setShowImportSource?.(null); + } + }; + return ( + setShowImportSource?.(null)} + > + + + Show most visited sites + + + + + ); + } + + if ( + showImportSource === 'bookmarks' && + hasCheckedBookmarksPermission && + bookmarks === undefined + ) { + const onGrant = async () => { + const granted = await askBookmarksPermission(); + if (!granted) { + setShowImportSource?.(null); + } + }; + return ( + setShowImportSource?.(null)} + > + + + Import your bookmarks bar + + To import your bookmarks bar, your browser will ask for permission + to read bookmarks. We never sync your bookmarks to our servers. + + + + + + + ); + } + + return null; +} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx index 4b857c7f4f0..48c3f3c4222 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx @@ -41,6 +41,13 @@ jest.mock('@dailydotdev/shared/src/lib/boot', () => ({ getBootData: jest.fn(), })); +// Pin these tests to the legacy code path. The shortcuts hub redesign is +// default-on in production; the suite below exercises the legacy UI that the +// hub is replacing behind the feature flag. +jest.mock('@dailydotdev/shared/src/hooks/useConditionalFeature', () => ({ + useConditionalFeature: () => ({ value: false, isLoading: false }), +})); + jest.mock('webextension-polyfill', () => { let providedPermission = false; diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx index 228a69ac85f..14bad4fa2f9 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -13,14 +13,21 @@ import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; import { useShortcutLinks } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutLinks'; +import { useConditionalFeature } from '@dailydotdev/shared/src/hooks/useConditionalFeature'; +import { featureShortcutsHub } from '@dailydotdev/shared/src/lib/featureManagement'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { useShortcutsMigration } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsMigration'; import { ShortcutLinksList } from './ShortcutLinksList'; import { ShortcutGetStarted } from './ShortcutGetStarted'; +import { ShortcutLinksHub } from './ShortcutLinksHub'; +import { ShortcutImportFlow } from './ShortcutImportFlow'; interface ShortcutLinksProps { shouldUseListFeedLayout: boolean; } -export default function ShortcutLinks({ +function LegacyShortcutLinks({ shouldUseListFeedLayout, }: ShortcutLinksProps): ReactElement { const { openModal } = useLazyModal(); @@ -111,7 +118,7 @@ export default function ShortcutLinks({ {...{ onLinkClick, onOptionsOpen, - shortcutLinks, + shortcutLinks: shortcutLinks ?? [], shouldUseListFeedLayout, toggleShowTopSites, onReorder, @@ -123,3 +130,58 @@ export default function ShortcutLinks({ ); } + +function NewShortcutLinks({ + shouldUseListFeedLayout, +}: ShortcutLinksProps): ReactElement { + const { showTopSites, toggleShowTopSites, flags } = useSettingsContext(); + const manager = useShortcutsManager(); + const { openModal } = useLazyModal(); + useShortcutsMigration(); + + if (!showTopSites) { + return <>; + } + + // Auto mode renders live top sites from the browser and ships its own + // permission CTA / empty state inside the hub, so an empty `customLinks` + // is not a signal to show onboarding. Only manual-mode users with zero + // curated shortcuts should see the "Choose your most visited sites" card. + const mode = flags?.shortcutsMode ?? 'manual'; + const showOnboarding = mode === 'manual' && manager.shortcuts.length === 0; + + if (showOnboarding) { + return ( + <> + + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }) + } + /> + + + ); + } + + return ( + <> + + + + ); +} + +export default function ShortcutLinks(props: ShortcutLinksProps): ReactElement { + const { user } = useAuthContext(); + const { value: hubEnabled } = useConditionalFeature({ + feature: featureShortcutsHub, + shouldEvaluate: !!user, + }); + + if (user && hubEnabled) { + return ; + } + + return ; +} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx new file mode 100644 index 00000000000..e0f1ceb6a94 --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -0,0 +1,517 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; +import { + EyeIcon, + MenuIcon, + SettingsIcon, +} from '@dailydotdev/shared/src/components/icons'; +import { ChromeIcon } from '@dailydotdev/shared/src/components/icons/Browser/Chrome'; +import { MenuIcon as WrappingMenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; +import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; +import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; +import { ShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/ShortcutTile'; +import { AddShortcutTile } from '@dailydotdev/shared/src/features/shortcuts/components/AddShortcutTile'; +import { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { useHiddenTopSites } from '@dailydotdev/shared/src/features/shortcuts/hooks/useHiddenTopSites'; +import { + useDragClickGuard, + DRAG_ACTIVATION_DISTANCE_PX, +} from '@dailydotdev/shared/src/features/shortcuts/hooks/useDragClickGuard'; +import { useShortcutDropZone } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutDropZone'; +import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; +import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { + LogEvent, + ShortcutsSourceType, + TargetType, +} from '@dailydotdev/shared/src/lib/log'; +import type { + Shortcut, + ShortcutsAppearance, + ShortcutsMode, +} from '@dailydotdev/shared/src/features/shortcuts/types'; +import { + DEFAULT_SHORTCUTS_APPEARANCE, + MAX_SHORTCUTS, +} from '@dailydotdev/shared/src/features/shortcuts/types'; + +interface ShortcutLinksHubProps { + shouldUseListFeedLayout: boolean; +} + +interface SourceModeToggleItemProps { + isAuto: boolean; + onToggle: () => void; +} + +// Stable menu row that flips source mode in place. Uses the same metrics as +// standard DropdownMenuOptions rows (h-7, typo-footnote, MenuIcon wrapper) so +// the dropdown reads as one dense list — matching the PostOptionButton +// convention. The enclosing DropdownMenuItem owns click + keyboard; the +// native Switch is pointer-events-none so clicks fall through to the row +// handler and `preventDefault` on `onSelect` keeps the menu open after +// toggling (it's a setting, not an action). +function SourceModeToggleItem({ + isAuto, + onToggle, +}: SourceModeToggleItemProps): ReactElement { + return ( + { + event.preventDefault(); + onToggle(); + }} + > + + + Most visited sites + + + + ); +} + +export function ShortcutLinksHub({ + shouldUseListFeedLayout, +}: ShortcutLinksHubProps): ReactElement { + const { openModal } = useLazyModal(); + const { toggleShowTopSites, showTopSites, flags, updateFlag } = + useSettingsContext(); + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const manager = useShortcutsManager(); + const { + hidden: hiddenTopSites, + hide: hideTopSite, + unhide: unhideTopSite, + } = useHiddenTopSites(); + const { + topSites, + hasCheckedPermission: hasCheckedTopSitesPermission, + askTopSitesPermission, + } = useShortcuts(); + + // Default to 'manual' so existing users keep their curated lists. Auto mode + // is opt-in via the overflow menu (users who grant topSites permission and + // prefer Chrome-style live tiles). + const mode: ShortcutsMode = flags?.shortcutsMode ?? 'manual'; + const isAuto = mode === 'auto'; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE_PX }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // dnd-kit activates drag via pointer events; browsers still synthesize a + // `click` on `pointerup` because the tile follows the pointer via CSS + // transform, and on drops *outside* the toolbar the click target can be a + // sibling surface React's root listener never bubbles up to our handler. + // `useDragClickGuard` installs a document-level capture-phase listener so + // the stray click is swallowed wherever it lands, with the toolbar's + // `onClickCapture` kept as a React-side belt for the normal in-bounds case. + const { armGuard: armDragSuppression, onClickCapture: suppressClickCapture } = + useDragClickGuard(); + + // Belt-and-suspenders for native HTML5 drag. Each tile already marks its + // anchor/favicon as `draggable={false}`, but capture-phase cancellation + // at the toolbar root makes it impossible for a stray child (or a + // browser that ignores the attribute) to kick off a URL drag that could + // then navigate the tab when dropped outside any drop zone. + const suppressNativeDragCapture = (event: React.DragEvent) => { + event.preventDefault(); + }; + + const loggedRef = useRef(null); + useEffect(() => { + if (!showTopSites) { + return; + } + if (loggedRef.current === mode) { + return; + } + loggedRef.current = mode; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ + source: isAuto + ? ShortcutsSourceType.Browser + : ShortcutsSourceType.Custom, + }), + }); + }, [logEvent, showTopSites, mode, isAuto]); + + const [reorderAnnouncement, setReorderAnnouncement] = useState(''); + + // Auto mode: render live top sites from the browser, minus any the user + // dismissed (Chrome-style). Manual mode: render the curated customLinks. + const hiddenTopSitesSet = useMemo( + () => new Set(hiddenTopSites), + [hiddenTopSites], + ); + const autoShortcuts: Shortcut[] = useMemo( + () => + (topSites ?? []) + .filter((site) => !hiddenTopSitesSet.has(site.url)) + .slice(0, MAX_SHORTCUTS) + .map((site) => ({ url: site.url, name: site.title || undefined })), + [topSites, hiddenTopSitesSet], + ); + const manualShortcuts = manager.shortcuts.slice(0, MAX_SHORTCUTS); + const overflowCount = isAuto + ? 0 + : manager.shortcuts.length - manualShortcuts.length; + const visibleShortcuts = isAuto ? autoShortcuts : manualShortcuts; + + const handleDragEnd = (event: DragEndEvent) => { + armDragSuppression(); + if (isAuto) { + return; + } + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const urls = manualShortcuts.map((s) => s.url); + const oldIndex = urls.indexOf(active.id as string); + const newIndex = urls.indexOf(over.id as string); + if (oldIndex < 0 || newIndex < 0) { + return; + } + const overflowUrls = manager.shortcuts + .slice(MAX_SHORTCUTS) + .map((s) => s.url); + manager.reorder([...arrayMove(urls, oldIndex, newIndex), ...overflowUrls]); + const moved = manualShortcuts[oldIndex]; + const label = moved?.name || moved?.url || 'Shortcut'; + setReorderAnnouncement( + `Moved ${label} to position ${newIndex + 1} of ${urls.length}`, + ); + }; + + const onLinkClick = () => + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ + source: isAuto + ? ShortcutsSourceType.Browser + : ShortcutsSourceType.Custom, + }), + }); + + const onEdit = (shortcut: Shortcut) => + openModal({ + type: LazyModal.ShortcutEdit, + props: { mode: 'edit', shortcut }, + }); + + const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + + // Chrome-style dismiss for auto mode: hide the tile for this browser and + // offer a single-action "Undo" toast. We can't delete the site from the + // browser's history, so we just remember the URL locally. + const onHideTopSite = (shortcut: Shortcut) => { + hideTopSite(shortcut.url); + const label = shortcut.name || shortcut.url; + displayToast(`Hidden ${label}`, { + action: { + copy: 'Undo', + onClick: () => unhideTopSite(shortcut.url), + }, + }); + }; + + const onAdd = () => + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); + + // Drag-a-link-into-the-row shortcut: skip the edit modal entirely when + // the user drops a URL from the address bar, another tab, or the + // browser's bookmarks bar. We only surface a toast when the add fails + // (duplicate / limit), because the success case speaks for itself — the + // tile just appears in the row. + const onDropUrl = async (url: string) => { + const result = await manager.addShortcut({ url }); + if (result.error) { + displayToast(result.error); + } + }; + + // The whole toolbar is the drop zone (auto mode excluded — we can't add + // to a browser-managed list). The "+" tile is still visible for click + // discoverability, but users no longer have to aim at a 44px target to + // drop a bookmark — anywhere on the row counts. + const canAcceptDroppedUrl = !isAuto && manager.canAdd; + const { isDropTarget, dropHandlers } = useShortcutDropZone( + onDropUrl, + canAcceptDroppedUrl, + ); + + const onManage = () => openModal({ type: LazyModal.ShortcutsManage }); + + const requestTopSitesAccess = async () => { + // Unlike the import flow, we only need READ access here — we're not + // copying sites into customLinks, just rendering whatever the browser + // exposes. If the user declines we stay on the empty-state CTA. + const granted = await askTopSitesPermission(); + return granted; + }; + + const switchToAuto = async () => { + await updateFlag('shortcutsMode', 'auto'); + // Auto mode is worthless without topSites permission — if the user + // declines (or it was previously denied and the browser returned no + // data), flip back to manual so they don't end up with an empty row + // and no idea how to fix it. + if (!hasCheckedTopSitesPermission || topSites === undefined) { + const granted = await requestTopSitesAccess(); + if (!granted) { + await updateFlag('shortcutsMode', 'manual'); + } + } + }; + + const switchToManual = () => updateFlag('shortcutsMode', 'manual'); + + // The overflow menu is the same shape in both modes — source selection is + // an inline toggle at the top, so users never see items appear/disappear + // after flipping mode. "Add shortcut" stays visible but is disabled in auto + // so the placement doesn't jump. + const toggleSourceMode = () => { + if (isAuto) { + switchToManual(); + } else { + switchToAuto(); + } + }; + + // The inline "+" tile already lets users add when there's room. We don't + // mirror "Add shortcut" into the dropdown: it either duplicates the tile + // (manual + room left) or points at a disabled action (at the 12/12 cap), + // both of which are clutter. At the limit, the library's-full story is + // told by the Manage modal's counter and by tiles already filling the row. + const menuOptions = [ + { + icon: , + label: 'Manage shortcuts…', + action: onManage, + }, + { + icon: , + label: 'Hide shortcuts', + action: toggleShowTopSites, + }, + ]; + + // Auto mode empty has two shapes: either the user hasn't granted topSites + // permission yet (ask for it) or they've granted it but the browser + // returned no sites (new profile, cleared history). We surface copy for + // both — "Grant access" is wrong when the user already granted and just + // has an empty history. + const autoPermissionGranted = + hasCheckedTopSitesPermission && topSites !== undefined; + const showAutoEmptyState = isAuto && visibleShortcuts.length === 0; + const showAutoPermissionCta = showAutoEmptyState && !autoPermissionGranted; + const showAutoNoHistoryMessage = showAutoEmptyState && autoPermissionGranted; + + // Controlled open state so the trigger stays visible while the menu is + // open even when the user hovers *into* the floating menu content. + const [menuOpen, setMenuOpen] = useState(false); + + // Force the trigger visible in these cases so users aren't trapped: + // - the menu is already open (don't yank the trigger mid-hover) + // - the auto-mode empty state is showing (no tiles to hover, only options) + // - there's literally nothing else in the row (no "+" tile, no tiles) + const forceShowMenuButton = + menuOpen || + showAutoEmptyState || + (visibleShortcuts.length === 0 && (isAuto || !manager.canAdd)); + + return ( +