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/ShortcutGetStarted.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx index 1bd59277b97..f5c6243b9cc 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutGetStarted.tsx @@ -76,18 +76,18 @@ export const ShortcutGetStarted = ({
diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx new file mode 100644 index 00000000000..b6cc11fbf25 --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutImportFlow.tsx @@ -0,0 +1,200 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { useShortcuts } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; +import { MostVisitedSitesPermissionContent } from '@dailydotdev/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent'; +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 { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { + type ImportSource, + MAX_SHORTCUTS, +} from '@dailydotdev/shared/src/features/shortcuts/types'; + +interface PermissionModalProps { + onGrant: () => Promise; + onClose: () => void; +} + +function TopSitesPermissionModal({ + onGrant, + onClose, +}: PermissionModalProps): ReactElement { + return ( + + + + Show most visited sites + + + + + ); +} + +function BookmarksPermissionModal({ + onGrant, + onClose, +}: PermissionModalProps): ReactElement { + return ( + + + + 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. + + + + + + + ); +} + +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(); + const handledRef = useRef(null); + + const closeImportFlow = useCallback( + () => setShowImportSource?.(null), + [setShowImportSource], + ); + const isTopSitesImport = showImportSource === 'topSites'; + const hasCheckedPermission = isTopSitesImport + ? hasCheckedTopSitesPermission + : hasCheckedBookmarksPermission; + const items = ( + isTopSitesImport + ? topSites?.map((site) => ({ url: site.url })) + : bookmarks?.map((bookmark) => ({ + url: bookmark.url, + title: bookmark.title, + })) + ) as Array<{ url: string; title?: string }> | undefined; + const askPermission = isTopSitesImport + ? askTopSitesPermission + : askBookmarksPermission; + const emptyToast = isTopSitesImport + ? 'No top sites yet. Visit some sites and try again.' + : 'Your bookmarks bar is empty. Add some bookmarks and try again.'; + + useEffect(() => { + if (!showImportSource) { + handledRef.current = null; + return; + } + + if (!hasCheckedPermission || items === undefined) { + return; + } + + if (handledRef.current === showImportSource) { + return; + } + handledRef.current = showImportSource; + + const capacity = Math.max(0, MAX_SHORTCUTS - (customLinks?.length ?? 0)); + if (items.length === 0) { + displayToast(emptyToast); + closeImportFlow(); + return; + } + + if (capacity === 0) { + displayToast( + `You already have ${MAX_SHORTCUTS} shortcuts. Remove some to import more.`, + ); + closeImportFlow(); + return; + } + + openModal({ + type: LazyModal.ImportPicker, + props: { + source: showImportSource, + items, + returnTo: returnToAfterImport, + }, + }); + closeImportFlow(); + }, [ + customLinks, + displayToast, + emptyToast, + hasCheckedPermission, + items, + closeImportFlow, + openModal, + returnToAfterImport, + showImportSource, + ]); + + if (!showImportSource) { + return null; + } + + if (!hasCheckedPermission || items !== undefined) { + return null; + } + + const handleGrant = async () => { + const granted = await askPermission(); + if (!granted) { + closeImportFlow(); + } + }; + + if (isTopSitesImport) { + return ( + + ); + } + + return ( + + ); +} 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..0bd68649ac9 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.tsx @@ -13,16 +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 { useShortcutsManager } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsManager'; +import { useShortcutsMigration } from '@dailydotdev/shared/src/features/shortcuts/hooks/useShortcutsMigration'; +import { useIsShortcutsHubEnabled } from '@dailydotdev/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled'; 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 { +}: ShortcutLinksProps): ReactElement | null { const { openModal } = useLazyModal(); const { showTopSites, toggleShowTopSites, updateCustomLinks } = useSettingsContext(); @@ -95,7 +100,7 @@ export default function ShortcutLinks({ }; if (!showTopSites) { - return <>; + return null; } return ( @@ -111,7 +116,7 @@ export default function ShortcutLinks({ {...{ onLinkClick, onOptionsOpen, - shortcutLinks, + shortcutLinks: shortcutLinks ?? [], shouldUseListFeedLayout, toggleShowTopSites, onReorder, @@ -123,3 +128,53 @@ export default function ShortcutLinks({ ); } + +function NewShortcutLinks({ + shouldUseListFeedLayout, +}: ShortcutLinksProps): ReactElement | null { + const { showTopSites, toggleShowTopSites, flags } = useSettingsContext(); + const manager = useShortcutsManager(); + const { openModal } = useLazyModal(); + useShortcutsMigration(); + + if (!showTopSites) { + return null; + } + + // Onboarding is only shown for manual-mode users with no shortcuts yet — + // auto mode handles its own empty state (permission CTA / no-history copy) + // inside the hub. + const mode = flags?.shortcutsMode ?? 'manual'; + const showOnboarding = mode === 'manual' && manager.shortcuts.length === 0; + + if (showOnboarding) { + return ( + <> + + openModal({ type: LazyModal.ShortcutsManage }) + } + /> + + + ); + } + + return ( + <> + + + + ); +} + +export default function ShortcutLinks(props: ShortcutLinksProps): ReactElement { + const hubEnabled = useIsShortcutsHubEnabled(); + + if (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..b401ae80cc2 --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHub.tsx @@ -0,0 +1,297 @@ +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 { + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +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 { useHiddenTopSites } from '@dailydotdev/shared/src/features/shortcuts/hooks/useHiddenTopSites'; +import { + useDragClickGuard, + DRAG_ACTIVATION_DISTANCE_PX, +} from '@dailydotdev/shared/src/features/shortcuts/hooks/useDragClickGuard'; +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'; +import { useManualShortcutsRow } from '@dailydotdev/shared/src/features/shortcuts/hooks/useManualShortcutsRow'; +import { ShortcutLinksHubAutoState } from './ShortcutLinksHubAutoState'; +import { ShortcutLinksHubMenu } from './ShortcutLinksHubMenu'; + +interface ShortcutLinksHubProps { + shouldUseListFeedLayout: boolean; +} + +export function ShortcutLinksHub({ + shouldUseListFeedLayout, +}: ShortcutLinksHubProps): ReactElement { + const { openModal } = useLazyModal(); + const { toggleShowTopSites, showTopSites, flags, updateFlag } = + useSettingsContext(); + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const manualRow = useManualShortcutsRow(); + const { + hidden: hiddenTopSites, + hide: hideTopSite, + unhide: unhideTopSite, + } = useHiddenTopSites(); + const { + topSites, + hasCheckedPermission: hasCheckedTopSitesPermission, + askTopSitesPermission, + } = useShortcuts(); + + // Default manual so existing users keep their curated lists; auto is opt-in + // via the overflow menu. + const mode: ShortcutsMode = flags?.shortcutsMode ?? 'manual'; + const isAuto = mode === 'auto'; + const shortcutSource = isAuto + ? ShortcutsSourceType.Browser + : ShortcutsSourceType.Custom; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE_PX }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // Drops outside the toolbar can synthesize a stray `click` on the tile + // that React's root listener doesn't see; `useDragClickGuard` swallows it + // at document capture so the shortcut doesn't navigate mid-drag. + const { armGuard: armDragSuppression, onClickCapture: suppressClickCapture } = + useDragClickGuard(); + + // Cancel native HTML5 drag at the toolbar root — prevents a stray child + // (or a browser ignoring `draggable={false}`) from kicking off a URL drag + // that navigates the tab on drop. + 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: shortcutSource }), + }); + }, [logEvent, showTopSites, mode, shortcutSource]); + + const [reorderAnnouncement, setReorderAnnouncement] = useState(''); + + 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 visibleShortcuts = isAuto ? autoShortcuts : manualRow.shortcuts; + + const handleDragEnd = (event: DragEndEvent) => { + armDragSuppression(); + if (isAuto) { + return; + } + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + const moved = manualRow.reorderShortcuts( + active.id as string, + over.id as string, + ); + if (!moved) { + return; + } + const label = moved?.name || moved?.url || 'Shortcut'; + setReorderAnnouncement( + `Moved ${label} to position ${ + visibleShortcuts.findIndex((shortcut) => shortcut.url === over.id) + 1 + } of ${manualRow.shortcuts.length}`, + ); + }; + + const onLinkClick = () => + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ source: shortcutSource }), + }); + + // We can't delete the site from the browser's history, so we remember + // dismissed URLs locally and offer an Undo toast. + const onHideTopSite = (shortcut: Shortcut) => { + hideTopSite(shortcut.url); + const label = shortcut.name || shortcut.url; + displayToast(`Hidden ${label}`, { + action: { + copy: 'Undo', + onClick: () => unhideTopSite(shortcut.url), + }, + }); + }; + + const onManage = () => openModal({ type: LazyModal.ShortcutsManage }); + + // If permission is declined (or revoked since last boot), flip back to + // manual so the user isn't stranded with an empty auto row and no way out. + const switchToAuto = async () => { + await updateFlag('shortcutsMode', 'auto'); + if (!hasCheckedTopSitesPermission || topSites === undefined) { + const granted = await askTopSitesPermission(); + if (!granted) { + await updateFlag('shortcutsMode', 'manual'); + } + } + }; + + const switchToManual = () => updateFlag('shortcutsMode', 'manual'); + + const toggleSourceMode = () => { + if (isAuto) { + switchToManual(); + } else { + switchToAuto(); + } + }; + + // Two auto-mode empty shapes: permission not granted (ask) vs granted but + // no history (new profile / cleared) — we need distinct copy for each. + const autoPermissionGranted = + hasCheckedTopSitesPermission && topSites !== undefined; + const showAutoEmptyState = isAuto && visibleShortcuts.length === 0; + const showAutoPermissionCta = showAutoEmptyState && !autoPermissionGranted; + const showAutoNoHistoryMessage = showAutoEmptyState && autoPermissionGranted; + + const [menuOpen, setMenuOpen] = useState(false); + + // Force the overflow trigger visible when the user would otherwise be + // trapped: menu open, auto-mode empty state, or row with no tiles at all. + const forceShowMenuButton = + menuOpen || + showAutoEmptyState || + (visibleShortcuts.length === 0 && (isAuto || !manualRow.canAdd)); + + return ( +
+ + s.url)} + strategy={horizontalListSortingStrategy} + > + {visibleShortcuts.map((shortcut) => ( + + ))} + + + {!isAuto && manualRow.canAdd && ( + + )} + + + {reorderAnnouncement} + + +
+ ); +} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHubAutoState.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHubAutoState.tsx new file mode 100644 index 00000000000..ad68d5d59df --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHubAutoState.tsx @@ -0,0 +1,37 @@ +import type { ReactElement } from 'react'; +import React from 'react'; + +interface ShortcutLinksHubAutoStateProps { + showPermissionCta: boolean; + showNoHistoryMessage: boolean; + onAskPermission: () => void | Promise; +} + +export function ShortcutLinksHubAutoState({ + showPermissionCta, + showNoHistoryMessage, + onAskPermission, +}: ShortcutLinksHubAutoStateProps): ReactElement | null { + if (!showPermissionCta && !showNoHistoryMessage) { + return null; + } + + return ( + <> + {showPermissionCta && ( + + )} + {showNoHistoryMessage && ( + + Nothing visited yet — check back after browsing a few sites + + )} + + ); +} diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHubMenu.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHubMenu.tsx new file mode 100644 index 00000000000..aab8a2bbbbc --- /dev/null +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinksHubMenu.tsx @@ -0,0 +1,123 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +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 type { ShortcutsAppearance } from '@dailydotdev/shared/src/features/shortcuts/types'; + +interface ShortcutLinksHubMenuProps { + isAuto: boolean; + appearance: ShortcutsAppearance; + forceShowMenuButton: boolean; + menuOpen: boolean; + onOpenChange: (open: boolean) => void; + onToggleSourceMode: () => void; + onManage: () => void; + onHideShortcuts: () => void; +} + +interface SourceModeToggleItemProps { + isAuto: boolean; + onToggle: () => void; +} + +function SourceModeToggleItem({ + isAuto, + onToggle, +}: SourceModeToggleItemProps): ReactElement { + return ( + { + event.preventDefault(); + onToggle(); + }} + > + + + Most visited sites + + + + ); +} + +export function ShortcutLinksHubMenu({ + isAuto, + appearance, + forceShowMenuButton, + menuOpen, + onOpenChange, + onToggleSourceMode, + onManage, + onHideShortcuts, +}: ShortcutLinksHubMenuProps): ReactElement { + const menuOptions = [ + { + icon: , + label: 'Manage shortcuts…', + action: onManage, + }, + { + icon: , + label: 'Hide shortcuts', + action: onHideShortcuts, + }, + ]; + + return ( + + + + ); + } + + if (isIconOnly) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx new file mode 100644 index 00000000000..f1d16917d5f --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/ShortcutEditForm.tsx @@ -0,0 +1,394 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import classNames from 'classnames'; +import ControlledTextField from '../../../components/fields/ControlledTextField'; +import { useShortcutsManager } from '../hooks/useShortcutsManager'; +import type { Shortcut } from '../types'; +import { isValidHttpUrl, withHttps } from '../../../lib/links'; +import { CameraIcon, EarthIcon } from '../../../components/icons'; +import { apiUrl } from '../../../lib/config'; +import { + allowedContentImage, + imageSizeLimitMB, + uploadContentImage, +} from '../../../graphql/posts'; +import { useFileInput } from '../../../hooks/utils/useFileInput'; +import { useToastNotification } from '../../../hooks/useToastNotification'; + +const schema = z.object({ + name: z + .string() + .max(40, 'Name must be 40 characters or less') + .optional() + .or(z.literal('')), + url: z + .string() + .min(1, 'URL is required') + .refine( + (value) => isValidHttpUrl(withHttps(value)), + 'Must be a valid HTTP/S URL', + ), + iconUrl: z + .string() + .optional() + .refine( + (value) => !value || isValidHttpUrl(withHttps(value)), + 'Must be a valid URL', + ), +}); + +type FormValues = z.infer; + +export type ShortcutEditFormState = { + isSubmitting: boolean; + isUploading: boolean; +}; + +export type ShortcutEditFormProps = { + mode: 'add' | 'edit'; + shortcut?: Shortcut; + formId?: string; + onDone: () => void; + onStateChange?: (state: ShortcutEditFormState) => void; +}; + +// Reused by both `ShortcutEditModal` (standalone) and `ShortcutsManageModal` +// (inline). The parent owns the action buttons and binds them to this form +// via `formId` + `onStateChange` — the modal places them in `Modal.Footer`, +// the inline version renders them below the form. +export function ShortcutEditForm({ + mode, + shortcut, + formId = 'shortcut-edit-form', + onDone, + onStateChange, +}: ShortcutEditFormProps): ReactElement { + const manager = useShortcutsManager(); + const { displayToast } = useToastNotification(); + + const [isUploading, setIsUploading] = useState(false); + const [showUrlInput, setShowUrlInput] = useState(false); + const methods = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: shortcut?.name ?? '', + url: shortcut?.url ?? '', + iconUrl: shortcut?.iconUrl ?? '', + }, + mode: 'onBlur', + }); + + const { + handleSubmit, + watch, + setError, + clearErrors, + setValue, + formState: { isSubmitting }, + } = methods; + + // Only notify the parent when either boolean actually flips, otherwise + // every keystroke would re-invoke `onStateChange`. + const lastReportedRef = useRef(null); + useEffect(() => { + const next = { isSubmitting, isUploading }; + const prev = lastReportedRef.current; + if ( + prev && + prev.isSubmitting === next.isSubmitting && + prev.isUploading === next.isUploading + ) { + return; + } + lastReportedRef.current = next; + onStateChange?.(next); + }, [isSubmitting, isUploading, onStateChange]); + + const fileInputRef = useRef(null); + const [faviconFailed, setFaviconFailed] = useState(false); + const [customIconFailed, setCustomIconFailed] = useState(false); + const [isDropTarget, setIsDropTarget] = useState(false); + // 250ms debounce on favicon requests while the user is typing. + const [debouncedUrl, setDebouncedUrl] = useState(shortcut?.url ?? ''); + + const handleIconBase64 = async (base64: string, file: File) => { + clearErrors('iconUrl'); + setValue('iconUrl', base64, { shouldDirty: true }); + setIsUploading(true); + + try { + const uploadedUrl = await uploadContentImage(file); + setValue('iconUrl', uploadedUrl, { shouldDirty: true }); + } catch (error) { + const message = (error as Error)?.message ?? 'Failed to upload the image'; + setError('iconUrl', { message }); + displayToast(message); + setValue('iconUrl', shortcut?.iconUrl ?? '', { shouldDirty: true }); + } finally { + setIsUploading(false); + } + }; + + const { onFileChange } = useFileInput({ + limitMb: imageSizeLimitMB, + acceptedTypes: allowedContentImage, + onChange: handleIconBase64, + }); + + const values = watch(); + + useEffect(() => { + setFaviconFailed(false); + const handle = setTimeout(() => { + setDebouncedUrl(values.url ?? ''); + }, 250); + return () => clearTimeout(handle); + }, [values.url]); + + useEffect(() => { + setCustomIconFailed(false); + }, [values.iconUrl]); + + const hasCustomIcon = !!values.iconUrl && !customIconFailed; + const urlCandidate = debouncedUrl ? withHttps(debouncedUrl) : ''; + const canShowFavicon = + !hasCustomIcon && !faviconFailed && isValidHttpUrl(urlCandidate); + const faviconSrc = canShowFavicon + ? `${apiUrl}/icon?url=${encodeURIComponent(urlCandidate)}&size=96` + : null; + + const openFilePicker = () => fileInputRef.current?.click(); + const clearCustomIcon = () => { + clearErrors('iconUrl'); + setValue('iconUrl', '', { shouldDirty: true }); + }; + + const handleAvatarDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + if (event.dataTransfer.types.includes('Files')) { + setIsDropTarget(true); + } + }; + + const handleAvatarDragOver = (event: React.DragEvent) => { + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.dataTransfer.dropEffect = 'copy'; + }; + + const handleAvatarDragLeave = () => setIsDropTarget(false); + + const handleAvatarDrop = (event: React.DragEvent) => { + event.preventDefault(); + setIsDropTarget(false); + const file = Array.from(event.dataTransfer.files || []).find((candidate) => + candidate.type.startsWith('image/'), + ); + if (!file) { + return; + } + onFileChange(file); + }; + + const nameValue = values.name ?? ''; + const nameLen = nameValue.length; + const nameNearCap = nameLen >= 32; + const nameHint = nameLen + ? `${nameLen} / 40 characters` + : 'Up to 40 characters'; + const uploadPrompt = faviconSrc + ? 'Tap or drop to upload' + : 'Tap or drop an image to upload'; + + let iconStatus: ReactElement; + if (isUploading) { + iconStatus = ( + + + Uploading… + + ); + } else if (hasCustomIcon) { + iconStatus = ( + + ); + } else if (customIconFailed) { + iconStatus = ( + + Couldn't load that image. Showing favicon instead. + + ); + } else if (isDropTarget) { + iconStatus = ( + + Drop to use this image + + ); + } else { + iconStatus = ( + <> + {uploadPrompt} + + · + + + + ); + } + + const onSubmit = handleSubmit(async (data) => { + const payload = { + url: data.url, + name: data.name || undefined, + iconUrl: data.iconUrl || undefined, + }; + + try { + const result = + mode === 'add' + ? await manager.addShortcut(payload) + : await manager.updateShortcut(shortcut!.url, payload); + + if (result.error) { + setError('url', { message: result.error }); + return; + } + } catch { + // The write is optimistic — local state already reflects the change. + // If the remote mutation rejects, SettingsContext rolls it back and + // will toast its own error. We still finish here so the user isn't + // trapped. + } + + onDone(); + }); + + return ( + +
+
+ + { + onFileChange(event.target.files?.[0] ?? null); + // eslint-disable-next-line no-param-reassign + event.target.value = ''; + }} + /> +
+ {iconStatus} +
+ {showUrlInput && ( +
+ +
+ )} +
+ +
+
+ +
+ {nameHint} +
+
+ +
+
+
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx new file mode 100644 index 00000000000..2be380ca966 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/ShortcutTile.tsx @@ -0,0 +1,425 @@ +import type { + DragEvent as ReactDragEvent, + KeyboardEvent, + MouseEvent, + PointerEvent as ReactPointerEvent, + ReactElement, +} from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuOptions, + DropdownMenuTrigger, +} from '../../../components/dropdown/DropdownMenu'; +import { + EditIcon, + MenuIcon, + MiniCloseIcon, + TrashIcon, +} from '../../../components/icons'; +import { IconSize } from '../../../components/Icon'; +import { MenuIcon as WrappingMenuIcon } from '../../../components/MenuIcon'; +import { combinedClicks } from '../../../lib/click'; +import { apiUrl } from '../../../lib/config'; +import { getDomainFromUrl } from '../../../lib/links'; +import { + DRAG_ACTIVATION_DISTANCE_SQ_PX, + POST_DRAG_SUPPRESSION_MS, +} from '../hooks/useDragClickGuard'; +import type { Shortcut, ShortcutsAppearance } from '../types'; + +const pixelRatio = + typeof globalThis?.window === 'undefined' + ? 1 + : globalThis.window.devicePixelRatio ?? 1; +const iconSize = Math.round(24 * pixelRatio); + +const letterChipClasses = [ + 'bg-accent-burger-bolder text-white', + 'bg-accent-cheese-bolder text-black', + 'bg-accent-avocado-bolder text-white', + 'bg-accent-bacon-bolder text-white', + 'bg-accent-blueCheese-bolder text-white', + 'bg-accent-cabbage-bolder text-white', +] as const; + +const hashString = (value: string): number => { + let hash = 0; + + for (let index = 0; index < value.length; index += 1) { + // eslint-disable-next-line no-bitwise + hash = (hash << 5) - hash + value.charCodeAt(index); + // eslint-disable-next-line no-bitwise + hash |= 0; + } + + return Math.abs(hash); +}; + +const getLetterChipClass = (seed: string): string => + letterChipClasses[hashString(seed) % letterChipClasses.length]; + +interface LetterChipProps { + name: string; + seed: string; + size?: 'sm' | 'md' | 'lg'; +} + +function LetterChip({ + name, + seed, + size = 'md', +}: LetterChipProps): ReactElement { + const letter = (name || '?').charAt(0).toUpperCase(); + const sizeClassMap: Record<'sm' | 'md' | 'lg', string> = { + lg: 'size-10 text-lg', + sm: 'size-6 text-xs', + md: 'size-8 text-sm', + }; + const sizeClass = sizeClassMap[size]; + return ( + + {letter} + + ); +} + +interface ShortcutTileProps { + shortcut: Shortcut; + appearance?: ShortcutsAppearance; + draggable?: boolean; + onClick?: () => void; + onEdit?: (shortcut: Shortcut) => void; + onRemove?: (shortcut: Shortcut) => void; + removeLabel?: string; + className?: string; +} + +export function ShortcutTile({ + shortcut, + appearance = 'tile', + draggable = true, + onClick, + onEdit, + onRemove, + removeLabel = 'Remove', + className, +}: ShortcutTileProps): ReactElement { + const { url, name, iconUrl } = shortcut; + const label = name || getDomainFromUrl(url); + const [iconBroken, setIconBroken] = useState(false); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: url, disabled: !draggable }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const handleKey = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + if (onClick) { + onClick(); + } + }, + [onClick], + ); + + const pointerOriginRef = useRef<{ x: number; y: number } | null>(null); + + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + pointerOriginRef.current = { x: event.clientX, y: event.clientY }; + }, + [], + ); + + const didPointerTravel = useCallback( + (event: MouseEvent): boolean => { + const origin = pointerOriginRef.current; + pointerOriginRef.current = null; + if (!origin) { + return false; + } + const dx = event.clientX - origin.x; + const dy = event.clientY - origin.y; + return dx * dx + dy * dy >= DRAG_ACTIVATION_DISTANCE_SQ_PX; + }, + [], + ); + + // `isDragging` flips back to false before the browser fires the stray + // post-drag `click`, and that click can land on a *sibling* tile (dnd-kit + // reorders mid-drag) with no recorded pointer origin. The short post-drag + // window catches both cases. + const justDraggedRef = useRef(false); + const dragWasActiveRef = useRef(false); + useEffect(() => { + if (isDragging) { + dragWasActiveRef.current = true; + justDraggedRef.current = true; + return undefined; + } + if (!dragWasActiveRef.current) { + return undefined; + } + dragWasActiveRef.current = false; + const timer = window.setTimeout(() => { + justDraggedRef.current = false; + }, POST_DRAG_SUPPRESSION_MS); + return () => window.clearTimeout(timer); + }, [isDragging]); + + const handleAnchorClick = useCallback( + (event: MouseEvent) => { + if (isDragging || justDraggedRef.current || didPointerTravel(event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onClick?.(); + }, + [didPointerTravel, isDragging, onClick], + ); + + const finalIconSrc = + !iconBroken && iconUrl + ? iconUrl + : `${apiUrl}/icon?url=${encodeURIComponent(url)}&size=${iconSize}`; + + const handleIconError = () => setIconBroken(true); + + const shouldShowFavicon = !iconBroken; + + const stop = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const useQuickRemove = !!onRemove && !onEdit; + + const menuOptions = useQuickRemove + ? [] + : [ + ...(onEdit + ? [ + { + icon: , + label: 'Edit', + action: () => onEdit(shortcut), + }, + ] + : []), + ...(onRemove + ? [ + { + icon: , + label: removeLabel, + action: () => onRemove(shortcut), + }, + ] + : []), + ]; + + const dragHandleProps = draggable ? { ...attributes, ...listeners } : {}; + + // The browser starts a native HTML5 URL-drag on `
` / `` before + // dnd-kit's pointer threshold fires. Dropping that URL outside any drop + // zone navigates the tab — kill `dragstart` at the tile root. + const suppressNativeDrag = useCallback((event: ReactDragEvent) => { + event.preventDefault(); + }, []); + + const isChip = appearance === 'chip'; + const isIconOnly = appearance === 'icon'; + + const iconContent = shouldShowFavicon ? ( + + ) : ( + + ); + + // `draggable={false}` belt to the `onDragStart` preventDefault suspenders + // — Chrome starts a URL drag on mousedown before React's handler runs. + const anchorCommon = { + href: url, + rel: 'noopener noreferrer', + draggable: false, + onPointerDown: handlePointerDown, + onKeyDown: handleKey, + 'aria-label': label, + }; + + // Override the anchor's default `cursor: pointer` so the whole tile reads + // as draggable. `pointer-events-none` during drag is a last-resort shield + // against post-drop click handlers. + const anchorCursorClass = draggable + ? classNames( + 'cursor-grab active:cursor-grabbing', + isDragging && 'pointer-events-none', + ) + : ''; + + let appearanceContainerClass: string; + if (isChip) { + appearanceContainerClass = + 'flex h-9 max-w-[12.5rem] items-center gap-2 rounded-10 bg-surface-float pl-2 pr-2 focus-within:bg-background-default hover:bg-background-default'; + } else if (isIconOnly) { + appearanceContainerClass = + 'flex size-12 items-center justify-center rounded-12 focus-within:bg-surface-float hover:bg-surface-float'; + } else { + appearanceContainerClass = + 'flex w-[4.75rem] flex-col items-center rounded-14 p-2 focus-within:bg-surface-float hover:bg-surface-float'; + } + const containerClass = classNames( + 'group relative outline-none transition-colors duration-150 ease-out motion-reduce:transition-none', + appearanceContainerClass, + draggable && 'cursor-grab active:cursor-grabbing', + isDragging && + 'z-10 rotate-[-2deg] bg-surface-float shadow-2 motion-reduce:rotate-0', + className, + ); + + let actionBtnPositionClass: string; + if (isChip) { + actionBtnPositionClass = 'absolute -right-1 -top-1'; + } else { + actionBtnPositionClass = 'absolute right-0.5 top-0.5'; + } + + return ( +
+ {/* eslint-disable-next-line no-nested-ternary */} + {isChip ? ( + // CHIP: single pill, favicon on the left inside the pill, text right. + + + {iconContent} + + + {label} + + + ) : isIconOnly ? ( + // ICON ONLY: just the favicon box, no label. + + {iconContent} + + ) : ( + // TILE: favicon square + label under (default Chrome new-tab style). + <> + + {iconContent} + + + {label} + + + )} + + {useQuickRemove && ( + + )} + + {menuOptions.length > 0 && ( + + + + + {/* Tile menu only carries 1–2 short labels (Edit / Remove or + Hide), so the default 256px action width feels enormous next + to a 76px tile. min-w-0 + a sensible 7rem floor lets it size + to its content while staying tappable on touch. */} + + + + + )} +
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx new file mode 100644 index 00000000000..73bb64bf301 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/WebappShortcutsRow.tsx @@ -0,0 +1,155 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } 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 { + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, ShortcutsSourceType, TargetType } from '../../../lib/log'; +import { ShortcutTile } from './ShortcutTile'; +import { AddShortcutTile } from './AddShortcutTile'; +import { + useDragClickGuard, + DRAG_ACTIVATION_DISTANCE_PX, +} from '../hooks/useDragClickGuard'; +import { DEFAULT_SHORTCUTS_APPEARANCE } from '../types'; +import type { ShortcutsAppearance } from '../types'; +import { useManualShortcutsRow } from '../hooks/useManualShortcutsRow'; + +interface WebappShortcutsRowProps { + className?: string; +} + +// Shares `ShortcutTile` / `useShortcutsManager` with the extension hub so +// edits and reorders stay in sync. Auto mode is ignored — we don't have +// topSites permission on the webapp and live browser history doesn't +// translate across devices anyway. +export function WebappShortcutsRow({ + className, +}: WebappShortcutsRowProps): ReactElement | null { + const { flags, showTopSites } = useSettingsContext(); + const { logEvent } = useLogContext(); + const manualRow = useManualShortcutsRow(); + const { shortcuts } = manualRow; + + const enabled = flags?.showShortcutsOnWebapp ?? false; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + + const loggedRef = useRef(false); + useEffect(() => { + if (loggedRef.current) { + return; + } + if (!enabled || !showTopSites || shortcuts.length === 0) { + return; + } + loggedRef.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.Shortcuts, + extra: JSON.stringify({ + source: ShortcutsSourceType.Custom, + surface: 'webapp', + }), + }); + }, [enabled, showTopSites, shortcuts.length, logEvent]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE_PX }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + // Same drag-guard plumbing as `ShortcutLinksHub` — see that file for the + // full rationale on post-drag click + native URL-drag suppression. + const { armGuard: armDragSuppression, onClickCapture: suppressClickCapture } = + useDragClickGuard(); + + const suppressNativeDragCapture = (event: React.DragEvent) => { + event.preventDefault(); + }; + + const handleDragEnd = (event: DragEndEvent) => { + armDragSuppression(); + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + manualRow.reorderShortcuts(active.id as string, over.id as string); + }; + + if (!enabled || !showTopSites) { + return null; + } + if (shortcuts.length === 0 && !manualRow.canAdd) { + return null; + } + + return ( +
+ + s.url)} + strategy={horizontalListSortingStrategy} + > + {shortcuts.map((shortcut) => ( + + ))} + + + {manualRow.canAdd && ( + + )} +
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx new file mode 100644 index 00000000000..cacef0e6460 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ImportPickerModal.tsx @@ -0,0 +1,329 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonVariant } from '../../../../components/buttons/Button'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { Justify } from '../../../../components/utilities'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { BookmarkIcon, SitesIcon, VIcon } from '../../../../components/icons'; +import { IconSize } from '../../../../components/Icon'; +import { apiUrl } from '../../../../lib/config'; +import { getDomainFromUrl } from '../../../../lib/links'; +import { MAX_SHORTCUTS } from '../../types'; +import type { ImportSource } from '../../types'; +import { useShortcutsManager } from '../../hooks/useShortcutsManager'; +import { useSettingsContext } from '../../../../contexts/SettingsContext'; +import { useToastNotification } from '../../../../hooks/useToastNotification'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import type { LazyModal } from '../../../../components/modals/common/types'; +import { getShortcutDedupKey } from '../../lib/getShortcutDedupKey'; + +export interface ImportPickerItem { + url: string; + title?: string; +} + +export interface ImportPickerModalProps extends ModalProps { + source: ImportSource; + items: ImportPickerItem[]; + onImported?: (result: { imported: number; skipped: number }) => void; + // When set, Cancel reopens this modal instead of dismissing the stack — + // lets the picker be invoked from Manage without losing the user's place. + returnTo?: LazyModal.ShortcutsManage; +} + +// The icon proxy falls back to a blurry generic globe for unknown sites; +// swap that for a letter chip instead. +function FaviconOrLetter({ + url, + label, +}: { + url: string; + label: string; +}): ReactElement { + const [failed, setFailed] = useState(false); + const letter = (label || '?').charAt(0).toUpperCase(); + + if (failed) { + return ( + + {letter} + + ); + } + + return ( + + setFailed(true)} + className="size-6 rounded-4" + /> + + ); +} + +export default function ImportPickerModal({ + source, + items, + onImported, + returnTo, + ...props +}: ImportPickerModalProps): ReactElement { + const { customLinks } = useSettingsContext(); + const manager = useShortcutsManager(); + const { displayToast } = useToastNotification(); + const { openModal, closeModal } = useLazyModal(); + + const dedupedItems = useMemo(() => { + const seen = new Set(); + + return items.filter((item) => { + const dedupKey = getShortcutDedupKey(item.url) ?? item.url; + if (seen.has(dedupKey)) { + return false; + } + seen.add(dedupKey); + return true; + }); + }, [items]); + + const close = () => { + closeModal(); + props.onRequestClose?.(undefined as never); + }; + + const handleCancel = () => { + if (returnTo) { + openModal({ type: returnTo }); + return; + } + close(); + }; + + const alreadyUsed = customLinks?.length ?? 0; + const capacity = Math.max(0, MAX_SHORTCUTS - alreadyUsed); + const [checked, setChecked] = useState>(() => { + const state: Record = {}; + dedupedItems.slice(0, capacity).forEach((item) => { + state[item.url] = true; + }); + return state; + }); + + const selected = useMemo( + () => dedupedItems.filter((item) => checked[item.url]), + [checked, dedupedItems], + ); + + const selectableCount = Math.min(dedupedItems.length, capacity); + const atCapacity = selected.length >= capacity; + + const toggle = (url: string) => + setChecked((prev) => { + const next = !prev[url]; + if (next && !prev[url] && selected.length >= capacity) { + return prev; + } + return { ...prev, [url]: next }; + }); + + const allSelected = selectableCount > 0 && selected.length >= selectableCount; + const toggleAll = () => { + if (allSelected) { + setChecked({}); + return; + } + const next: Record = {}; + dedupedItems.slice(0, capacity).forEach((item) => { + next[item.url] = true; + }); + setChecked(next); + }; + + const handleImport = async () => { + const result = await manager.importFrom(source, selected); + onImported?.(result); + const noun = source === 'bookmarks' ? 'bookmarks' : 'sites'; + // Every selection ended up as a duplicate / at-cap skip. Reporting + // "Imported 0" would read like a bug — say what actually happened. + if (result.imported === 0) { + displayToast( + result.skipped > 0 + ? `Nothing imported — ${result.skipped} ${noun} already in shortcuts` + : `Nothing to import`, + ); + } else { + displayToast( + `Imported ${result.imported} ${noun} to shortcuts${ + result.skipped ? `. ${result.skipped} skipped.` : '' + }`, + ); + } + close(); + }; + + const isBookmarks = source === 'bookmarks'; + const title = isBookmarks ? 'Import bookmarks' : 'Import most visited'; + const sourceCopy = isBookmarks + ? `Pick the ones you want. Your bookmarks stay untouched. ${dedupedItems.length} available.` + : `Pick the ones you want. Snapshot from your browser. ${ + dedupedItems.length + } site${dedupedItems.length === 1 ? '' : 's'} available.`; + + const slotsLeft = Math.max(0, capacity - selected.length); + + return ( + + + + {title} + + + +

{sourceCopy}

+
+
+ + {selected.length === 0 + ? 'Nothing picked yet' + : `${selected.length} picked`} + + + {atCapacity + ? `You've hit the ${MAX_SHORTCUTS}-shortcut limit` + : `${slotsLeft} of ${MAX_SHORTCUTS} slot${ + slotsLeft === 1 ? '' : 's' + } left${ + alreadyUsed > 0 ? ` · ${alreadyUsed} already saved` : '' + }`} + +
+ +
+ + {dedupedItems.length === 0 ? ( +
+ + {isBookmarks ? ( + + ) : ( + + )} + + + {isBookmarks + ? 'No bookmarks to import' + : 'No browsing history to show'} + + + {isBookmarks + ? 'Add bookmarks to your browser bar, then come back.' + : 'Visit a few sites first. Your browser needs history to suggest from.'} + +
+ ) : ( +
    + {dedupedItems.map((item) => { + const isChecked = !!checked[item.url]; + const atCap = !isChecked && atCapacity; + const label = item.title || getDomainFromUrl(item.url); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ + + + +
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesModal.tsx b/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesModal.tsx index 052d0f973b2..7797248cdee 100644 --- a/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesModal.tsx +++ b/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesModal.tsx @@ -1,12 +1,15 @@ import type { ReactElement } from 'react'; import React from 'react'; -import { LazyImage } from '../../../../components/LazyImage'; -import { Button, ButtonVariant } from '../../../../components/buttons/Button'; import type { ModalProps } from '../../../../components/modals/common/Modal'; import { Modal } from '../../../../components/modals/common/Modal'; -import { Justify } from '../../../../components/utilities'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; import { useShortcutLinks } from '../../hooks/useShortcutLinks'; import { useShortcuts } from '../../contexts/ShortcutsProvider'; +import { MostVisitedSitesPermissionContent } from './MostVisitedSitesPermissionContent'; export function MostVisitedSitesModal({ className, @@ -27,43 +30,23 @@ export function MostVisitedSitesModal({ {...props} onRequestClose={onRequestClose} > - - - Show most visited sites - - To show your most visited sites, your browser will now ask for more - permissions. Once approved, it will be kept locally. - - + + Show most visited sites + + + { + const granted = await askTopSitesBrowserPermission(); + setIsManual(!granted); + if (granted) { + setShowPermissionsModal(false); } - imgAlt="Image of the browser's default home screen" - className="mx-auto my-8 w-full max-w-[22rem] rounded-16" - ratio="45.8%" - eager - /> - - We will never collect your browsing history. We promise. - - - - - + }} + /> ); } diff --git a/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent.tsx b/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent.tsx new file mode 100644 index 00000000000..c3c70b64622 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/MostVisitedSitesPermissionContent.tsx @@ -0,0 +1,43 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { LazyImage } from '../../../../components/LazyImage'; +import { Button, ButtonVariant } from '../../../../components/buttons/Button'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { Justify } from '../../../../components/utilities'; +import { isFirefoxExtension } from '../../../../lib/func'; + +export interface MostVisitedSitesPermissionContentProps { + onGrant: () => void | Promise; + ctaLabel?: string; + footerText?: string; +} + +export function MostVisitedSitesPermissionContent({ + onGrant, + ctaLabel = 'Add the shortcuts', + footerText = 'We will never collect your browsing history. We promise.', +}: MostVisitedSitesPermissionContentProps): ReactElement { + return ( + <> + + + To show your most visited sites, your browser will now ask for more + permissions. Once approved, it will be kept locally. + + + {footerText} + + + + + + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx new file mode 100644 index 00000000000..5e97daa88ee --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutEditModal.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { Justify } from '../../../../components/utilities'; +import type { Shortcut } from '../../types'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import type { ShortcutEditFormState } from '../ShortcutEditForm'; +import { ShortcutEditForm } from '../ShortcutEditForm'; + +type ShortcutEditModalProps = ModalProps & { + mode: 'add' | 'edit'; + shortcut?: Shortcut; + onSubmitted?: () => void; +}; + +const FORM_ID = 'shortcut-edit-form'; + +export default function ShortcutEditModal({ + mode, + shortcut, + onSubmitted, + ...props +}: ShortcutEditModalProps): ReactElement { + const { closeModal } = useLazyModal(); + const [formState, setFormState] = useState({ + isSubmitting: false, + isUploading: false, + }); + const close = () => { + closeModal(); + props.onRequestClose?.(undefined as never); + }; + + return ( + + + + {mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} + + + + { + onSubmitted?.(); + close(); + }} + /> + + + + + + + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageAppearancePicker.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageAppearancePicker.tsx new file mode 100644 index 00000000000..7dab6089484 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageAppearancePicker.tsx @@ -0,0 +1,129 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { VIcon } from '../../../../components/icons'; +import type { ShortcutsAppearance } from '../../types'; +import { SectionHeader } from './ShortcutsManageCommon'; + +export function AppearancePicker({ + value, + onChange, +}: { + value: ShortcutsAppearance; + onChange: (next: ShortcutsAppearance) => void; +}): ReactElement { + const options: Array<{ + id: ShortcutsAppearance; + title: string; + preview: ReactElement; + }> = [ + { + id: 'tile', + title: 'Tile', + preview: ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ), + }, + { + id: 'icon', + title: 'Icon', + preview: ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ ))} +
+ ), + }, + { + id: 'chip', + title: 'Chip', + preview: ( +
+ {[0, 1].map((i) => ( +
+
+
+
+ ))} +
+ ), + }, + ]; + + return ( +
+ + + +
+ {options.map((option) => { + const checked = value === option.id; + + return ( + + ); + })} +
+
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageCommon.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageCommon.tsx new file mode 100644 index 00000000000..0f0aa8e150c --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageCommon.tsx @@ -0,0 +1,38 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../../components/typography/Typography'; + +interface SectionHeaderProps { + title: string; + description?: string; + trailing?: ReactElement; +} + +export function SectionHeader({ + title, + description, + trailing, +}: SectionHeaderProps): ReactElement { + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+ {trailing} +
+ ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx new file mode 100644 index 00000000000..03d76359e1b --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageConnectionsSection.tsx @@ -0,0 +1,300 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { Switch } from '../../../../components/fields/Switch'; +import { + BookmarkIcon, + EarthIcon, + LinkIcon, + RefreshIcon, +} from '../../../../components/icons'; +import { ChromeIcon } from '../../../../components/icons/Browser/Chrome'; +import { SectionHeader } from './ShortcutsManageCommon'; + +function ShortcutsModeOption({ + id, + checked, + onSelect, + title, + description, + trailingBadge, +}: { + id: string; + checked: boolean; + onSelect: () => void; + title: string; + description: string; + trailingBadge?: ReactElement; +}): ReactElement { + return ( + + ); +} + +interface ShortcutsModeSectionProps { + mode: 'manual' | 'auto'; + onSelectMode: (next: 'manual' | 'auto') => void; +} + +export function ShortcutsModeSection({ + mode, + onSelectMode, +}: ShortcutsModeSectionProps): ReactElement { + return ( +
+ + + +
+ onSelectMode('manual')} + title="My shortcuts" + description="Curated by you. Add, edit, and reorder." + /> + onSelectMode('auto')} + title="Most visited sites" + description="Pulled automatically from your browser history." + trailingBadge={} + /> +
+
+ ); +} + +interface ConnectionRowProps { + icon: ReactElement; + label: string; + description: string; + primaryLabel?: string; + onPrimary?: () => void | Promise; + secondaryLabel?: string; + onSecondary?: () => void | Promise; + trailing?: ReactElement; +} + +function ConnectionRow({ + icon, + label, + description, + primaryLabel, + onPrimary, + secondaryLabel, + onSecondary, + trailing, +}: ConnectionRowProps): ReactElement { + return ( +
  • + + {icon} + +
    +

    {label}

    +

    + {description} +

    +
    +
    + {trailing ?? ( + <> + {secondaryLabel && ( + + )} + {primaryLabel && ( + + )} + + )} +
    +
  • + ); +} + +interface AutoConnectionsSectionProps { + topSitesGranted: boolean; + topSitesKnown: boolean; + topSitesCount: number; + hiddenTopSitesCount: number; + onImportTopSites?: () => void; + onAskTopSites?: () => Promise | void; + onRevokeTopSites?: () => Promise | void; + onRestoreHiddenTopSites: () => void; +} + +export function AutoConnectionsSection({ + topSitesGranted, + topSitesKnown, + topSitesCount, + hiddenTopSitesCount, + onImportTopSites, + onAskTopSites, + onRevokeTopSites, + onRestoreHiddenTopSites, +}: AutoConnectionsSectionProps): ReactElement { + return ( +
    + +
      + } + label="Browser access" + description={ + topSitesKnown + ? `${topSitesCount} sites available from your browser.` + : 'Grant access so we can read your most visited sites.' + } + primaryLabel={topSitesGranted ? 'Import' : 'Connect'} + onPrimary={topSitesGranted ? onImportTopSites : onAskTopSites} + secondaryLabel={topSitesGranted ? 'Disconnect' : undefined} + onSecondary={topSitesGranted ? () => onRevokeTopSites?.() : undefined} + /> + {hiddenTopSitesCount > 0 && ( + } + label={`Hidden sites (${hiddenTopSitesCount})`} + description="Restore sites you removed from your Most visited row." + primaryLabel="Restore all" + onPrimary={onRestoreHiddenTopSites} + /> + )} +
    +
    + ); +} + +interface BrowserConnectionsSectionProps { + bookmarksGranted: boolean; + bookmarksCount: number; + bookmarksKnown: boolean; + showOnWebapp: boolean; + onToggleShowOnWebapp: () => void; + onImportBookmarks?: () => void; + onAskBookmarks?: () => void | Promise; + onRevokeBookmarks?: () => void | Promise; +} + +export function BrowserConnectionsSection({ + bookmarksGranted, + bookmarksCount, + bookmarksKnown, + showOnWebapp, + onToggleShowOnWebapp, + onImportBookmarks, + onAskBookmarks, + onRevokeBookmarks, +}: BrowserConnectionsSectionProps): ReactElement { + return ( +
    + +
      + } + label="Bookmarks bar" + description={ + bookmarksKnown + ? `${bookmarksCount} available` + : 'Grant access to import your browser bookmarks.' + } + primaryLabel={bookmarksGranted ? 'Import' : 'Connect'} + onPrimary={bookmarksGranted ? onImportBookmarks : onAskBookmarks} + secondaryLabel={bookmarksGranted ? 'Disconnect' : undefined} + onSecondary={ + bookmarksGranted ? () => onRevokeBookmarks?.() : undefined + } + /> + } + label="Show on daily.dev web app" + description="Mirror these shortcuts across every signed-in browser." + trailing={ + + } + /> +
    +
    + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageEditor.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageEditor.tsx new file mode 100644 index 00000000000..58541f7c899 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageEditor.tsx @@ -0,0 +1,86 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import type { Shortcut } from '../../types'; +import type { ShortcutEditFormState } from '../ShortcutEditForm'; +import { ShortcutEditForm } from '../ShortcutEditForm'; + +const EDIT_FORM_ID = 'shortcut-edit-form-manage'; + +export type ShortcutsManageEditingState = + | { mode: 'add' } + | { mode: 'edit'; shortcut: Shortcut }; + +interface ShortcutsManageEditorProps extends ModalProps { + editing: ShortcutsManageEditingState; + onClose: () => void; +} + +export function ShortcutsManageEditor({ + editing, + onClose, + ...props +}: ShortcutsManageEditorProps): ReactElement { + const [formState, setFormState] = useState({ + isSubmitting: false, + isUploading: false, + }); + + return ( + + + + {editing.mode === 'add' ? 'Add shortcut' : 'Edit shortcut'} + + + + + +
    + + +
    +
    +
    + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageManualSection.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageManualSection.tsx new file mode 100644 index 00000000000..acf28ec1a99 --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageManualSection.tsx @@ -0,0 +1,268 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import type { DragEndEvent } from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../../components/typography/Typography'; +import { + DragIcon, + EditIcon, + PlusIcon, + StarIcon, + TrashIcon, +} from '../../../../components/icons'; +import { apiUrl } from '../../../../lib/config'; +import { getDomainFromUrl } from '../../../../lib/links'; +import { MAX_SHORTCUTS } from '../../types'; +import type { Shortcut } from '../../types'; +import { SectionHeader } from './ShortcutsManageCommon'; + +function CapacityPill({ + used, + max, +}: { + used: number; + max: number; +}): ReactElement { + const remaining = max - used; + let tone = 'bg-surface-float text-text-tertiary'; + + if (used >= max) { + tone = 'bg-overlay-float-ketchup text-accent-ketchup-default'; + } else if (remaining <= 2) { + tone = 'bg-overlay-float-cabbage text-accent-cabbage-default'; + } + + return ( + + {used}/{max} + + ); +} + +function ShortcutRow({ + shortcut, + onEdit, + onRemove, +}: { + shortcut: Shortcut; + onEdit: (shortcut: Shortcut) => void; + onRemove: (shortcut: Shortcut) => void; +}): ReactElement { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: shortcut.url }); + const label = shortcut.name || getDomainFromUrl(shortcut.url); + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
    + + +
    +

    {label}

    +

    + {shortcut.url} +

    +
    +
    +
    +
    + ); +} + +interface ManualShortcutsSectionProps { + shortcuts: Shortcut[]; + canAdd: boolean; + onAdd: () => void; + onEdit: (shortcut: Shortcut) => void; + onRemove: (shortcut: Shortcut) => void; + onReorder: (activeId: string, overId: string) => void; +} + +export function ManualShortcutsSection({ + shortcuts, + canAdd, + onAdd, + onEdit, + onRemove, + onReorder, +}: ManualShortcutsSectionProps): ReactElement { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + if (!over || active.id === over.id) { + return; + } + + onReorder(active.id as string, over.id as string); + }; + + return ( +
    + } + /> + {shortcuts.length === 0 ? ( +
    + + + + + Your shortcuts, your rules + + + Add one manually or import from Connections below. + + +
    + ) : ( +
    + + + shortcut.url)} + strategy={verticalListSortingStrategy} + > + {shortcuts.map((shortcut) => ( + + ))} + + +
    + )} +
    + ); +} diff --git a/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx new file mode 100644 index 00000000000..5e705bf995f --- /dev/null +++ b/packages/shared/src/features/shortcuts/components/modals/ShortcutsManageModal.tsx @@ -0,0 +1,243 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { arrayMove } from '@dnd-kit/sortable'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; +import { Switch } from '../../../../components/fields/Switch'; +import type { ModalProps } from '../../../../components/modals/common/Modal'; +import { Modal } from '../../../../components/modals/common/Modal'; +import { + Typography, + TypographyTag, + TypographyType, +} from '../../../../components/typography/Typography'; +import { useSettingsContext } from '../../../../contexts/SettingsContext'; +import { useLogContext } from '../../../../contexts/LogContext'; +import { useLazyModal } from '../../../../hooks/useLazyModal'; +import { LogEvent, TargetType } from '../../../../lib/log'; +import { LazyModal } from '../../../../components/modals/common/types'; +import { useShortcuts } from '../../contexts/ShortcutsProvider'; +import { useHiddenTopSites } from '../../hooks/useHiddenTopSites'; +import { useShortcutsManager } from '../../hooks/useShortcutsManager'; +import { DEFAULT_SHORTCUTS_APPEARANCE } from '../../types'; +import type { Shortcut, ShortcutsAppearance } from '../../types'; +import { AppearancePicker } from './ShortcutsManageAppearancePicker'; +import { + AutoConnectionsSection, + BrowserConnectionsSection, + ShortcutsModeSection, +} from './ShortcutsManageConnectionsSection'; +import { SectionHeader } from './ShortcutsManageCommon'; +import { ManualShortcutsSection } from './ShortcutsManageManualSection'; +import { + ShortcutsManageEditor, + type ShortcutsManageEditingState, +} from './ShortcutsManageEditor'; + +export default function ShortcutsManageModal(props: ModalProps): ReactElement { + const { logEvent } = useLogContext(); + const { showTopSites, toggleShowTopSites, flags, updateFlag } = + useSettingsContext(); + const manager = useShortcutsManager(); + const { + setShowImportSource, + topSites, + hasCheckedPermission: hasCheckedTopSitesPermission, + askTopSitesPermission, + onRevokePermission, + bookmarks, + hasCheckedBookmarksPermission, + askBookmarksPermission, + revokeBookmarksPermission, + } = useShortcuts(); + const { hidden: hiddenTopSites, restore: restoreHiddenTopSites } = + useHiddenTopSites(); + const { closeModal } = useLazyModal(); + const [editing, setEditing] = useState( + null, + ); + + const logShortcutsEvent = useCallback( + (eventName: LogEvent, extra?: Record) => { + logEvent({ + event_name: eventName, + target_type: TargetType.Shortcuts, + extra: extra ? JSON.stringify(extra) : undefined, + }); + }, + [logEvent], + ); + + useEffect(() => { + logShortcutsEvent(LogEvent.OpenShortcutConfig); + }, [logShortcutsEvent]); + + const close = () => { + closeModal(); + props?.onRequestClose?.(undefined as never); + }; + + const mode = flags?.shortcutsMode ?? 'manual'; + const appearance: ShortcutsAppearance = + flags?.shortcutsAppearance ?? DEFAULT_SHORTCUTS_APPEARANCE; + const showOnWebapp = flags?.showShortcutsOnWebapp ?? false; + + const topSitesGranted = topSites !== undefined; + const topSitesKnown = hasCheckedTopSitesPermission && topSitesGranted; + const bookmarksGranted = bookmarks !== undefined; + const bookmarksKnown = hasCheckedBookmarksPermission && bookmarksGranted; + + const handleSelectMode = async (next: 'manual' | 'auto') => { + if (next === mode) { + return; + } + + await updateFlag('shortcutsMode', next); + logShortcutsEvent(LogEvent.ChangeShortcutsMode, { mode: next }); + + if (next === 'auto' && topSites === undefined) { + await askTopSitesPermission(); + } + }; + + const handleSelectAppearance = (next: ShortcutsAppearance) => { + if (next === appearance) { + return; + } + + updateFlag('shortcutsAppearance', next); + logShortcutsEvent(LogEvent.ChangeShortcutsAppearance, { + appearance: next, + }); + }; + + const handleToggleShowOnWebapp = () => { + const next = !showOnWebapp; + updateFlag('showShortcutsOnWebapp', next); + logShortcutsEvent(LogEvent.ToggleShortcutsOnWebapp, { enabled: next }); + }; + + const handleReorderShortcuts = (activeId: string, overId: string) => { + const urls = manager.shortcuts.map((shortcut) => shortcut.url); + const oldIndex = urls.indexOf(activeId); + const newIndex = urls.indexOf(overId); + + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) { + return; + } + + manager.reorder(arrayMove(urls, oldIndex, newIndex)); + }; + + const handleEditShortcut = (shortcut: Shortcut) => + setEditing({ mode: 'edit', shortcut }); + const handleAddShortcut = () => setEditing({ mode: 'add' }); + + const openTopSitesImport = setShowImportSource + ? () => setShowImportSource('topSites', LazyModal.ShortcutsManage) + : undefined; + + const openBookmarksImport = setShowImportSource + ? () => setShowImportSource('bookmarks', LazyModal.ShortcutsManage) + : undefined; + + if (editing) { + return ( + setEditing(null)} + {...props} + /> + ); + } + + return ( + + + + Shortcuts + + + + +
    + + } + /> + + {showTopSites && ( + + )} + + {showTopSites && mode === 'auto' && ( + + )} + + {mode === 'manual' && ( + manager.removeShortcut(shortcut.url)} + onReorder={handleReorderShortcuts} + /> + )} + + {mode === 'manual' && ( + + )} + + {showTopSites && ( +
    + +
    + )} +
    +
    +
    + ); +} diff --git a/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx b/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx index 933dec44bfd..70c63b79fac 100644 --- a/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx +++ b/packages/shared/src/features/shortcuts/contexts/ShortcutsProvider.tsx @@ -1,9 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { createContextProvider } from '@kickass-coderz/react'; import { useTopSites } from '../hooks/useTopSites'; +import { useBrowserBookmarks } from '../hooks/useBrowserBookmarks'; import { useLogContext } from '../../../contexts/LogContext'; import { LogEvent, TargetType } from '../../../lib/log'; import { useSettingsContext } from '../../../contexts/SettingsContext'; +import type { ImportSource } from '../types'; +import type { LazyModal } from '../../../components/modals/common/types'; const [ShortcutsProvider, useShortcuts] = createContextProvider( () => { @@ -12,6 +15,23 @@ const [ShortcutsProvider, useShortcuts] = createContextProvider( const [isManual, setIsManual] = useState(false); const [showPermissionsModal, setShowPermissionsModal] = useState(false); + const [showImportSource, setShowImportSourceRaw] = + useState(null); + // When the picker was triggered from another modal (e.g. Manage), we + // remember it so the picker's Cancel button can hand control back there + // instead of fully dismissing the flow. Narrowed to ShortcutsManage + // because that's the only prop-less modal we reopen from here. + const [returnToAfterImport, setReturnToAfterImport] = useState< + LazyModal.ShortcutsManage | undefined + >(undefined); + + const setShowImportSource = useCallback( + (source: ImportSource | null, returnTo?: LazyModal.ShortcutsManage) => { + setReturnToAfterImport(source ? returnTo : undefined); + setShowImportSourceRaw(source); + }, + [], + ); const { topSites, @@ -20,6 +40,13 @@ const [ShortcutsProvider, useShortcuts] = createContextProvider( revokePermission, } = useTopSites(); + const { + bookmarks, + hasCheckedPermission: hasCheckedBookmarksPermission, + askBookmarksPermission, + revokeBookmarksPermission, + } = useBrowserBookmarks(); + const onRevokePermission = async () => { await revokePermission(); @@ -52,6 +79,14 @@ const [ShortcutsProvider, useShortcuts] = createContextProvider( onRevokePermission, showPermissionsModal, setShowPermissionsModal, + // New hub state + bookmarks, + hasCheckedBookmarksPermission, + askBookmarksPermission, + revokeBookmarksPermission, + showImportSource, + setShowImportSource, + returnToAfterImport, }; }, { diff --git a/packages/shared/src/features/shortcuts/hooks/useBrowserBookmarks.ts b/packages/shared/src/features/shortcuts/hooks/useBrowserBookmarks.ts new file mode 100644 index 00000000000..ca0fc722b7f --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useBrowserBookmarks.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { Browser, Bookmarks } from 'webextension-polyfill'; +import { checkIsExtension } from '../../../lib/func'; + +export type BrowserBookmark = { + title: string; + url: string; +}; + +// Cross-browser bookmarks-bar folder ids: +// Chrome / Edge / Opera = "1"; Firefox = "toolbar_____" (5 underscores). +const KNOWN_BOOKMARKS_BAR_IDS = ['1', 'toolbar_____']; +const FALLBACK_BAR_TITLES = ['Bookmarks bar', 'Bookmarks Toolbar']; + +const isBookmarksBarNode = (node: Bookmarks.BookmarkTreeNode): boolean => { + if (KNOWN_BOOKMARKS_BAR_IDS.includes(node.id)) { + return true; + } + return !!node.title && FALLBACK_BAR_TITLES.includes(node.title); +}; + +const findBookmarksBar = ( + nodes: Bookmarks.BookmarkTreeNode[] | undefined, +): Bookmarks.BookmarkTreeNode | null => { + if (!nodes) { + return null; + } + return nodes.reduce((found, node) => { + if (found) { + return found; + } + if (isBookmarksBarNode(node)) { + return node; + } + return findBookmarksBar(node.children); + }, null); +}; + +const flattenBar = (bar: Bookmarks.BookmarkTreeNode): BrowserBookmark[] => { + const bookmarks: BrowserBookmark[] = []; + + const walk = (nodes: Bookmarks.BookmarkTreeNode[], depth: number) => { + nodes.forEach((node) => { + if (node.url) { + bookmarks.push({ + title: node.title || node.url, + url: node.url, + }); + return; + } + if (depth === 0) { + if (node.children?.length) { + walk(node.children, depth + 1); + } + } + }); + }; + + walk(bar.children ?? [], 0); + return bookmarks; +}; + +export interface UseBrowserBookmarks { + bookmarks: BrowserBookmark[] | undefined; + hasCheckedPermission: boolean; + askBookmarksPermission: () => Promise; + revokeBookmarksPermission: () => Promise; +} + +export const useBrowserBookmarks = (): UseBrowserBookmarks => { + const [browser, setBrowser] = useState(); + const [bookmarks, setBookmarks] = useState(); + const [hasCheckedPermission, setHasCheckedPermission] = useState(false); + + const getBookmarks = useCallback(async (): Promise => { + if (!browser?.bookmarks) { + setBookmarks(undefined); + setHasCheckedPermission(true); + return; + } + + try { + const tree = await browser.bookmarks.getTree(); + const bar = findBookmarksBar(tree); + if (!bar) { + setBookmarks([]); + } else { + setBookmarks(flattenBar(bar)); + } + } catch (_) { + setBookmarks(undefined); + } + + setHasCheckedPermission(true); + }, [browser]); + + const askBookmarksPermission = useCallback(async (): Promise => { + if (!browser) { + return false; + } + + const granted = await browser.permissions.request({ + permissions: ['bookmarks'], + }); + if (granted) { + await getBookmarks(); + } + return granted; + }, [browser, getBookmarks]); + + const revokeBookmarksPermission = useCallback(async (): Promise => { + if (!browser) { + return; + } + + await browser.permissions.remove({ permissions: ['bookmarks'] }); + setBookmarks(undefined); + }, [browser]); + + useEffect(() => { + if (!checkIsExtension()) { + return; + } + if (!browser) { + import('webextension-polyfill').then((mod) => setBrowser(mod.default)); + } else { + getBookmarks(); + } + }, [browser, getBookmarks]); + + return useMemo( + () => ({ + bookmarks, + hasCheckedPermission, + askBookmarksPermission, + revokeBookmarksPermission, + }), + [ + bookmarks, + hasCheckedPermission, + askBookmarksPermission, + revokeBookmarksPermission, + ], + ); +}; diff --git a/packages/shared/src/features/shortcuts/hooks/useDragClickGuard.ts b/packages/shared/src/features/shortcuts/hooks/useDragClickGuard.ts new file mode 100644 index 00000000000..30592d9a273 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useDragClickGuard.ts @@ -0,0 +1,118 @@ +import type { MouseEvent as ReactMouseEvent } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Pointer distance (px) that promotes a pointerdown→pointerup into a drag + * gesture instead of a click. Shared between dnd-kit's `PointerSensor` + * `activationConstraint` and per-tile `didPointerTravel` calculations so the + * "is this a click or a drag?" threshold agrees across layers. + */ +export const DRAG_ACTIVATION_DISTANCE_PX = 5; + +/** + * Squared distance counterpart of {@link DRAG_ACTIVATION_DISTANCE_PX}, exposed + * so callers can skip a `Math.sqrt` in hot pointer paths by comparing against + * `dx * dx + dy * dy` directly. Kept next to its source value so the two + * cannot drift. + */ +export const DRAG_ACTIVATION_DISTANCE_SQ_PX = + DRAG_ACTIVATION_DISTANCE_PX * DRAG_ACTIVATION_DISTANCE_PX; + +/** + * How long after a drag ends we continue to swallow stray clicks. Chrome + * occasionally fires a second synthesized click when a drag crosses element + * boundaries, and the first click can arrive on a different DOM target than + * the tile the drag started from. 500ms covers both without meaningfully + * blocking a deliberate follow-up click. + */ +export const POST_DRAG_SUPPRESSION_MS = 500; + +/** + * Shared guard for the "drag ended, browser fires a stray click on pointerup, + * the click lands on an `` and navigates the tab" bug that plagues + * dnd-kit sortable rows of anchor tiles. + * + * The previous fix scoped click suppression to the toolbar's `onClickCapture`, + * which only catches clicks whose DOM target is a descendant of the toolbar. + * When the user drags a tile *outside* the toolbar (e.g. several hundred pixels + * to the left into the greeting area) and releases, the tile follows the + * pointer via CSS transform but the hit-test at pointerup can land on a + * sibling surface — or the synthetic click React dispatches can be routed to + * a different root-attached listener before ours fires. A document-level + * capture-phase listener sits above everything, so a single armed flag + * reliably swallows the next click regardless of where it lands. + * + * Usage: + * const { armGuard, onClickCapture } = useDragClickGuard(); + * { armGuard(); ... }} + * /> + *
    ...
    + * + * `onClickCapture` stays wired on the toolbar as a React-side belt; the + * native document listener is the suspenders. + */ +export function useDragClickGuard(): { + armGuard: () => void; + onClickCapture: (event: ReactMouseEvent) => void; +} { + const activeRef = useRef(false); + const timerRef = useRef(null); + + const disarm = useCallback(() => { + activeRef.current = false; + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const armGuard = useCallback(() => { + activeRef.current = true; + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + } + timerRef.current = window.setTimeout(() => { + activeRef.current = false; + timerRef.current = null; + }, POST_DRAG_SUPPRESSION_MS); + }, []); + + useEffect(() => { + if (typeof document === 'undefined') { + return undefined; + } + // Capture phase runs before any React synthetic handler (React attaches + // its own root listener in the bubble phase, and even with 17+'s root + // delegation, capture still wins). stopImmediatePropagation keeps any + // other capture-phase listener on the same target from re-triggering + // navigation. + const handler = (event: MouseEvent) => { + if (!activeRef.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + }; + document.addEventListener('click', handler, true); + document.addEventListener('auxclick', handler, true); + return () => { + document.removeEventListener('click', handler, true); + document.removeEventListener('auxclick', handler, true); + disarm(); + }; + }, [disarm]); + + const onClickCapture = useCallback((event: ReactMouseEvent) => { + if (!activeRef.current) { + return; + } + event.preventDefault(); + event.stopPropagation(); + }, []); + + return { armGuard, onClickCapture }; +} diff --git a/packages/shared/src/features/shortcuts/hooks/useHiddenTopSites.ts b/packages/shared/src/features/shortcuts/hooks/useHiddenTopSites.ts new file mode 100644 index 00000000000..8c47ea8ec61 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useHiddenTopSites.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from 'react'; +import usePersistentContext from '../../../hooks/usePersistentContext'; + +const HIDDEN_TOP_SITES_KEY = 'shortcuts_hidden_top_sites'; + +// Persists a per-browser list of most-visited URLs the user has dismissed. +// Mirrors Chrome's NTP behaviour: the browser keeps surfacing top sites from +// history, but we respect the user's one-off "remove this tile" decision. +// Stored in IndexedDB via `usePersistentContext` so it survives reloads and +// stays local to the device (top sites are inherently a per-browser signal). +export function useHiddenTopSites(): { + hidden: string[]; + isHidden: (url: string) => boolean; + hide: (url: string) => Promise; + unhide: (url: string) => Promise; + restore: () => Promise; +} { + const [value, setValue] = usePersistentContext( + HIDDEN_TOP_SITES_KEY, + [], + undefined, + [], + ); + const hidden = useMemo(() => value ?? [], [value]); + + const hiddenSet = useMemo(() => new Set(hidden), [hidden]); + + const isHidden = useCallback( + (url: string) => hiddenSet.has(url), + [hiddenSet], + ); + + const hide = useCallback( + async (url: string) => { + if (hiddenSet.has(url)) { + return; + } + await setValue([...hidden, url]); + }, + [hidden, hiddenSet, setValue], + ); + + const unhide = useCallback( + async (url: string) => { + if (!hiddenSet.has(url)) { + return; + } + await setValue(hidden.filter((existing) => existing !== url)); + }, + [hidden, hiddenSet, setValue], + ); + + const restore = useCallback(async () => { + await setValue([]); + }, [setValue]); + + return { hidden, isHidden, hide, unhide, restore }; +} diff --git a/packages/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled.ts b/packages/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled.ts new file mode 100644 index 00000000000..6c6d2806cf6 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useIsShortcutsHubEnabled.ts @@ -0,0 +1,13 @@ +import { useAuthContext } from '../../../contexts/AuthContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { featureShortcutsHub } from '../../../lib/featureManagement'; + +export function useIsShortcutsHubEnabled(): boolean { + const { user } = useAuthContext(); + const { value: hubEnabled } = useConditionalFeature({ + feature: featureShortcutsHub, + shouldEvaluate: !!user, + }); + + return !!user && !!hubEnabled; +} diff --git a/packages/shared/src/features/shortcuts/hooks/useManualShortcutsRow.ts b/packages/shared/src/features/shortcuts/hooks/useManualShortcutsRow.ts new file mode 100644 index 00000000000..9c3190c3b81 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useManualShortcutsRow.ts @@ -0,0 +1,71 @@ +import { arrayMove } from '@dnd-kit/sortable'; +import type { Shortcut } from '../types'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { LazyModal } from '../../../components/modals/common/types'; +import { useShortcutsManager } from './useShortcutsManager'; +import { useShortcutDropZone } from './useShortcutDropZone'; + +interface UseManualShortcutsRowResult { + shortcuts: Shortcut[]; + canAdd: boolean; + isDropTarget: boolean; + dropHandlers: ReturnType['dropHandlers']; + onAdd: () => void; + onEdit: (shortcut: Shortcut) => void; + onRemove: (shortcut: Shortcut) => Promise; + reorderShortcuts: (activeId: string, overId: string) => Shortcut | null; +} + +export function useManualShortcutsRow(): UseManualShortcutsRowResult { + const manager = useShortcutsManager(); + const { openModal } = useLazyModal(); + const { displayToast } = useToastNotification(); + + const onAdd = () => + openModal({ type: LazyModal.ShortcutEdit, props: { mode: 'add' } }); + + const onEdit = (shortcut: Shortcut) => + openModal({ + type: LazyModal.ShortcutEdit, + props: { mode: 'edit', shortcut }, + }); + + const onRemove = (shortcut: Shortcut) => manager.removeShortcut(shortcut.url); + + const onDropUrl = async (url: string) => { + const result = await manager.addShortcut({ url }); + if (result.error) { + displayToast(result.error); + } + }; + + const { isDropTarget, dropHandlers } = useShortcutDropZone( + onDropUrl, + manager.canAdd, + ); + + const reorderShortcuts = (activeId: string, overId: string) => { + const urls = manager.shortcuts.map((shortcut) => shortcut.url); + const oldIndex = urls.indexOf(activeId); + const newIndex = urls.indexOf(overId); + + if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) { + return null; + } + + manager.reorder(arrayMove(urls, oldIndex, newIndex)); + return manager.shortcuts[oldIndex] ?? null; + }; + + return { + shortcuts: manager.shortcuts, + canAdd: manager.canAdd, + isDropTarget, + dropHandlers, + onAdd, + onEdit, + onRemove, + reorderShortcuts, + }; +} diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutDropZone.spec.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutDropZone.spec.ts new file mode 100644 index 00000000000..d85fba0249b --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutDropZone.spec.ts @@ -0,0 +1,209 @@ +import { renderHook, act } from '@testing-library/react'; +import type { DragEvent } from 'react'; +import { useShortcutDropZone } from './useShortcutDropZone'; + +// In jsdom, DragEvent's DataTransfer is sparsely implemented and `types` is +// read-only — so we stub the parts the hook actually reads and fake just +// enough of a React synthetic event to drive the handlers directly. This +// matches how the hook is actually consumed (via React's synthetic event +// system spreading `dropHandlers` onto a JSX element), so we're exercising +// the same branches a real drag would hit. +interface FakeDataTransfer { + types: string[]; + data: Record; + dropEffect: string; +} + +const createDragEvent = ( + payload: Record = {}, +): { + event: DragEvent; + preventDefault: jest.Mock; + dataTransfer: FakeDataTransfer; +} => { + const dataTransfer: FakeDataTransfer = { + types: Object.keys(payload), + data: payload, + dropEffect: 'none', + }; + const preventDefault = jest.fn(); + const event = { + preventDefault, + dataTransfer: { + ...dataTransfer, + getData: (type: string) => dataTransfer.data[type] ?? '', + // Make `dropEffect` writable the way the DOM spec treats it. The + // hook flips it to 'copy' on dragOver; tests then assert on it. + get dropEffect() { + return dataTransfer.dropEffect; + }, + set dropEffect(value: string) { + dataTransfer.dropEffect = value; + }, + }, + } as unknown as DragEvent; + return { event, preventDefault, dataTransfer }; +}; + +describe('useShortcutDropZone', () => { + it('returns no handlers when onDropUrl is undefined', () => { + const { result } = renderHook(() => useShortcutDropZone(undefined)); + expect(result.current.dropHandlers).toBeUndefined(); + expect(result.current.isDropTarget).toBe(false); + }); + + it('returns no handlers when explicitly disabled', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop, false)); + expect(result.current.dropHandlers).toBeUndefined(); + expect(result.current.isDropTarget).toBe(false); + }); + + it('ignores drags without a text/uri-list payload on hover', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + const { event, preventDefault } = createDragEvent({ + 'text/plain': 'hello, plain text', + }); + + act(() => { + result.current.dropHandlers?.onDragEnter(event); + }); + + expect(preventDefault).not.toHaveBeenCalled(); + expect(result.current.isDropTarget).toBe(false); + }); + + it('activates the drop target for text/uri-list drags and flips dropEffect on dragOver', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + + const enter = createDragEvent({ + 'text/uri-list': 'https://example.com', + }); + act(() => { + result.current.dropHandlers?.onDragEnter(enter.event); + }); + expect(enter.preventDefault).toHaveBeenCalledTimes(1); + expect(result.current.isDropTarget).toBe(true); + + const over = createDragEvent({ 'text/uri-list': 'https://example.com' }); + act(() => { + result.current.dropHandlers?.onDragOver(over.event); + }); + expect(over.preventDefault).toHaveBeenCalledTimes(1); + expect(over.dataTransfer.dropEffect).toBe('copy'); + }); + + it('keeps the highlight on while nested child boundaries are crossed', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + const payload = { 'text/uri-list': 'https://example.com' }; + + // Simulates entering the toolbar, then crossing into a child tile: two + // enters, only one leave should NOT yet deactivate the zone. + act(() => { + result.current.dropHandlers?.onDragEnter(createDragEvent(payload).event); + result.current.dropHandlers?.onDragEnter(createDragEvent(payload).event); + }); + expect(result.current.isDropTarget).toBe(true); + + act(() => { + result.current.dropHandlers?.onDragLeave(); + }); + expect(result.current.isDropTarget).toBe(true); + + act(() => { + result.current.dropHandlers?.onDragLeave(); + }); + expect(result.current.isDropTarget).toBe(false); + }); + + it('calls onDropUrl with the text/uri-list payload', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + + act(() => { + result.current.dropHandlers?.onDragEnter( + createDragEvent({ 'text/uri-list': 'https://example.com' }).event, + ); + }); + + const drop = createDragEvent({ 'text/uri-list': 'https://example.com' }); + act(() => { + result.current.dropHandlers?.onDrop(drop.event); + }); + + expect(drop.preventDefault).toHaveBeenCalledTimes(1); + expect(onDrop).toHaveBeenCalledWith('https://example.com'); + expect(result.current.isDropTarget).toBe(false); + }); + + it('falls back to text/plain for the URL at drop time (Firefox case)', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + + // Enter still gated on uri-list so the row lights up for real link drags. + act(() => { + result.current.dropHandlers?.onDragEnter( + createDragEvent({ 'text/uri-list': 'https://example.com' }).event, + ); + }); + + // On drop, the uri-list is empty but text/plain carries the URL. + const drop = createDragEvent({ + 'text/uri-list': '', + 'text/plain': 'example.com', + }); + act(() => { + result.current.dropHandlers?.onDrop(drop.event); + }); + + expect(onDrop).toHaveBeenCalledWith('https://example.com'); + }); + + it('skips comment lines and whitespace in text/uri-list', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + + act(() => { + result.current.dropHandlers?.onDragEnter( + createDragEvent({ 'text/uri-list': 'https://example.com' }).event, + ); + }); + + // Per RFC 2483, `#`-prefixed lines are comments. We should pick the + // first valid URL past them. + const drop = createDragEvent({ + 'text/uri-list': + '# comment line\n \nhttps://daily.dev\nhttps://ignored.example', + }); + act(() => { + result.current.dropHandlers?.onDrop(drop.event); + }); + + expect(onDrop).toHaveBeenCalledWith('https://daily.dev'); + }); + + it('no-ops on drop when the payload is not a valid URL', () => { + const onDrop = jest.fn(); + const { result } = renderHook(() => useShortcutDropZone(onDrop)); + + act(() => { + result.current.dropHandlers?.onDragEnter( + createDragEvent({ 'text/uri-list': 'https://example.com' }).event, + ); + }); + + const drop = createDragEvent({ + 'text/uri-list': '', + 'text/plain': 'just some selected text, not a URL at all', + }); + act(() => { + result.current.dropHandlers?.onDrop(drop.event); + }); + + expect(onDrop).not.toHaveBeenCalled(); + expect(result.current.isDropTarget).toBe(false); + }); +}); diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutDropZone.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutDropZone.ts new file mode 100644 index 00000000000..9e23bdaa0bb --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutDropZone.ts @@ -0,0 +1,173 @@ +import type { DragEvent } from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { isValidHttpUrl, withHttps } from '../../../lib/links'; + +/** + * Drag-to-add drop zone for shortcuts rows. + * + * Historically only the small `AddShortcutTile` (the "+" button) listened for + * external URL drops. That's a ~44px target in a flexible row that can be + * hundreds of pixels wide, so users dragging a link from the browser's + * bookmarks bar almost always missed it — and because the rest of the row + * had no drop listeners, there was no visible "you can drop here" indicator + * either. This hook turns the entire toolbar container into a single drop + * target so a drop anywhere on the row counts. + * + * Drop lifecycle notes: + * - `dragenter` / `dragleave` fire on every child boundary the pointer + * crosses, so a naive boolean state flickers as the drag moves across + * tiles. We use a depth counter: +1 on enter, −1 on leave, indicator is + * active while depth > 0. This is the well-known fix for the fact that + * `relatedTarget` is unreliable across browsers during a drag. + * - During `dragenter`/`dragover` the spec disallows reading the dragged + * data for security, so we key the "is this a URL?" gate off + * `dataTransfer.types`. We only light up for `text/uri-list` at hover + * time — every real link-drag source (bookmarks bar, address bar, link + * elements) sets it, and gating on it alone avoids false-positive halos + * for plain-text drags (selected text, etc). At `drop` time we broaden + * to `text/plain` as a fallback since Firefox occasionally only sets + * that for link drags initiated from older sources. + * - `dragover.preventDefault()` is required to make the drop event fire at + * all; without it browsers reject the drop as "not a valid target". + */ + +// During `dragenter`/`dragover` the spec doesn't let us read the dragged data +// (security), so we pattern-match on `dataTransfer.types` instead. Browsers +// emit `text/uri-list` for real link drags — bookmarks bar, address-bar URL, +// link-to-link tab drags — so that's the only type we accept as a hover +// signal. `text/plain` is too permissive (any selected-text drag advertises +// it), so we save it for a fallback at *drop* time via `extractUrlFromDrop`. +const URL_HOVER_TYPE = 'text/uri-list'; + +const hasUrlHoverPayload = (event: DragEvent): boolean => { + const { types } = event.dataTransfer; + if (!types) { + return false; + } + // `types` is an array-like DOMStringList; avoid `.includes` for older APIs. + for (let i = 0; i < types.length; i += 1) { + if (types[i] === URL_HOVER_TYPE) { + return true; + } + } + return false; +}; + +const parseUrlLine = (raw: string): string | null => { + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith('#')) { + return null; + } + const normalised = withHttps(trimmed); + return isValidHttpUrl(normalised) ? normalised : null; +}; + +const extractUrlFromDrop = (event: DragEvent): string | null => { + const uriList = event.dataTransfer.getData('text/uri-list'); + if (uriList) { + const fromUriList = uriList + .split(/\r?\n/) + .map(parseUrlLine) + .find((parsed): parsed is string => !!parsed); + if (fromUriList) { + return fromUriList; + } + } + const plain = event.dataTransfer.getData('text/plain'); + if (plain) { + return parseUrlLine(plain); + } + return null; +}; + +export interface ShortcutDropZoneHandlers { + onDragEnter: (event: DragEvent) => void; + onDragOver: (event: DragEvent) => void; + onDragLeave: () => void; + onDrop: (event: DragEvent) => void; +} + +export interface UseShortcutDropZoneResult { + isDropTarget: boolean; + dropHandlers: ShortcutDropZoneHandlers | undefined; +} + +export function useShortcutDropZone( + onDropUrl: ((url: string) => void) | undefined, + enabled: boolean = true, +): UseShortcutDropZoneResult { + const [isDropTarget, setIsDropTarget] = useState(false); + const depthRef = useRef(0); + const canAccept = !!onDropUrl && enabled; + + const handleDragEnter = useCallback( + (event: DragEvent) => { + if (!canAccept || !hasUrlHoverPayload(event)) { + return; + } + event.preventDefault(); + depthRef.current += 1; + setIsDropTarget(true); + }, + [canAccept], + ); + + const handleDragOver = useCallback( + (event: DragEvent) => { + if (!canAccept || !hasUrlHoverPayload(event)) { + return; + } + // Required to mark the element a valid drop target; without it the + // browser won't fire `drop` and the copy cursor never appears. + event.preventDefault(); + // eslint-disable-next-line no-param-reassign + event.dataTransfer.dropEffect = 'copy'; + // Safety net: if a drag crosses browser windows or starts inside the + // zone, `dragenter` can be skipped — keep the indicator on while the + // pointer is actively hovering the zone. + if (depthRef.current === 0) { + depthRef.current = 1; + setIsDropTarget(true); + } + }, + [canAccept], + ); + + const handleDragLeave = useCallback(() => { + if (!canAccept) { + return; + } + depthRef.current = Math.max(0, depthRef.current - 1); + if (depthRef.current === 0) { + setIsDropTarget(false); + } + }, [canAccept]); + + const handleDrop = useCallback( + (event: DragEvent) => { + if (!canAccept) { + return; + } + event.preventDefault(); + depthRef.current = 0; + setIsDropTarget(false); + const url = extractUrlFromDrop(event); + if (url && onDropUrl) { + onDropUrl(url); + } + }, + [canAccept, onDropUrl], + ); + + return { + isDropTarget: canAccept && isDropTarget, + dropHandlers: canAccept + ? { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + } + : undefined, + }; +} diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts index 688377461be..7706676403b 100644 --- a/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutLinks.ts @@ -7,16 +7,16 @@ import { useShortcutsUser } from './useShortcutsUser'; import { useShortcuts } from '../contexts/ShortcutsProvider'; export interface UseShortcutLinks { - formRef: MutableRefObject; + formRef: MutableRefObject; onSaveChanges: ( e: FormEvent, - ) => Promise<{ errors: Record }>; + ) => Promise<{ errors: Record | null }>; askTopSitesBrowserPermission: () => Promise; hasCheckedPermission?: boolean; - isTopSiteActive?: boolean; - hasTopSites?: boolean; + isTopSiteActive?: boolean | null; + hasTopSites?: boolean | null; isManual?: boolean; - shortcutLinks: string[]; + shortcutLinks?: string[]; formLinks: string[]; customLinks?: string[]; hideShortcuts: boolean; @@ -33,10 +33,14 @@ export function useShortcutLinks(): UseShortcutLinks { const { customLinks, updateCustomLinks, showTopSites } = useSettingsContext(); const hasTopSites = topSites === undefined ? null : topSites?.length > 0; - const hasCustomLinks = customLinks?.length > 0; + const hasCustomLinks = (customLinks?.length ?? 0) > 0; const isTopSiteActive = hasCheckedPermission && !hasCustomLinks && hasTopSites; - const sites = topSites?.map((site) => site.url); + // Legacy surface caps at 8 tiles. The upstream hook now hands back up to + // `MAX_SHORTCUTS` (12) so the new hub's auto mode can render the full + // row, so we slice here to keep the legacy row's visual width stable + // for flag-off users. + const sites = topSites?.slice(0, 8).map((site) => site.url); const shortcutLinks = isTopSiteActive ? sites : customLinks; const formLinks = (isManual ? customLinks : sites) || []; @@ -46,10 +50,14 @@ export function useShortcutLinks(): UseShortcutLinks { const showGetStarted = !isOldUser && hasNoShortcuts && !hasCompletedFirstSession; - const getFormInputs = () => - Array.from(formRef.current.elements).filter( + const getFormInputs = () => { + if (!formRef.current) { + return [] as HTMLInputElement[]; + } + return Array.from(formRef.current.elements).filter( (el) => el.getAttribute('name') === 'shortcutLink', ) as HTMLInputElement[]; + }; const onSaveChanges = async (e: FormEvent) => { e.preventDefault(); diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.spec.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.spec.ts new file mode 100644 index 00000000000..3441cc2e9d6 --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.spec.ts @@ -0,0 +1,166 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useShortcutsManager } from './useShortcutsManager'; + +type ToastOptions = { + timer?: number; + action?: { + copy?: string; + onClick: () => void | Promise; + }; +}; + +// Jest hoists `jest.mock` factories above imports, so any identifiers they +// reference must be prefixed with `mock` per jest's out-of-scope guard. +const mockState = { + customLinks: [] as string[], + flags: { shortcutMeta: {} as Record } as Record< + string, + unknown + >, +}; +const mockSetSettings = jest.fn(async (patch: Record) => { + if ('customLinks' in patch) { + mockState.customLinks = patch.customLinks as string[]; + } + if ('flags' in patch) { + mockState.flags = { ...(patch.flags as Record) }; + } +}); +const mockUpdateCustomLinks = jest.fn(async (links: string[]) => { + mockState.customLinks = [...links]; +}); +const mockLogEvent = jest.fn(); +const mockDisplayToast = jest.fn(); + +jest.mock('../../../contexts/SettingsContext', () => ({ + useSettingsContext: () => ({ + get customLinks() { + return mockState.customLinks; + }, + get flags() { + return mockState.flags; + }, + updateCustomLinks: mockUpdateCustomLinks, + setSettings: mockSetSettings, + }), +})); + +jest.mock('../../../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: mockLogEvent }), +})); + +jest.mock('../../../hooks/useToastNotification', () => ({ + useToastNotification: () => ({ displayToast: mockDisplayToast }), +})); + +jest.mock('../contexts/ShortcutsProvider', () => ({ + useShortcuts: () => ({ setShowImportSource: jest.fn() }), +})); + +const resetState = () => { + mockState.customLinks = []; + mockState.flags = { shortcutMeta: {} }; + mockSetSettings.mockClear(); + mockUpdateCustomLinks.mockClear(); + mockLogEvent.mockClear(); + mockDisplayToast.mockClear(); +}; + +describe('useShortcutsManager', () => { + beforeEach(resetState); + + it('adds a shortcut with https normalization', async () => { + const { result } = renderHook(() => useShortcutsManager()); + + await act(async () => { + const res = await result.current.addShortcut({ url: 'example.com' }); + expect(res.error).toBeUndefined(); + }); + + expect(mockSetSettings).toHaveBeenCalledTimes(1); + expect(mockState.customLinks).toEqual(['https://example.com']); + }); + + it('rejects duplicates regardless of www / trailing slash', async () => { + mockState.customLinks = ['https://www.example.com/']; + const { result } = renderHook(() => useShortcutsManager()); + + await act(async () => { + const res = await result.current.addShortcut({ + url: 'https://example.com', + }); + expect(res.error).toBe('This shortcut already exists'); + }); + + expect(mockSetSettings).not.toHaveBeenCalled(); + }); + + it('treats distinct search params as distinct shortcuts', async () => { + mockState.customLinks = ['https://example.com/search?q=foo']; + const { result } = renderHook(() => useShortcutsManager()); + + await act(async () => { + const res = await result.current.addShortcut({ + url: 'https://example.com/search?q=bar', + }); + expect(res.error).toBeUndefined(); + }); + + expect(mockState.customLinks).toEqual([ + 'https://example.com/search?q=foo', + 'https://example.com/search?q=bar', + ]); + }); + + it('undo restores a removed shortcut without stomping concurrent writes', async () => { + mockState.customLinks = ['https://a.com', 'https://b.com']; + const { result, rerender } = renderHook(() => useShortcutsManager()); + + await act(async () => { + await result.current.removeShortcut('https://a.com'); + }); + + // Simulate a concurrent add landing while the undo toast is still visible. + mockState.customLinks = ['https://b.com', 'https://c.com']; + rerender(); + + await waitFor(() => { + expect(mockDisplayToast).toHaveBeenCalled(); + }); + const [, options] = mockDisplayToast.mock.calls[0]; + expect(options?.action?.copy).toBe('Undo'); + + await act(async () => { + await options?.action?.onClick(); + }); + + // The undo should merge the restored shortcut with the concurrent add, + // not roll back to the pre-remove snapshot that would lose `c.com`. + expect(mockState.customLinks).toEqual([ + 'https://a.com', + 'https://b.com', + 'https://c.com', + ]); + }); + + it('imports sites up to the MAX_SHORTCUTS capacity and skips dupes', async () => { + mockState.customLinks = ['https://a.com']; + const { result } = renderHook(() => useShortcutsManager()); + + await act(async () => { + const res = await result.current.importFrom('topSites', [ + { url: 'a.com' }, + { url: 'b.com', title: 'B' }, + { url: 'c.com' }, + ]); + expect(res.imported).toBe(2); + expect(res.skipped).toBe(1); + }); + + expect(mockState.customLinks).toEqual([ + 'https://a.com', + 'https://b.com', + 'https://c.com', + ]); + }); +}); diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts new file mode 100644 index 00000000000..aa199fbf75e --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsManager.ts @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { LogEvent, ShortcutsSourceType, TargetType } from '../../../lib/log'; +import { withHttps } from '../../../lib/links'; +import type { SettingsFlags } from '../../../graphql/settings'; +import type { ImportSource, Shortcut, ShortcutMeta } from '../types'; +import { MAX_SHORTCUTS, UNDO_TIMEOUT_MS } from '../types'; +import { useShortcuts } from '../contexts/ShortcutsProvider'; +import { getShortcutDedupKey } from '../lib/getShortcutDedupKey'; + +export interface UseShortcutsManager { + shortcuts: Shortcut[]; + canAdd: boolean; + addShortcut: (input: { + url: string; + name?: string; + iconUrl?: string; + }) => Promise<{ error?: string }>; + updateShortcut: ( + url: string, + patch: { url?: string; name?: string; iconUrl?: string }, + ) => Promise<{ error?: string }>; + removeShortcut: (url: string) => Promise; + reorder: (nextUrls: string[]) => Promise; + importFrom: ( + source: ImportSource, + items: Array<{ url: string; title?: string }>, + ) => Promise<{ imported: number; skipped: number }>; + findDuplicate: (url: string) => string | null; +} + +export const useShortcutsManager = (): UseShortcutsManager => { + const { logEvent } = useLogContext(); + const { displayToast } = useToastNotification(); + const { customLinks, flags, updateCustomLinks, setSettings } = + useSettingsContext(); + const { setShowImportSource } = useShortcuts(); + + const metaMap = useMemo>(() => { + const rawMeta = flags?.shortcutMeta ?? {}; + + return Object.fromEntries( + Object.entries(rawMeta).map(([url, meta]) => [ + url, + { + name: meta?.name, + iconUrl: meta?.iconUrl, + }, + ]), + ); + }, [flags]); + const links = useMemo(() => customLinks ?? [], [customLinks]); + + // Refs tracking the latest committed state so undo toasts can recompute + // against fresh data rather than a stale closure snapshot, which would + // otherwise clobber unrelated writes that landed during the undo window. + const linksRef = useRef(links); + const metaRef = useRef(metaMap); + useEffect(() => { + linksRef.current = links; + metaRef.current = metaMap; + }, [links, metaMap]); + + const shortcuts = useMemo( + () => + links.map((url) => { + const meta = metaMap[url] ?? {}; + return { + url, + name: meta.name, + iconUrl: meta.iconUrl, + }; + }), + [links, metaMap], + ); + + const canonicalMap = useMemo(() => { + const map = new Map(); + links.forEach((url) => { + const key = getShortcutDedupKey(url); + if (key) { + map.set(key, url); + } + }); + return map; + }, [links]); + + const findDuplicate = useCallback( + (url: string) => { + const key = getShortcutDedupKey(url); + if (!key) { + return null; + } + return canonicalMap.get(key) ?? null; + }, + [canonicalMap], + ); + + const canAdd = links.length < MAX_SHORTCUTS; + + const log = useCallback( + (eventName: LogEvent, extra?: Record) => + logEvent({ + event_name: eventName, + target_type: TargetType.Shortcuts, + extra: extra ? JSON.stringify(extra) : undefined, + }), + [logEvent], + ); + + const writeBatch = useCallback( + async ( + nextLinks: string[], + nextMeta: Record, + ): Promise => { + await setSettings({ + customLinks: nextLinks, + flags: { ...flags, shortcutMeta: nextMeta } as SettingsFlags, + }); + }, + [flags, setSettings], + ); + + const addShortcut: UseShortcutsManager['addShortcut'] = useCallback( + async ({ url, name, iconUrl }) => { + if (!canAdd) { + return { error: `You can only add up to ${MAX_SHORTCUTS} shortcuts.` }; + } + const httpsUrl = withHttps(url); + const existingDuplicate = findDuplicate(httpsUrl); + if (existingDuplicate) { + return { error: 'This shortcut already exists' }; + } + + const meta: ShortcutMeta = {}; + if (name) { + meta.name = name; + } + if (iconUrl) { + meta.iconUrl = iconUrl; + } + const nextLinks = [...links, httpsUrl]; + const nextMeta = { ...metaMap }; + if (Object.keys(meta).length) { + nextMeta[httpsUrl] = meta; + } + + await writeBatch(nextLinks, nextMeta); + log(LogEvent.AddShortcut); + return {}; + }, + [canAdd, findDuplicate, links, metaMap, writeBatch, log], + ); + + const updateShortcut: UseShortcutsManager['updateShortcut'] = useCallback( + async (url, patch) => { + const index = links.indexOf(url); + if (index === -1) { + return { error: 'Shortcut not found' }; + } + + const nextUrl = patch.url ? withHttps(patch.url) : url; + if (nextUrl !== url) { + const duplicate = findDuplicate(nextUrl); + if (duplicate && duplicate !== url) { + return { error: 'This shortcut already exists' }; + } + } + + const nextLinks = [...links]; + nextLinks[index] = nextUrl; + + const prevMeta = metaMap[url] ?? {}; + const mergedMeta: ShortcutMeta = { + ...prevMeta, + ...(patch.name !== undefined ? { name: patch.name || undefined } : {}), + ...(patch.iconUrl !== undefined + ? { iconUrl: patch.iconUrl || undefined } + : {}), + }; + + const nextMeta = { ...metaMap }; + delete nextMeta[url]; + const isEmpty = !mergedMeta.name && !mergedMeta.iconUrl; + if (!isEmpty) { + nextMeta[nextUrl] = mergedMeta; + } + + await writeBatch(nextLinks, nextMeta); + log(LogEvent.EditShortcut); + return {}; + }, + [links, metaMap, findDuplicate, writeBatch, log], + ); + + const removeShortcut = useCallback( + async (url) => { + const index = links.indexOf(url); + if (index === -1) { + return; + } + const prevMeta = metaMap[url]; + const nextLinks = links.filter((u) => u !== url); + const nextMeta = { ...metaMap }; + delete nextMeta[url]; + + await writeBatch(nextLinks, nextMeta); + log(LogEvent.RemoveShortcut); + + // `displayToast` owns the 6s undo window via `timer`; a second + // remove clobbers the first toast through the toast manager, so we + // don't need to track timers here. + displayToast('Shortcut removed', { + timer: UNDO_TIMEOUT_MS, + action: { + copy: 'Undo', + onClick: async () => { + // Recompute from the latest committed state so we don't stomp + // unrelated shortcut writes that landed during the undo window. + const currentLinks = linksRef.current; + const currentMeta = metaRef.current; + if (currentLinks.includes(url)) { + // User re-added the same shortcut during the undo window; + // nothing to restore. + return; + } + const insertAt = Math.min(index, currentLinks.length); + const restoredLinks = [...currentLinks]; + restoredLinks.splice(insertAt, 0, url); + const restoredMeta = { ...currentMeta }; + if (prevMeta) { + restoredMeta[url] = prevMeta; + } + await writeBatch(restoredLinks, restoredMeta); + log(LogEvent.UndoRemoveShortcut); + }, + }, + }); + }, + [links, metaMap, writeBatch, displayToast, log], + ); + + const reorder = useCallback( + async (nextUrls) => { + await updateCustomLinks(nextUrls); + log(LogEvent.ReorderShortcuts); + }, + [updateCustomLinks, log], + ); + + const importFrom = useCallback( + async (source, items) => { + const capacity = MAX_SHORTCUTS - links.length; + if (capacity <= 0) { + return { imported: 0, skipped: items.length }; + } + + const existingKeys = new Set(canonicalMap.keys()); + const batchLinks: string[] = []; + const batchMeta: Record = {}; + let skipped = 0; + + items.forEach((item) => { + if (batchLinks.length >= capacity) { + skipped += 1; + return; + } + const httpsUrl = withHttps(item.url); + const key = getShortcutDedupKey(httpsUrl); + if (!key || existingKeys.has(key)) { + skipped += 1; + return; + } + existingKeys.add(key); + batchLinks.push(httpsUrl); + if (item.title) { + batchMeta[httpsUrl] = { name: item.title }; + } + }); + + if (!batchLinks.length) { + setShowImportSource?.(null); + return { imported: 0, skipped }; + } + + await writeBatch([...links, ...batchLinks], { ...metaMap, ...batchMeta }); + + const logSource = + source === 'bookmarks' + ? ShortcutsSourceType.Bookmarks + : ShortcutsSourceType.Browser; + log(LogEvent.ImportShortcuts, { + source: logSource, + count: batchLinks.length, + }); + + setShowImportSource?.(null); + return { imported: batchLinks.length, skipped }; + }, + [canonicalMap, links, metaMap, writeBatch, setShowImportSource, log], + ); + + return { + shortcuts, + canAdd, + addShortcut, + updateShortcut, + removeShortcut, + reorder, + importFrom, + findDuplicate, + }; +}; diff --git a/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts new file mode 100644 index 00000000000..b2748ee7b1c --- /dev/null +++ b/packages/shared/src/features/shortcuts/hooks/useShortcutsMigration.ts @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { useActions } from '../../../hooks/useActions'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { ActionType } from '../../../graphql/actions'; +import { useShortcutsManager } from './useShortcutsManager'; +import { useShortcuts } from '../contexts/ShortcutsProvider'; + +/** + * One-time auto-import for users who previously relied on the top-sites mode + * (had topSites permission + empty customLinks). Seeds customLinks from + * topSites silently and surfaces a dismissible toast. + * + * Hardening notes: + * - `ranRef` only flips to `true` AFTER we know whether we imported or not, + * so a thrown `importFrom` doesn't permanently lock out retry. + * - Strict Mode double-invoke is guarded by `inFlightRef` instead of the + * commit-time `ranRef` so we never start two parallel imports. + * - `completeAction` only fires on a real success (imported > 0); if the + * browser returned zero top sites we leave the action unchecked and retry + * on the next mount. + */ +export const useShortcutsMigration = (): void => { + const { customLinks, flags } = useSettingsContext(); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const { topSites, hasCheckedPermission } = useShortcuts(); + // Destructure `importFrom` so the effect's dep list tracks the only + // function we actually invoke. Depending on `manager` would rerun the + // effect every time any *other* shortcut field on the manager changed. + const { importFrom } = useShortcutsManager(); + const { displayToast } = useToastNotification(); + const inFlightRef = useRef(false); + const ranRef = useRef(false); + + useEffect(() => { + if (ranRef.current || inFlightRef.current) { + return; + } + if (!isActionsFetched || !hasCheckedPermission) { + return; + } + if (checkHasCompleted(ActionType.ShortcutsMigratedFromTopSites)) { + ranRef.current = true; + return; + } + // Auto mode renders live top sites directly, so copying them into + // `customLinks` would leave the user with a stale manual list the next + // time they flip back to manual. Latch the migration action anyway so + // we don't keep re-evaluating this branch on every mount. + if ((flags?.shortcutsMode ?? 'manual') === 'auto') { + ranRef.current = true; + completeAction(ActionType.ShortcutsMigratedFromTopSites); + return; + } + // Once the user has engaged with the hub at all (picked suggestions, + // added/skipped from the get-started screen, or dismissed it), they own + // their list. An empty `customLinks` after that point is intentional — + // never silently re-import top sites over it. We latch the migration + // action too so this decision persists across devices/new tabs and the + // effect won't keep re-evaluating on every remount. + if (checkHasCompleted(ActionType.FirstShortcutsSession)) { + ranRef.current = true; + completeAction(ActionType.ShortcutsMigratedFromTopSites); + return; + } + if ((customLinks?.length ?? 0) > 0) { + return; + } + if (!topSites?.length) { + return; + } + + inFlightRef.current = true; + const items = topSites.map((s) => ({ url: s.url })); + importFrom('topSites', items) + .then((result) => { + if (result.imported > 0) { + displayToast( + 'We imported your most visited sites. You can edit them anytime.', + ); + completeAction(ActionType.ShortcutsMigratedFromTopSites); + ranRef.current = true; + } + }) + .catch(() => { + // Swallow: if the import failed we want the next mount to retry. + // The user is not blocked — we never showed a loading spinner. + }) + .finally(() => { + inFlightRef.current = false; + }); + }, [ + isActionsFetched, + hasCheckedPermission, + checkHasCompleted, + completeAction, + customLinks, + flags, + topSites, + importFrom, + displayToast, + ]); +}; diff --git a/packages/shared/src/features/shortcuts/hooks/useTopSites.ts b/packages/shared/src/features/shortcuts/hooks/useTopSites.ts index f5a11f590c8..ea2af793773 100644 --- a/packages/shared/src/features/shortcuts/hooks/useTopSites.ts +++ b/packages/shared/src/features/shortcuts/hooks/useTopSites.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import type { Browser, TopSites } from 'webextension-polyfill'; import { checkIsExtension } from '../../../lib/func'; +import { MAX_SHORTCUTS } from '../types'; type TopSite = TopSites.MostVisitedURL; @@ -15,8 +16,14 @@ export const useTopSites = () => { } try { + // Slice upstream so downstream consumers can choose their own visible + // cap: the legacy `ShortcutLinksList` takes 8, the new hub's auto + // mode takes `MAX_SHORTCUTS`. `MAX_SHORTCUTS` here is a defensive + // upper bound — browsers typically return ~10, but some profiles + // (edge cases, long histories) will return the full limit they + // support, and we don't want to haul more than we'd ever render. await browser.topSites.get().then((result = []) => { - setTopSites(result.slice(0, 8)); + setTopSites(result.slice(0, MAX_SHORTCUTS)); }); } catch (err) { setTopSites(undefined); diff --git a/packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.spec.ts b/packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.spec.ts new file mode 100644 index 00000000000..59f7e2f8eab --- /dev/null +++ b/packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.spec.ts @@ -0,0 +1,46 @@ +import { getShortcutDedupKey } from './getShortcutDedupKey'; + +describe('getShortcutDedupKey', () => { + it('lowercases the host and strips trailing slashes', () => { + expect(getShortcutDedupKey('HTTPS://Example.COM/Foo/')).toEqual( + 'https://example.com/Foo', + ); + }); + + it('collapses www. so www.example.com and example.com dedup', () => { + expect(getShortcutDedupKey('https://www.example.com/')).toEqual( + 'https://example.com', + ); + expect(getShortcutDedupKey('https://example.com')).toEqual( + 'https://example.com', + ); + }); + + it('preserves non-www subdomains', () => { + expect(getShortcutDedupKey('https://blog.example.com')).toEqual( + 'https://blog.example.com', + ); + }); + + it('preserves non-default ports', () => { + expect(getShortcutDedupKey('https://www.example.com:8443/path')).toEqual( + 'https://example.com:8443/path', + ); + }); + + it('preserves search params and hash so distinct pages dedup separately', () => { + expect(getShortcutDedupKey('https://example.com/search?q=foo')).toEqual( + 'https://example.com/search?q=foo', + ); + expect(getShortcutDedupKey('https://example.com/app#/route')).toEqual( + 'https://example.com/app#/route', + ); + expect(getShortcutDedupKey('https://example.com/search?q=foo')).not.toEqual( + getShortcutDedupKey('https://example.com/search?q=bar'), + ); + }); + + it('returns null for invalid input', () => { + expect(getShortcutDedupKey('not a url')).toBeNull(); + }); +}); diff --git a/packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.ts b/packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.ts new file mode 100644 index 00000000000..b9dc68851a2 --- /dev/null +++ b/packages/shared/src/features/shortcuts/lib/getShortcutDedupKey.ts @@ -0,0 +1,20 @@ +import { withHttps } from '../../../lib/links'; + +/** + * Normalized comparison key used only for shortcut duplicate detection. + * We keep query/hash so distinct pages can still coexist as separate tiles. + */ +export const getShortcutDedupKey = (url: string): string | null => { + try { + const parsed = new URL(withHttps(url)); + const hostname = parsed.hostname.toLowerCase().replace(/^www\./, ''); + const pathname = parsed.pathname.replace(/\/+$/, ''); + const port = parsed.port ? `:${parsed.port}` : ''; + + return `${parsed.protocol.toLowerCase()}//${hostname}${port}${pathname}${ + parsed.search + }${parsed.hash}`; + } catch { + return null; + } +}; diff --git a/packages/shared/src/features/shortcuts/types.ts b/packages/shared/src/features/shortcuts/types.ts new file mode 100644 index 00000000000..b62067dc034 --- /dev/null +++ b/packages/shared/src/features/shortcuts/types.ts @@ -0,0 +1,28 @@ +export type ShortcutMeta = { + name?: string; + iconUrl?: string; +}; + +export type Shortcut = { + url: string; + name?: string; + iconUrl?: string; +}; + +export type ImportSource = 'topSites' | 'bookmarks'; + +// 'auto' mirrors Chrome's default new tab: live top-sites from the browser, +// read-only. 'manual' is the curated list the user pins and edits. +export type ShortcutsMode = 'auto' | 'manual'; + +// Appearance presets informed by patterns users already know: +// - 'tile' → Chrome new-tab / iOS Home (favicon square, label under). +// - 'icon' → iOS Dock, macOS Finder sidebar (icon only, labels via title). +// - 'chip' → Chrome bookmarks bar, Toby, Raindrop headlines (horizontal +// pill with favicon left, title right; info-dense, more fit). +export type ShortcutsAppearance = 'tile' | 'icon' | 'chip'; + +export const DEFAULT_SHORTCUTS_APPEARANCE: ShortcutsAppearance = 'tile'; + +export const MAX_SHORTCUTS = 12; +export const UNDO_TIMEOUT_MS = 6000; diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 0ce5b75e3db..2cddf5b0a53 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -32,6 +32,7 @@ export enum ActionType { DisableReadingStreakMilestone = 'disable_reading_streak_milestone', DisableReadingStreakRecover = 'disable_reading_streak_recover', FirstShortcutsSession = 'first_shortcuts_session', + ShortcutsMigratedFromTopSites = 'shortcuts_migrated_from_top_sites', VotePost = 'vote_post', BookmarkPost = 'bookmark_post', DigestConfig = 'digest_config', diff --git a/packages/shared/src/graphql/settings.ts b/packages/shared/src/graphql/settings.ts index c48d561e1ed..52354d7aa68 100644 --- a/packages/shared/src/graphql/settings.ts +++ b/packages/shared/src/graphql/settings.ts @@ -1,6 +1,11 @@ import { gql } from 'graphql-request'; import type { SortCommentsBy } from './comments'; import type { WriteFormTab } from '../components/fields/form/common'; +import type { + ShortcutMeta, + ShortcutsAppearance, + ShortcutsMode, +} from '../features/shortcuts/types'; export type Spaciness = 'eco' | 'roomy' | 'cozy'; export type RemoteTheme = 'darcula' | 'bright' | 'auto'; @@ -20,6 +25,10 @@ export type SettingsFlags = { timezoneMismatchIgnore?: string; prompt?: Record; defaultWriteTab?: WriteFormTab; + shortcutMeta?: Record; + shortcutsMode?: ShortcutsMode; + shortcutsAppearance?: ShortcutsAppearance; + showShortcutsOnWebapp?: boolean; }; export enum SidebarSettingsFlags { diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 08cb456228b..d15a23314a5 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -158,3 +158,5 @@ export const featureShortcutsExtensionPromo = new Feature( 'shortcuts_extension_promo', false, ); + +export const featureShortcutsHub = new Feature('shortcuts_hub', false); diff --git a/packages/shared/src/lib/func.ts b/packages/shared/src/lib/func.ts index 0d0807510a1..144a0e067a1 100644 --- a/packages/shared/src/lib/func.ts +++ b/packages/shared/src/lib/func.ts @@ -45,6 +45,8 @@ export const postWindowMessage = ( export const checkIsExtension = (): boolean => !!process.env.TARGET_BROWSER; export const isExtension = !!process.env.TARGET_BROWSER; +export const isFirefoxExtension = process.env.TARGET_BROWSER === 'firefox'; +export const isChromeExtension = process.env.TARGET_BROWSER === 'chrome'; export const isPWA = (): boolean => // @ts-expect-error - Safari only, not web standard. diff --git a/packages/shared/src/lib/links.ts b/packages/shared/src/lib/links.ts index ad068d188ce..6bd1f11b1c0 100644 --- a/packages/shared/src/lib/links.ts +++ b/packages/shared/src/lib/links.ts @@ -29,6 +29,14 @@ export const stripLinkParameters = (link: string): string => { return origin + pathname; }; +export const getDomainFromUrl = (link: string): string => { + try { + return new URL(withHttps(link)).hostname.replace(/^www\./, ''); + } catch (_) { + return link; + } +}; + export const removeQueryParam = (url: string, param: string): string => { const link = new URL(url); link.searchParams.delete(param); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 8d418c39ce3..993a387baf9 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -198,6 +198,15 @@ export enum LogEvent { RevokeShortcutAccess = 'revoke shortcut access', SaveShortcutAccess = 'save shortcut access', OpenShortcutConfig = 'open shortcut config', + AddShortcut = 'add shortcut', + EditShortcut = 'edit shortcut', + RemoveShortcut = 'remove shortcut', + ReorderShortcuts = 'reorder shortcuts', + ImportShortcuts = 'import shortcuts', + UndoRemoveShortcut = 'undo remove shortcut', + ChangeShortcutsMode = 'change shortcuts mode', + ChangeShortcutsAppearance = 'change shortcuts appearance', + ToggleShortcutsOnWebapp = 'toggle shortcuts on webapp', // Devcard ShareDevcard = 'share devcard', GenerateDevcard = 'generate devcard', @@ -615,6 +624,7 @@ export enum ShortcutsSourceType { Browser = 'browser', Placeholder = 'placeholder', Button = 'button', + Bookmarks = 'bookmarks', } export enum UserAcquisitionEvent { diff --git a/packages/webapp/pages/_app.tsx b/packages/webapp/pages/_app.tsx index 12a9aa58668..9031568a172 100644 --- a/packages/webapp/pages/_app.tsx +++ b/packages/webapp/pages/_app.tsx @@ -19,6 +19,7 @@ import { } from '@dailydotdev/shared/src/hooks/useCookieBanner'; import { ProgressiveEnhancementContextProvider } from '@dailydotdev/shared/src/contexts/ProgressiveEnhancementContext'; import { SubscriptionContextProvider } from '@dailydotdev/shared/src/contexts/SubscriptionContext'; +import { ShortcutsProvider } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; import { canonicalFromRouter } from '@dailydotdev/shared/src/lib/canonical'; import '@dailydotdev/shared/src/styles/globals.css'; import useLogPageView from '@dailydotdev/shared/src/hooks/log/useLogPageView'; @@ -410,7 +411,9 @@ export default function App( - + + +